Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d3d44020 | ||
|
|
f5f41a0921 | ||
|
|
af1780f2ca | ||
|
|
74fbd7f01f | ||
|
|
afa3e89119 | ||
|
|
c542884168 | ||
|
|
5f298e5b2a | ||
|
|
4deaa42ccb | ||
|
|
4bdb2ba38c | ||
|
|
71bd99b8b0 | ||
|
|
04c8fcd1df | ||
|
|
e77f1de940 | ||
|
|
c64972af77 | ||
|
|
657bd1d328 | ||
|
|
8e291759fc | ||
|
|
02df5207a9 | ||
|
|
23ce5c00ad | ||
|
|
8ee19f354d | ||
|
|
f62abc9ac2 | ||
|
|
967574f9d7 | ||
|
|
3017095449 | ||
|
|
89be1f52b2 | ||
|
|
bfd6d165a7 | ||
|
|
a72bb03757 | ||
|
|
47e77ae166 | ||
|
|
4c797d0e23 | ||
|
|
189ffe7362 | ||
|
|
2c19208224 | ||
|
|
5718811de0 | ||
|
|
af3c8b80b5 | ||
|
|
70d5d7e39b | ||
|
|
a5c32cdf30 | ||
|
|
15813336cc | ||
|
|
183d86b3e0 | ||
|
|
cd9a9cd8e5 | ||
|
|
5d8c44a393 | ||
|
|
2f19512341 | ||
|
|
f222bd26e7 | ||
|
|
38273676ea | ||
|
|
c1308ebf3f | ||
|
|
fa32af886f | ||
|
|
984e69ff62 | ||
|
|
e80754647c | ||
|
|
298bb93d39 | ||
|
|
eee1da45f0 | ||
|
|
6a30cfca82 | ||
|
|
888bf96025 | ||
|
|
383d44bc9a | ||
|
|
243cada157 | ||
|
|
af978ecb17 | ||
|
|
4eadef18a9 | ||
|
|
099146fedd | ||
|
|
e5580f43c2 | ||
|
|
5a4297a11a | ||
|
|
aea0b7397b | ||
|
|
311900842e | ||
|
|
105625d650 | ||
|
|
2ce3ae3d16 | ||
|
|
19c07c4037 | ||
|
|
ab55008631 | ||
|
|
1c055a4c58 | ||
|
|
095f526b11 | ||
|
|
9ca9697342 | ||
|
|
63a421d4c0 | ||
|
|
e4a1b35a39 |
@@ -63,3 +63,45 @@ data/
|
||||
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
||||
hermes-config/
|
||||
runtime/
|
||||
|
||||
# ---------- Not needed inside the Docker image ----------
|
||||
|
||||
# Desktop app source (Tauri/Electron); never installed in the container
|
||||
apps/
|
||||
|
||||
# Test suite — not shipped in production images
|
||||
tests/
|
||||
|
||||
# Documentation site (Docusaurus) and supplementary docs
|
||||
website/
|
||||
docs/
|
||||
|
||||
# Assets only used by the GitHub README
|
||||
assets/
|
||||
infographic/
|
||||
|
||||
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
|
||||
plugins/hermes-achievements/docs/
|
||||
|
||||
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
|
||||
nix/
|
||||
flake.nix
|
||||
flake.lock
|
||||
packaging/
|
||||
|
||||
# Design and planning documents
|
||||
plans/
|
||||
.plans/
|
||||
|
||||
# ACP registry manifest (icon + agent.json) — not consumed at runtime
|
||||
acp_registry/
|
||||
|
||||
# Repo-level dotfiles that are git-only or dev-tooling config
|
||||
.env.example
|
||||
.envrc
|
||||
.gitattributes
|
||||
.hadolint.yaml
|
||||
.mailmap
|
||||
|
||||
# Top-level LICENSE (not matched by *.md); not needed inside the container
|
||||
LICENSE
|
||||
|
||||
@@ -114,6 +114,12 @@ docs/superpowers/*
|
||||
# treat it as a local edit and autostash it on every run (#38529).
|
||||
.hermes-bootstrap-complete
|
||||
|
||||
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
|
||||
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
||||
# — ignore so `git status` stays clean and update's autostash skips them.
|
||||
.update-incomplete
|
||||
.update-incomplete.lock
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
@@ -26,6 +26,15 @@ reviewing any change:
|
||||
high. Most new capability should arrive as a CLI command + skill, a
|
||||
service-gated tool, or a plugin — not as core surface.
|
||||
|
||||
## Platform Support
|
||||
|
||||
Hermes Agent's platform support is formalized into three tiers. For the full tier breakdown, contribution guidelines, and user-facing migration policies, see the [Platform Support section in CONTRIBUTING.md](./CONTRIBUTING.md#platform-support) and the [Platform Support Reference](https://hermes-agent.nousresearch.com/docs/reference/platform-support).
|
||||
|
||||
**Summary for agents:**
|
||||
- **Explicitly Supported**: Linux (x86_64/arm64), macOS arm64, Windows (x86_64/arm64), Docker. Use `curl | bash`, PowerShell, or Docker installers.
|
||||
- **Best-Effort**: Termux, AUR, Homebrew, Nix. PRs accepted but won't block releases.
|
||||
- **Explicitly Unsupported**: macOS x86_64, pip/PyPI packaging, FreeBSD. Do not accept PRs for these.
|
||||
|
||||
## Contribution Rubric — What We Want / What We Don't
|
||||
|
||||
This is the project's intent layer. Use it two ways:
|
||||
@@ -123,6 +132,13 @@ conservative at the waist.
|
||||
without E2E proof, and plugins that touch core files.** Plugins live in their
|
||||
own directory and work within the ABCs/hooks we provide; if a plugin needs
|
||||
more, widen the generic plugin surface, don't special-case it in core.
|
||||
- **Dependency installation via uv helpers, not raw pip.** Hermes manages its
|
||||
own uv binary. Always use `ensure_uv()` or
|
||||
`resolve_uv()` from `hermes_cli.managed_uv`, or a dedicated helper like
|
||||
`_get_pip_cmd()` that returns `[uv_path, "pip"]`. **Do not** write
|
||||
`[sys.executable, "-m", "pip"]` or `[sys.executable, "-m", "ensurepip"]`
|
||||
directly in subprocess calls. The helpers guarantee the managed uv path and
|
||||
only fall back to raw pip in explicitly documented degenerate edge cases.
|
||||
|
||||
### Before you call it a bug — verify the premise (and when NOT to close)
|
||||
|
||||
|
||||
+33
-1
@@ -9,7 +9,7 @@ Thank you for contributing to Hermes Agent! This guide covers everything you nee
|
||||
We value contributions in this order:
|
||||
|
||||
1. **Bug fixes** — crashes, incorrect behavior, data loss. Always top priority.
|
||||
2. **Cross-platform compatibility** — macOS, different Linux distros, and WSL2 on Windows. We want Hermes to work everywhere.
|
||||
2. **Cross-platform compatibility** — See the [Platform Support](#platform-support) section below for tier-aware guidelines.
|
||||
3. **Security hardening** — shell injection, prompt injection, path traversal, privilege escalation. See [Security](#security-considerations).
|
||||
4. **Performance and robustness** — retry logic, error handling, graceful degradation.
|
||||
5. **New skills** — but only broadly useful ones. See [Should it be a Skill or a Tool?](#should-it-be-a-skill-or-a-tool)
|
||||
@@ -18,6 +18,38 @@ We value contributions in this order:
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
Hermes Agent's platform support is formalized into three tiers. This ensures we can maintain high quality and reliability while still welcoming community contributions.
|
||||
|
||||
### Explicitly Supported (Guaranteed)
|
||||
These platforms are fully supported, tested, and guaranteed to work. We provide first-party installers and prioritize fixes for these environments.
|
||||
|
||||
| Platform | Supported Installers |
|
||||
|----------|----------------------|
|
||||
| Linux (x86_64 / arm64) | `curl \| bash` installer, Docker image |
|
||||
| Latest Debian, Ubuntu, Fedora | `curl \| bash` installer |
|
||||
| Official Docker image | `docker pull` |
|
||||
| macOS (arm64 / Apple Silicon) | Desktop app installer, `curl \| bash` installer |
|
||||
| Windows (x86_64 / arm64) | Desktop app installer, PowerShell installer |
|
||||
|
||||
### Best-Effort Support
|
||||
We welcome community PRs for fixes on these platforms, and they generally work, but Nous will not prioritize them. We also do not accept packaging-specific code changes into the core repository for these platforms.
|
||||
|
||||
- **Termux / Android**: Community-supported. Best-effort fixes are welcome, but will not block Hermes releases.
|
||||
- **AUR Packaging**: Community-maintained.
|
||||
- **Homebrew Packaging**: Deprecated. See the [Platform Support docs](https://hermes-agent.nousresearch.com/docs/reference/platform-support) for migration.
|
||||
- **Nix Packaging**: The `flake.nix` and NixOS module are maintained in-tree as a primary deployment method. However, niche Nix-specific packaging bugs (e.g., a new dependency failing to build under Nix) are treated as best-effort.
|
||||
|
||||
### Explicitly Unsupported
|
||||
We do not accept PRs attempting to add or restore support for these platforms.
|
||||
|
||||
- **macOS (x86_64 / Intel)**: No longer supported.
|
||||
- **Packaging via pip / PyPI**: Deprecated and discontinued.
|
||||
- **FreeBSD**: Not supported.
|
||||
|
||||
---
|
||||
|
||||
## Should it be a Skill or a Tool?
|
||||
|
||||
This is the most common question for new contributors. The answer is almost always **skill**.
|
||||
|
||||
+20
-9
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
#
|
||||
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
||||
# installs the deps reachable through the composite `[all]` extra
|
||||
# (handpicked set intended for the production image), plus gateway
|
||||
# messaging adapters that should work in the published image without a
|
||||
# first-boot lazy install. We do NOT use `--all-extras`:
|
||||
# (handpicked set intended for the production image — excludes `[dev]`),
|
||||
# plus gateway messaging adapters that should work in the published image
|
||||
# without a first-boot lazy install. We do NOT use `--all-extras`:
|
||||
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
||||
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
||||
# redundancy), none of which belong in the published container.
|
||||
@@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
# image update and recall/retain then fails with
|
||||
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
||||
#
|
||||
# The Matrix gateway's deps ([matrix] extra) are baked in because
|
||||
# python-olm (transitive via mautrix[encryption]) builds from source on
|
||||
# Python/image combinations without usable wheels. The Docker image is
|
||||
# Linux-only, so keeping the native libolm/build-toolchain packages here
|
||||
# avoids the cross-platform failures that kept [matrix] out of [all]
|
||||
# while still making Matrix work in the published container. Fixes #30399.
|
||||
#
|
||||
# The editable link is created after the source copy below.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN touch ./README.md
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
|
||||
|
||||
# ---------- Frontend build (cached independently from Python source) ----------
|
||||
# Copy only the frontend source trees first so that Python-only changes don't
|
||||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
COPY --chown=hermes:hermes . .
|
||||
|
||||
# Build browser dashboard and terminal UI assets.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
# The venv needs to be traversable too.
|
||||
|
||||
@@ -234,7 +234,8 @@ def main(argv: list[str] | None = None) -> None:
|
||||
logger.info("Starting hermes-agent ACP adapter")
|
||||
|
||||
# Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = str(get_hermes_source_root())
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
|
||||
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
|
||||
return _nous_extra_body() if auxiliary_is_nous else {}
|
||||
|
||||
|
||||
def auxiliary_max_tokens_param(value: int) -> dict:
|
||||
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
|
||||
"""Return the correct max tokens kwarg for the auxiliary client's provider.
|
||||
|
||||
|
||||
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
|
||||
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
|
||||
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
|
||||
The Codex adapter translates max_tokens internally, so we use max_tokens
|
||||
for it as well.
|
||||
for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
|
||||
fronting the newer families are also recognised — URL-only detection
|
||||
misses the case where a custom base URL serves e.g. ``gpt-5.4``.
|
||||
"""
|
||||
custom_base = _current_custom_base_url()
|
||||
or_key = os.getenv("OPENROUTER_API_KEY")
|
||||
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
|
||||
and _read_nous_auth() is None
|
||||
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
|
||||
return {"max_completion_tokens": value}
|
||||
# ...and for any caller serving a newer OpenAI-family model by name.
|
||||
if model_forces_max_completion_tokens(model):
|
||||
return {"max_completion_tokens": value}
|
||||
return {"max_tokens": value}
|
||||
|
||||
|
||||
|
||||
+2
-15
@@ -25,7 +25,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -33,6 +32,7 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools import skill_usage
|
||||
from utils import atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]:
|
||||
def save_state(data: Dict[str, Any]) -> None:
|
||||
path = _state_file()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
atomic_json_write(path, data, indent=2, sort_keys=True)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
||||
|
||||
|
||||
@@ -966,6 +966,34 @@ def _classify_400(
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
# Request-validation errors (unsupported / unknown parameter) MUST be
|
||||
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
|
||||
# returns:
|
||||
# "Unsupported parameter: 'max_tokens' is not supported with this model.
|
||||
# Use 'max_completion_tokens' instead."
|
||||
# That string contains the literal substring "max_tokens", which is one of
|
||||
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
|
||||
# misclassified as context_overflow, routed into the compression loop,
|
||||
# re-sent with the same bad parameter, and ends in "Cannot compress
|
||||
# further". These errors are deterministic (every retry gets the identical
|
||||
# rejection), so classify as a non-retryable format_error and fall back.
|
||||
#
|
||||
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
|
||||
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
|
||||
# so matching it would mis-route real overflows away from compression. The
|
||||
# unambiguous signals are the explicit "unsupported/unknown parameter"
|
||||
# message text and the specific parameter-level error codes.
|
||||
if (
|
||||
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
|
||||
if p != "invalid_request_error")
|
||||
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
|
||||
):
|
||||
return result_fn(
|
||||
FailoverReason.format_error,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Context overflow from 400
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
|
||||
+2
-1
@@ -115,7 +115,8 @@ def _locales_dir() -> Path:
|
||||
)
|
||||
|
||||
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
|
||||
source_dir = Path(__file__).resolve().parent.parent / "locales"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
source_dir = get_hermes_source_root() / "locales"
|
||||
if source_dir.is_dir():
|
||||
return source_dir
|
||||
|
||||
|
||||
@@ -343,8 +343,11 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
pip_target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
||||
# pip_install() can't be used here — needs --target to install outside
|
||||
# the venv into a custom staging dir for LSP tool console scripts.
|
||||
from hermes_cli.managed_uv import get_pip_cmd
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
||||
get_pip_cmd() + ["install", "--target", str(pip_target), "--quiet", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -1838,6 +1838,17 @@ def get_model_context_length(
|
||||
from agent.models_dev import lookup_models_dev_context
|
||||
ctx = lookup_models_dev_context(effective_provider, model)
|
||||
if ctx:
|
||||
# MiniMax M3: models.dev reports 512K but actual context is 1M.
|
||||
# Prefer hardcoded catalog over stale probe value.
|
||||
if _model_name_suggests_minimax_m3(model):
|
||||
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
|
||||
if catalog and ctx < catalog:
|
||||
logger.info(
|
||||
"Rejecting models.dev context=%s for %r "
|
||||
"(MiniMax-M3 underreport); using hardcoded default %s",
|
||||
ctx, model, f"{catalog:,}",
|
||||
)
|
||||
ctx = catalog
|
||||
return ctx
|
||||
|
||||
# 6. OpenRouter live API metadata — provider-unaware fallback.
|
||||
|
||||
@@ -13,6 +13,7 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
|
||||
|
||||
_ZERO = Decimal("0")
|
||||
_ONE_MILLION = Decimal("1000000")
|
||||
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
|
||||
CostStatus = Literal["actual", "estimated", "included", "unknown"]
|
||||
CostSource = Literal[
|
||||
@@ -570,6 +571,8 @@ def resolve_billing_route(
|
||||
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
|
||||
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
|
||||
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
|
||||
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
|
||||
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
|
||||
if provider_name == "anthropic":
|
||||
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name == "openai":
|
||||
|
||||
@@ -40,6 +40,15 @@ const path = require('node:path')
|
||||
const https = require('node:https')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
@@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
const child = spawn(ps, fullArgs, {
|
||||
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
// choice rather than re-computing the default.
|
||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
@@ -107,6 +107,13 @@ const IS_WINDOWS = process.platform === 'win32'
|
||||
const IS_WSL = isWslEnvironment()
|
||||
const APP_ROOT = app.getAppPath()
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||
@@ -1106,7 +1113,7 @@ function findSystemPython() {
|
||||
const out = execFileSync(
|
||||
'reg',
|
||||
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
)
|
||||
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
||||
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
||||
@@ -1142,10 +1149,10 @@ function findSystemPython() {
|
||||
if (pyExe) {
|
||||
for (const version of SUPPORTED_VERSIONS) {
|
||||
try {
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
}))
|
||||
const candidate = out.trim()
|
||||
if (candidate && fileExists(candidate)) return candidate
|
||||
} catch {
|
||||
@@ -1280,11 +1287,11 @@ function resolveUpdateRoot() {
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -1494,7 +1501,7 @@ function forceKillProcessTree(pid) {
|
||||
if (!IS_WINDOWS) return
|
||||
if (!Number.isInteger(pid) || pid <= 0) return
|
||||
try {
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
|
||||
} catch {
|
||||
// Already gone, or no permission — best effort; the unlock wait below is
|
||||
// the real gate.
|
||||
@@ -1680,11 +1687,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
return
|
||||
@@ -2671,7 +2678,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
|
||||
'--raw',
|
||||
url
|
||||
]
|
||||
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
|
||||
const chunks = []
|
||||
let bytes = 0
|
||||
|
||||
@@ -4491,7 +4498,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const child = spawn(backend.command, backend.args, {
|
||||
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -4509,7 +4516,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
@@ -4691,7 +4698,7 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
|
||||
hermesProcess = spawn(backend.command, backend.args, {
|
||||
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -4714,7 +4721,7 @@ async function startHermes() {
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
@@ -5986,11 +5993,11 @@ async function getUninstallSummary() {
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
}))
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const ELECTRON_DIR = __dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const index = source.indexOf(needle)
|
||||
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||
const snippet = source.slice(index, index + 700)
|
||||
assert.match(
|
||||
snippet,
|
||||
/hiddenWindowsChildOptions\(/,
|
||||
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
||||
)
|
||||
}
|
||||
|
||||
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, 'execFileSync(pyExe')
|
||||
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
|
||||
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||
requireHiddenChildOptions(source, 'spawn(command, args')
|
||||
requireHiddenChildOptions(source, "spawn('curl'")
|
||||
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.cjs')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
|
||||
})
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -722,8 +722,14 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
// edit composer render the same bubble surface (rounded glass card);
|
||||
// they only differ in border weight, cursor, and padding-right (the
|
||||
// read-only view reserves room for the restore icon).
|
||||
//
|
||||
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
|
||||
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
|
||||
// drag regions at the compositor level — z-index and pointer-events don't help —
|
||||
// so without the carve-out, clicking a stuck bubble drags the window instead of
|
||||
// opening the edit composer.
|
||||
const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
|
||||
|
||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
|
||||
const node = status ? (
|
||||
statusGlyph(status, copy)
|
||||
) : icon ? (
|
||||
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
) : null
|
||||
|
||||
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
|
||||
// Clicking a user bubble must open the inline edit composer — through the
|
||||
// app's incremental external-store runtime (which reimplements capability
|
||||
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
|
||||
//
|
||||
// Note: this covers the React/runtime wiring only. The Electron-level failure
|
||||
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
|
||||
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
|
||||
// carve-out in thread.tsx.
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
|
||||
import { Thread } from './thread'
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
function userMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'edit me please' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
|
||||
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: repository,
|
||||
isRunning: false,
|
||||
setMessages: () => {},
|
||||
onNew: async () => {},
|
||||
onEdit,
|
||||
onCancel: async () => {},
|
||||
onReload: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Control: stock external store runtime.
|
||||
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage()],
|
||||
isRunning: false,
|
||||
onNew: async () => {},
|
||||
onEdit
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('click-to-edit user message', () => {
|
||||
it('opens the edit composer with the incremental runtime', async () => {
|
||||
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the edit composer with the stock runtime', async () => {
|
||||
const { container } = render(<StockHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
|
||||
// *font*, so an outline glyph has no separate fillable region — a filled look
|
||||
// can't be derived from it (stroke-thickening just bolds the outline). To get
|
||||
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
|
||||
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
|
||||
//
|
||||
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
|
||||
// path data mirrors the existing precedent in `directive-text.tsx`.
|
||||
const TOOL_ICON_PATHS: Record<string, string> = {
|
||||
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
|
||||
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
|
||||
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
|
||||
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
|
||||
'file-media':
|
||||
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
|
||||
files:
|
||||
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
|
||||
globe:
|
||||
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
|
||||
question:
|
||||
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
|
||||
search:
|
||||
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
|
||||
terminal:
|
||||
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
|
||||
tools:
|
||||
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
|
||||
watch:
|
||||
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
|
||||
}
|
||||
|
||||
export interface ToolIconProps {
|
||||
className?: string
|
||||
name: string
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
/** Filled tool glyph. Falls back to the outline codicon font for any name not
|
||||
* covered by the solid set so new tools still render an icon. */
|
||||
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
|
||||
const path = TOOL_ICON_PATHS[name]
|
||||
|
||||
if (!path) {
|
||||
return <Codicon className={className} name={name} size={size} />
|
||||
}
|
||||
|
||||
const dimension: React.CSSProperties = { height: size, width: size }
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn('shrink-0', className)}
|
||||
fill="currentColor"
|
||||
style={dimension}
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+24
-1
@@ -415,7 +415,8 @@ prompt_caching:
|
||||
# Auxiliary Models (Advanced — Experimental)
|
||||
# =============================================================================
|
||||
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
|
||||
# browser screenshot analysis, web page summarization, and context compression.
|
||||
# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
|
||||
# and context compression.
|
||||
#
|
||||
# By default these use Gemini Flash via OpenRouter or Nous Portal and are
|
||||
# auto-detected from your credentials. You do NOT need to change anything
|
||||
@@ -460,6 +461,12 @@ prompt_caching:
|
||||
# provider: "auto"
|
||||
# model: ""
|
||||
#
|
||||
# # Gemini 3.1 TTS hidden audio-tag insertion
|
||||
# tts_audio_tags:
|
||||
# provider: "auto" # empty model = your main chat model
|
||||
# model: ""
|
||||
# timeout: 30
|
||||
#
|
||||
# # Session search — summarizes matching past sessions
|
||||
# session_search:
|
||||
# provider: "auto"
|
||||
@@ -835,6 +842,22 @@ platform_toolsets:
|
||||
# max_tool_rounds: 5 # tool loop limit (0 = disable)
|
||||
# log_level: "info" # audit verbosity
|
||||
|
||||
# =============================================================================
|
||||
# Text-to-Speech
|
||||
# =============================================================================
|
||||
# TTS defaults to Edge TTS unless changed in ~/.hermes/config.yaml.
|
||||
# Gemini TTS supports persona/director prompt files, and Gemini 3.1 Flash TTS
|
||||
# can use a hidden auxiliary rewrite pass to insert expressive square-bracket
|
||||
# audio tags into the TTS script without showing tags in chat.
|
||||
#
|
||||
# tts:
|
||||
# provider: "gemini"
|
||||
# gemini:
|
||||
# model: "gemini-3.1-flash-tts-preview"
|
||||
# voice: "Kore"
|
||||
# audio_tags: false
|
||||
# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
|
||||
|
||||
# =============================================================================
|
||||
# Voice Transcription (Speech-to-Text)
|
||||
# =============================================================================
|
||||
|
||||
@@ -6516,6 +6516,47 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
}
|
||||
self._invalidate(min_interval=0.0)
|
||||
|
||||
def _confirm_expensive_model_switch(self, result) -> bool:
|
||||
"""Ask for explicit confirmation before applying costly model switches."""
|
||||
if not getattr(result, "success", False):
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
warning = expensive_model_warning(
|
||||
result.new_model,
|
||||
provider=result.target_provider,
|
||||
base_url=result.base_url or self.base_url or "",
|
||||
api_key=result.api_key or self.api_key or "",
|
||||
model_info=result.model_info,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
if warning is None:
|
||||
return True
|
||||
|
||||
choices = [
|
||||
("once", "Switch anyway", "Use this model for the current Hermes session."),
|
||||
("cancel", "Cancel", "Keep the current model."),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="!!! Expensive Model Warning !!!",
|
||||
detail=warning.message,
|
||||
choices=choices,
|
||||
timeout=120,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||
return choice == "once"
|
||||
|
||||
def _confirm_and_apply_model_switch_result(self, result, persist_global: bool) -> None:
|
||||
try:
|
||||
if result.success and not self._confirm_expensive_model_switch(result):
|
||||
_cprint(" Model switch cancelled.")
|
||||
return
|
||||
self._apply_model_switch_result(result, persist_global)
|
||||
except Exception as exc:
|
||||
_cprint(f" ✗ Model selection failed: {exc}")
|
||||
|
||||
def _close_model_picker(self) -> None:
|
||||
self._model_picker_state = None
|
||||
self._restore_modal_input_snapshot()
|
||||
@@ -6692,7 +6733,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
custom_providers=state.get("custom_provs"),
|
||||
)
|
||||
self._close_model_picker()
|
||||
self._apply_model_switch_result(result, persist_global)
|
||||
if getattr(self, "_app", None):
|
||||
threading.Thread(
|
||||
target=self._confirm_and_apply_model_switch_result,
|
||||
args=(result, persist_global),
|
||||
daemon=True,
|
||||
).start()
|
||||
else:
|
||||
self._confirm_and_apply_model_switch_result(result, persist_global)
|
||||
return
|
||||
self._close_model_picker()
|
||||
|
||||
@@ -6793,6 +6841,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
_cprint(f" ✗ {result.error_message}")
|
||||
return
|
||||
|
||||
if not self._confirm_expensive_model_switch(result):
|
||||
_cprint(" Model switch cancelled.")
|
||||
return
|
||||
|
||||
# Apply to CLI state.
|
||||
# Update requested_provider so _ensure_runtime_credentials() doesn't
|
||||
# overwrite the switch on the next turn (it re-resolves from this).
|
||||
@@ -8829,7 +8881,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Voice mode requires sounddevice and numpy.\n"
|
||||
f"Install with: {sys.executable} -m pip install sounddevice numpy"
|
||||
f"Install with: uv pip install sounddevice numpy"
|
||||
)
|
||||
if not reqs.get("stt_available", reqs.get("stt_key_set")):
|
||||
raise RuntimeError(
|
||||
@@ -9133,7 +9185,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
|
||||
_cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}")
|
||||
else:
|
||||
_cprint(f"\n {_BOLD}Install: {sys.executable} -m pip install {' '.join(reqs['missing_packages'])}{_RST}")
|
||||
_cprint(f"\n {_BOLD}Install: uv pip install {' '.join(reqs['missing_packages'])}{_RST}")
|
||||
return
|
||||
|
||||
with self._voice_lock:
|
||||
|
||||
@@ -36,6 +36,7 @@ from typing import List, Optional
|
||||
# Add parent directory to path for imports BEFORE repo-level imports.
|
||||
# Without this, standalone invocations (e.g. after `hermes update` reloads
|
||||
# the module) fail with ModuleNotFoundError for hermes_time et al.
|
||||
# Bootstrap sys.path before imports — cannot use get_hermes_source_root() here yet.
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
@@ -207,6 +207,14 @@ class GatewayAuthorizationMixin:
|
||||
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
|
||||
return True
|
||||
|
||||
# Adapter-verified role auth: the Discord adapter already confirmed the
|
||||
# user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.
|
||||
# Compare with ``is True`` so the real bool field authorizes while a
|
||||
# MagicMock source (test fixtures using ``object.__new__`` runners with
|
||||
# mock sources) does not auto-truthy through this gate (see pitfall #13).
|
||||
if getattr(source, "role_authorized", False) is True:
|
||||
return True
|
||||
|
||||
if getattr(source, "is_bot", False):
|
||||
allow_bots_var = platform_allow_bots_map.get(source.platform)
|
||||
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
|
||||
|
||||
+2
-1
@@ -213,7 +213,8 @@ class Platform(Enum):
|
||||
"""Return names of bundled platform plugins under ``plugins/platforms/``."""
|
||||
names: set = set()
|
||||
try:
|
||||
platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
platforms_dir = get_hermes_source_root() / "plugins" / "platforms"
|
||||
if platforms_dir.is_dir():
|
||||
for child in platforms_dir.iterdir():
|
||||
if (
|
||||
|
||||
@@ -33,6 +33,7 @@ _AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
|
||||
# delivered as a regular document.
|
||||
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
|
||||
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
|
||||
_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def _platform_name(platform) -> str:
|
||||
@@ -1803,6 +1804,18 @@ class BasePlatformAdapter(ABC):
|
||||
# preview (see gateway/run.py progress_callback).
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
# The command prefix users can always TYPE on this platform to reach
|
||||
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
|
||||
# as plain message text). Platforms where typing a leading "/" is
|
||||
# intercepted or restricted by the client (Slack blocks native slash
|
||||
# commands inside threads; Matrix clients reserve "/" for client-local
|
||||
# commands) ship a "!" alias rewrite in their adapter and set this to
|
||||
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
|
||||
# users the form that actually works everywhere. Capability flag —
|
||||
# shared prompt builders read it via getattr(adapter,
|
||||
# "typed_command_prefix", "/"); no per-platform branching at call sites.
|
||||
typed_command_prefix: str = "/"
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
self.config = config
|
||||
self.platform = platform
|
||||
@@ -4462,6 +4475,15 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception:
|
||||
pass # Last resort — don't let error reporting crash the handler
|
||||
finally:
|
||||
# Stop typing before any deferred callback work. Post-delivery
|
||||
# callbacks may perform platform I/O; a stuck callback must not
|
||||
# leave the typing refresh task running indefinitely.
|
||||
await _stop_typing_task()
|
||||
try:
|
||||
if hasattr(self, "stop_typing"):
|
||||
await self.stop_typing(event.source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Fire any one-shot post-delivery callback registered for this
|
||||
# session (e.g. deferred background-review notifications).
|
||||
#
|
||||
@@ -4489,11 +4511,12 @@ class BasePlatformAdapter(ABC):
|
||||
try:
|
||||
_post_result = _post_cb()
|
||||
if inspect.isawaitable(_post_result):
|
||||
await _post_result
|
||||
except Exception:
|
||||
await asyncio.wait_for(
|
||||
_post_result,
|
||||
timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
# Stop typing indicator
|
||||
await _stop_typing_task()
|
||||
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
|
||||
# that may have been recreated by _keep_typing after the last stop_typing()
|
||||
try:
|
||||
@@ -4651,6 +4674,7 @@ class BasePlatformAdapter(ABC):
|
||||
guild_id: Optional[str] = None,
|
||||
parent_chat_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
role_authorized: bool = False,
|
||||
) -> SessionSource:
|
||||
"""Helper to build a SessionSource for this platform."""
|
||||
# Normalize empty topic to None
|
||||
@@ -4671,6 +4695,7 @@ class BasePlatformAdapter(ABC):
|
||||
guild_id=str(guild_id) if guild_id else None,
|
||||
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
|
||||
message_id=str(message_id) if message_id else None,
|
||||
role_authorized=role_authorized,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -422,6 +422,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
|
||||
|
||||
# Matrix clients commonly reserve typed "/" for client-local commands;
|
||||
# the adapter accepts "!command" as the alias that always reaches Hermes
|
||||
# (see _normalize_matrix_bang_command), so instruction text shows "!".
|
||||
typed_command_prefix = "!"
|
||||
|
||||
# Threshold for detecting Matrix client-side message splits.
|
||||
# When a chunk is near the ~4000-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1350,11 +1355,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"⚠️ **Dangerous command requires approval**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {description}\n\n"
|
||||
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
|
||||
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
|
||||
"You can also click the reaction to approve:\n"
|
||||
"✅ = /approve\n"
|
||||
"❎ = /deny"
|
||||
"✅ = approve\n"
|
||||
"❎ = deny"
|
||||
)
|
||||
|
||||
result = await self.send(chat_id, text, metadata=metadata)
|
||||
|
||||
@@ -318,6 +318,11 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
|
||||
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
|
||||
# Slack blocks typed native slash commands inside threads ("/approve is
|
||||
# not supported in threads. Sorry!"). The adapter rewrites a leading
|
||||
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
|
||||
# the prefix that works everywhere — instruction text must show it.
|
||||
typed_command_prefix = "!"
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.SLACK)
|
||||
@@ -2692,19 +2697,26 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
|
||||
# Slack hard-caps a section block's text at 3000 chars; an
|
||||
# oversized block fails the whole send with ``invalid_blocks``
|
||||
# and the gateway falls back to the plain-text prompt (no
|
||||
# buttons). execute_code approvals embed the entire script in
|
||||
# ``command``, so budget the preview against the fixed parts
|
||||
# instead of a flat truncation that overflows once the header +
|
||||
# reason are added.
|
||||
header = ":warning: *Command Approval Required*\n"
|
||||
reason = f"Reason: {description[:500]}"
|
||||
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
|
||||
cmd_preview = command[:budget] + "..." if len(command) > budget else command
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f":warning: *Command Approval Required*\n"
|
||||
f"```{cmd_preview}```\n"
|
||||
f"Reason: {description}"
|
||||
),
|
||||
"text": f"{header}```{cmd_preview}```\n{reason}",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2772,8 +2784,13 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
body = message[:2900] + "..." if len(message) > 2900 else message
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
# Same 3000-char section-block cap as send_exec_approval: budget
|
||||
# the body against the rendered title so the wrapper never pushes
|
||||
# the block over the limit (overflow → invalid_blocks → no buttons).
|
||||
_title = (title or "Confirm")[:150]
|
||||
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
|
||||
body = message[:budget] + "..." if len(message) > budget else message
|
||||
# Encode session_key and confirm_id into the button value so the
|
||||
# callback handler can resolve without extra bookkeeping.
|
||||
value = f"{session_key}|{confirm_id}"
|
||||
@@ -2783,7 +2800,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{title or 'Confirm'}*\n\n{body}",
|
||||
"text": f"*{_title}*\n\n{body}",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3030,7 +3030,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
async def _handle_model_picker_callback(
|
||||
self, query, data: str, chat_id: str
|
||||
) -> None:
|
||||
"""Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
|
||||
"""Handle model picker inline keyboard callbacks (mp:/mm:/mc:/mb:/mx:/mg:)."""
|
||||
state = self._model_picker_state.get(chat_id)
|
||||
if not state:
|
||||
await query.answer(text="Picker expired — use /model again.")
|
||||
@@ -3115,6 +3115,55 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
await query.answer()
|
||||
|
||||
elif data.startswith("mc:"):
|
||||
# --- Expensive model confirmed: perform the switch ---
|
||||
try:
|
||||
idx = int(data[3:])
|
||||
except ValueError:
|
||||
await query.answer(text="Invalid selection.")
|
||||
return
|
||||
|
||||
model_list = state.get("model_list", [])
|
||||
if idx < 0 or idx >= len(model_list):
|
||||
await query.answer(text="Invalid model index.")
|
||||
return
|
||||
|
||||
model_id = model_list[idx]
|
||||
provider_slug = state.get("selected_provider", "")
|
||||
callback = state.get("on_model_selected")
|
||||
|
||||
if not callback:
|
||||
await query.answer(text="Picker expired.")
|
||||
return
|
||||
|
||||
switch_failed = False
|
||||
try:
|
||||
result_text = await callback(chat_id, model_id, provider_slug)
|
||||
except Exception as exc:
|
||||
logger.error("Model picker switch failed: %s", exc)
|
||||
result_text = f"Error switching model: {exc}"
|
||||
switch_failed = True
|
||||
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(result_text),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=result_text,
|
||||
parse_mode=None,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await query.answer(
|
||||
text="Switch failed." if switch_failed else "Model switched!"
|
||||
)
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
|
||||
elif data.startswith("mm:"):
|
||||
# --- Model selected: perform the switch ---
|
||||
try:
|
||||
@@ -3136,11 +3185,43 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
await query.answer(text="Picker expired.")
|
||||
return
|
||||
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
# Pricing lookup can hit models.dev / a /models endpoint on a
|
||||
# cache miss — keep it off the event loop.
|
||||
warning = await asyncio.to_thread(
|
||||
expensive_model_warning,
|
||||
model_id,
|
||||
provider=provider_slug,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
if warning is not None:
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton("Switch anyway", callback_data=f"mc:{idx}")],
|
||||
[
|
||||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||||
],
|
||||
])
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(
|
||||
f"⚠ *Expensive Model Warning*\n\n{warning.message}"
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer(text="Confirm expensive model")
|
||||
return
|
||||
|
||||
switch_failed = False
|
||||
try:
|
||||
result_text = await callback(chat_id, model_id, provider_slug)
|
||||
except Exception as exc:
|
||||
logger.error("Model picker switch failed: %s", exc)
|
||||
result_text = f"Error switching model: {exc}"
|
||||
switch_failed = True
|
||||
|
||||
# Edit message to show confirmation, remove buttons
|
||||
try:
|
||||
@@ -3159,7 +3240,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await query.answer(text="Model switched!")
|
||||
await query.answer(
|
||||
text="Switch failed." if switch_failed else "Model switched!"
|
||||
)
|
||||
|
||||
# Clean up state
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
@@ -3260,7 +3343,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mc:", "mb", "mx", "mg:")):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
|
||||
+36
-13
@@ -895,7 +895,8 @@ os.environ["_HERMES_GATEWAY"] = "1"
|
||||
_ensure_ssl_certs()
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from hermes_constants import get_hermes_source_root
|
||||
sys.path.insert(0, str(get_hermes_source_root()))
|
||||
|
||||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -1551,7 +1552,8 @@ def _check_unavailable_skill(command_name: str) -> str | None:
|
||||
|
||||
# Check optional skills (shipped with repo but not installed)
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_root = get_hermes_source_root()
|
||||
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
|
||||
if optional_dir.exists():
|
||||
for skill_md in optional_dir.rglob("SKILL.md"):
|
||||
@@ -6085,7 +6087,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
elif platform == Platform.SLACK:
|
||||
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
|
||||
if not check_slack_requirements():
|
||||
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
|
||||
logger.warning("Slack: slack-bolt not installed. Run: uv pip install -e '.[slack]' (from the hermes-agent checkout)")
|
||||
return None
|
||||
return SlackAdapter(config)
|
||||
|
||||
@@ -6473,6 +6475,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_tool_approval_live = False
|
||||
if _pending_confirm and not _tool_approval_live:
|
||||
_raw_reply = (event.text or "").strip()
|
||||
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim.
|
||||
# Slack/Matrix instruction text shows the `!` prefix (typed `/`
|
||||
# is blocked in Slack threads), but the adapters only rewrite
|
||||
# `!<known-command>` — `always`/`cancel` are confirm keywords,
|
||||
# not registered commands, so the `!` survives to here.
|
||||
_norm_reply = _raw_reply.lstrip("!/").lower()
|
||||
_cmd_reply = event.get_command()
|
||||
_confirm_choice = None
|
||||
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
|
||||
@@ -6481,11 +6489,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_confirm_choice = "always"
|
||||
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
|
||||
_confirm_choice = "cancel"
|
||||
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
|
||||
elif _norm_reply in {"approve", "approve once", "once"}:
|
||||
_confirm_choice = "once"
|
||||
elif _raw_reply.lower() in {"always", "always approve"}:
|
||||
elif _norm_reply in {"always", "always approve"}:
|
||||
_confirm_choice = "always"
|
||||
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
|
||||
elif _norm_reply in {"cancel", "nevermind", "no"}:
|
||||
_confirm_choice = "cancel"
|
||||
if _confirm_choice is not None:
|
||||
_resolved = await _slash_confirm_mod.resolve(
|
||||
@@ -7059,6 +7067,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "memory":
|
||||
return await self._handle_memory_command(event)
|
||||
|
||||
if canonical == "skills":
|
||||
return await self._handle_skills_command(event)
|
||||
|
||||
if canonical == "fast":
|
||||
return await self._handle_fast_command(event)
|
||||
|
||||
@@ -9367,6 +9378,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
adapter._voice_input_callback = self._handle_voice_channel_input
|
||||
if hasattr(adapter, "_on_voice_disconnect"):
|
||||
adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
|
||||
# Let the adapter's inactivity timer see the live voice-reply mode so it
|
||||
# doesn't disconnect a deliberately text-only (/voice off) session.
|
||||
if hasattr(adapter, "_voice_mode_getter"):
|
||||
adapter._voice_mode_getter = lambda chat_id: self._voice_mode.get(
|
||||
self._voice_key(Platform.DISCORD, str(chat_id)), "off"
|
||||
)
|
||||
|
||||
try:
|
||||
success = await adapter.join_voice_channel(voice_channel)
|
||||
@@ -9377,7 +9394,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower:
|
||||
return (
|
||||
"Voice dependencies are missing (PyNaCl / davey). "
|
||||
f"Install with: `{sys.executable} -m pip install PyNaCl`"
|
||||
f"Install with: `uv pip install PyNaCl`"
|
||||
)
|
||||
return f"Failed to join voice channel: {e}"
|
||||
|
||||
@@ -10622,6 +10639,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
return result
|
||||
return result
|
||||
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
prompt_message = (
|
||||
f"⚠️ **Confirm /{command}**\n\n"
|
||||
f"{detail}\n\n"
|
||||
@@ -10629,7 +10647,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"• **Approve Once** — proceed this time only\n"
|
||||
"• **Always Approve** — proceed and silence this prompt permanently\n"
|
||||
"• **Cancel** — keep current conversation\n\n"
|
||||
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
|
||||
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
|
||||
)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
@@ -11024,11 +11042,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
logger.debug("Button-based update prompt failed: %s", btn_err)
|
||||
if not sent_buttons:
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
_p = getattr(adapter, "typed_command_prefix", "/")
|
||||
await adapter.send(
|
||||
chat_id,
|
||||
f"⚕ **Update needs your input:**\n\n"
|
||||
f"{prompt_text}{default_hint}\n\n"
|
||||
f"Reply `/approve` (yes) or `/deny` (no), "
|
||||
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
|
||||
f"or type your answer directly.",
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -11538,7 +11557,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# when we successfully transcribed the audio — it's redundant.
|
||||
_placeholder = "(The user sent a message with no text content)"
|
||||
if user_text and user_text.strip() == _placeholder:
|
||||
return prefix
|
||||
return prefix, successful_transcripts
|
||||
if user_text:
|
||||
return f"{prefix}\n\n{user_text}", successful_transcripts
|
||||
return prefix, successful_transcripts
|
||||
@@ -14095,14 +14114,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Button-based approval failed, falling back to text: %s", _e
|
||||
)
|
||||
|
||||
# Fallback: plain text approval prompt
|
||||
# Fallback: plain text approval prompt. Use the adapter's
|
||||
# typed prefix so Slack/Matrix users are told the form they
|
||||
# can actually type (`!approve`) — typed "/" is blocked in
|
||||
# Slack threads and reserved by Matrix clients.
|
||||
_p = getattr(_status_adapter, "typed_command_prefix", "/")
|
||||
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
|
||||
msg = (
|
||||
f"⚠️ **Dangerous command requires approval:**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {desc}\n\n"
|
||||
f"Reply `/approve` to execute, `/approve session` to approve this pattern "
|
||||
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
|
||||
f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern "
|
||||
f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel."
|
||||
)
|
||||
try:
|
||||
_approval_send_fut = safe_schedule_threadsafe(
|
||||
|
||||
@@ -91,6 +91,7 @@ class SessionSource:
|
||||
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
|
||||
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
|
||||
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
|
||||
role_authorized: bool = False # True when adapter granted access via role (not user ID)
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
|
||||
+263
-142
@@ -47,6 +47,19 @@ logger = logging.getLogger("gateway.run")
|
||||
class GatewaySlashCommandsMixin:
|
||||
"""In-session slash-command handlers for GatewayRunner."""
|
||||
|
||||
def _typed_command_prefix_for(self, platform) -> str:
|
||||
"""Return the prefix users can always type to reach Hermes commands.
|
||||
|
||||
Reads the adapter's ``typed_command_prefix`` capability flag
|
||||
(default "/"). Slack and Matrix return "!" because typed "/"
|
||||
commands are blocked in Slack threads / reserved by Matrix clients;
|
||||
their adapters rewrite "!command" to "/command" on receive.
|
||||
Instruction text built for those platforms must show the prefix
|
||||
that actually works when typed.
|
||||
"""
|
||||
adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
|
||||
return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
|
||||
|
||||
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /new or /reset command."""
|
||||
source = event.source
|
||||
@@ -1146,149 +1159,198 @@ class GatewaySlashCommandsMixin:
|
||||
if not result.success:
|
||||
return t("gateway.model.error_prefix", error=result.error_message)
|
||||
|
||||
# If there's a cached agent, update it in-place
|
||||
cached_entry = None
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
cached_entry = _cache.get(session_key)
|
||||
async def _finish_switch() -> str:
|
||||
"""Apply the resolved switch (agent, session, config) and build the reply."""
|
||||
# If there's a cached agent, update it in-place
|
||||
cached_entry = None
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
cached_entry = _cache.get(session_key)
|
||||
|
||||
if cached_entry and cached_entry[0] is not None:
|
||||
if cached_entry and cached_entry[0] is not None:
|
||||
try:
|
||||
cached_entry[0].switch_model(
|
||||
new_model=result.new_model,
|
||||
new_provider=result.target_provider,
|
||||
api_key=result.api_key,
|
||||
base_url=result.base_url,
|
||||
api_mode=result.api_mode,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("In-place model switch failed for cached agent: %s", exc)
|
||||
|
||||
# Persist the new model to the session DB so the dashboard
|
||||
# shows the updated model (#34850).
|
||||
_sess_db = getattr(self, "_session_db", None)
|
||||
if _sess_db is not None:
|
||||
try:
|
||||
_sess_entry = self.session_store.get_or_create_session(source)
|
||||
_sess_db.update_session_model(
|
||||
_sess_entry.session_id, result.new_model
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to persist model switch to DB: %s", exc
|
||||
)
|
||||
|
||||
# Store a note to prepend to the next user message so the model
|
||||
# knows about the switch (avoids system messages mid-history).
|
||||
if not hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes = {}
|
||||
self._pending_model_notes[session_key] = (
|
||||
f"[Note: model was just switched from {current_model} to {result.new_model} "
|
||||
f"via {result.provider_label or result.target_provider}. "
|
||||
f"Adjust your self-identification accordingly.]"
|
||||
)
|
||||
|
||||
# Store session override so next agent creation uses the new model
|
||||
self._session_model_overrides[session_key] = {
|
||||
"model": result.new_model,
|
||||
"provider": result.target_provider,
|
||||
"api_key": result.api_key,
|
||||
"base_url": result.base_url,
|
||||
"api_mode": result.api_mode,
|
||||
}
|
||||
|
||||
# Evict cached agent so the next turn creates a fresh agent from the
|
||||
# override rather than relying on cache signature mismatch detection.
|
||||
self._evict_cached_agent(session_key)
|
||||
|
||||
# Persist to config if --global
|
||||
if persist_global:
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
else:
|
||||
cfg = {}
|
||||
# Coerce scalar/None ``model:`` into a dict before mutation —
|
||||
# otherwise ``cfg.setdefault("model", {})`` returns the existing
|
||||
# scalar and the next assignment raises
|
||||
# ``TypeError: 'str' object does not support item assignment``.
|
||||
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
|
||||
# string) instead of the proper nested ``model: {default: ...}``.
|
||||
raw_model = cfg.get("model")
|
||||
if isinstance(raw_model, dict):
|
||||
model_cfg = raw_model
|
||||
elif isinstance(raw_model, str) and raw_model.strip():
|
||||
model_cfg = {"default": raw_model.strip()}
|
||||
cfg["model"] = model_cfg
|
||||
else:
|
||||
model_cfg = {}
|
||||
cfg["model"] = model_cfg
|
||||
model_cfg["default"] = result.new_model
|
||||
model_cfg["provider"] = result.target_provider
|
||||
if result.base_url:
|
||||
model_cfg["base_url"] = result.base_url
|
||||
from hermes_cli.config import save_config
|
||||
save_config(cfg)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist model switch: %s", e)
|
||||
|
||||
# Build confirmation message with full metadata
|
||||
provider_label = result.provider_label or result.target_provider
|
||||
lines = [t("gateway.model.switched", model=result.new_model)]
|
||||
lines.append(t("gateway.model.provider_label", provider=provider_label))
|
||||
|
||||
# Context: always resolve via the provider-aware chain so Codex OAuth,
|
||||
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
|
||||
mi = result.model_info
|
||||
from hermes_cli.model_switch import resolve_display_context_length
|
||||
_sw2_config_ctx = None
|
||||
try:
|
||||
cached_entry[0].switch_model(
|
||||
new_model=result.new_model,
|
||||
new_provider=result.target_provider,
|
||||
api_key=result.api_key,
|
||||
base_url=result.base_url,
|
||||
api_mode=result.api_mode,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("In-place model switch failed for cached agent: %s", exc)
|
||||
_sw2_cfg = _load_gateway_config()
|
||||
_sw2_model_cfg = _sw2_cfg.get("model", {})
|
||||
if isinstance(_sw2_model_cfg, dict):
|
||||
_sw2_raw = _sw2_model_cfg.get("context_length")
|
||||
if _sw2_raw is not None:
|
||||
_sw2_config_ctx = int(_sw2_raw)
|
||||
except Exception:
|
||||
pass
|
||||
ctx = resolve_display_context_length(
|
||||
result.new_model,
|
||||
result.target_provider,
|
||||
base_url=result.base_url or current_base_url or "",
|
||||
api_key=result.api_key or current_api_key or "",
|
||||
model_info=mi,
|
||||
custom_providers=custom_provs,
|
||||
config_context_length=_sw2_config_ctx,
|
||||
)
|
||||
if ctx:
|
||||
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
|
||||
if mi:
|
||||
if mi.max_output:
|
||||
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
|
||||
if mi.has_cost_data():
|
||||
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
|
||||
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
|
||||
|
||||
# Persist the new model to the session DB so the dashboard
|
||||
# shows the updated model (#34850).
|
||||
_sess_db = getattr(self, "_session_db", None)
|
||||
if _sess_db is not None:
|
||||
try:
|
||||
_sess_entry = self.session_store.get_or_create_session(source)
|
||||
_sess_db.update_session_model(
|
||||
_sess_entry.session_id, result.new_model
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to persist model switch to DB: %s", exc
|
||||
)
|
||||
# Cache notice
|
||||
cache_enabled = (
|
||||
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
|
||||
or result.api_mode == "anthropic_messages"
|
||||
)
|
||||
if cache_enabled:
|
||||
lines.append(t("gateway.model.prompt_caching_enabled"))
|
||||
|
||||
# Store a note to prepend to the next user message so the model
|
||||
# knows about the switch (avoids system messages mid-history).
|
||||
if not hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes = {}
|
||||
self._pending_model_notes[session_key] = (
|
||||
f"[Note: model was just switched from {current_model} to {result.new_model} "
|
||||
f"via {result.provider_label or result.target_provider}. "
|
||||
f"Adjust your self-identification accordingly.]"
|
||||
)
|
||||
if result.warning_message:
|
||||
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
|
||||
|
||||
# Store session override so next agent creation uses the new model
|
||||
self._session_model_overrides[session_key] = {
|
||||
"model": result.new_model,
|
||||
"provider": result.target_provider,
|
||||
"api_key": result.api_key,
|
||||
"base_url": result.base_url,
|
||||
"api_mode": result.api_mode,
|
||||
}
|
||||
if persist_global:
|
||||
lines.append(t("gateway.model.saved_global"))
|
||||
else:
|
||||
lines.append(t("gateway.model.session_only_hint"))
|
||||
|
||||
# Evict cached agent so the next turn creates a fresh agent from the
|
||||
# override rather than relying on cache signature mismatch detection.
|
||||
self._evict_cached_agent(session_key)
|
||||
return "\n".join(lines)
|
||||
|
||||
# Persist to config if --global
|
||||
if persist_global:
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
else:
|
||||
cfg = {}
|
||||
# Coerce scalar/None ``model:`` into a dict before mutation —
|
||||
# otherwise ``cfg.setdefault("model", {})`` returns the existing
|
||||
# scalar and the next assignment raises
|
||||
# ``TypeError: 'str' object does not support item assignment``.
|
||||
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
|
||||
# string) instead of the proper nested ``model: {default: ...}``.
|
||||
raw_model = cfg.get("model")
|
||||
if isinstance(raw_model, dict):
|
||||
model_cfg = raw_model
|
||||
elif isinstance(raw_model, str) and raw_model.strip():
|
||||
model_cfg = {"default": raw_model.strip()}
|
||||
cfg["model"] = model_cfg
|
||||
else:
|
||||
model_cfg = {}
|
||||
cfg["model"] = model_cfg
|
||||
model_cfg["default"] = result.new_model
|
||||
model_cfg["provider"] = result.target_provider
|
||||
if result.base_url:
|
||||
model_cfg["base_url"] = result.base_url
|
||||
from hermes_cli.config import save_config
|
||||
save_config(cfg)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist model switch: %s", e)
|
||||
|
||||
# Build confirmation message with full metadata
|
||||
provider_label = result.provider_label or result.target_provider
|
||||
lines = [t("gateway.model.switched", model=result.new_model)]
|
||||
lines.append(t("gateway.model.provider_label", provider=provider_label))
|
||||
|
||||
# Context: always resolve via the provider-aware chain so Codex OAuth,
|
||||
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
|
||||
mi = result.model_info
|
||||
from hermes_cli.model_switch import resolve_display_context_length
|
||||
_sw2_config_ctx = None
|
||||
# Expensive-model confirmation gate (typed /model <name> path).
|
||||
# The pickers (Telegram/Discord inline keyboards, TUI, dashboard)
|
||||
# already confirm via their own UI affordances; this covers the
|
||||
# direct text command, which previously bypassed the guard.
|
||||
# expensive_model_warning() may hit models.dev or a /models endpoint
|
||||
# on a cache miss, so run it off the event loop.
|
||||
_cost_warning = None
|
||||
try:
|
||||
_sw2_cfg = _load_gateway_config()
|
||||
_sw2_model_cfg = _sw2_cfg.get("model", {})
|
||||
if isinstance(_sw2_model_cfg, dict):
|
||||
_sw2_raw = _sw2_model_cfg.get("context_length")
|
||||
if _sw2_raw is not None:
|
||||
_sw2_config_ctx = int(_sw2_raw)
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
_cost_warning = await asyncio.to_thread(
|
||||
expensive_model_warning,
|
||||
result.new_model,
|
||||
provider=result.target_provider,
|
||||
base_url=result.base_url or current_base_url or "",
|
||||
api_key=result.api_key or current_api_key or "",
|
||||
model_info=result.model_info,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
ctx = resolve_display_context_length(
|
||||
result.new_model,
|
||||
result.target_provider,
|
||||
base_url=result.base_url or current_base_url or "",
|
||||
api_key=result.api_key or current_api_key or "",
|
||||
model_info=mi,
|
||||
custom_providers=custom_provs,
|
||||
config_context_length=_sw2_config_ctx,
|
||||
)
|
||||
if ctx:
|
||||
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
|
||||
if mi:
|
||||
if mi.max_output:
|
||||
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
|
||||
if mi.has_cost_data():
|
||||
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
|
||||
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
|
||||
_cost_warning = None
|
||||
if _cost_warning is not None:
|
||||
async def _on_cost_confirm(choice: str) -> str:
|
||||
if choice == "cancel":
|
||||
return (
|
||||
f"🟡 Model switch cancelled. Current model unchanged "
|
||||
f"({current_model or 'unknown'})."
|
||||
)
|
||||
# "once" and "always" both proceed — there is no persistent
|
||||
# opt-out for the cost guard (each expensive switch should be
|
||||
# an explicit decision).
|
||||
return await _finish_switch()
|
||||
|
||||
# Cache notice
|
||||
cache_enabled = (
|
||||
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
|
||||
or result.api_mode == "anthropic_messages"
|
||||
)
|
||||
if cache_enabled:
|
||||
lines.append(t("gateway.model.prompt_caching_enabled"))
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
command="model",
|
||||
title="Expensive Model Warning",
|
||||
message=(
|
||||
f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
|
||||
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
|
||||
"the current model._"
|
||||
),
|
||||
handler=_on_cost_confirm,
|
||||
)
|
||||
|
||||
if result.warning_message:
|
||||
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
|
||||
|
||||
if persist_global:
|
||||
lines.append(t("gateway.model.saved_global"))
|
||||
else:
|
||||
lines.append(t("gateway.model.session_only_hint"))
|
||||
|
||||
return "\n".join(lines)
|
||||
return await _finish_switch()
|
||||
|
||||
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /codex-runtime command in the gateway.
|
||||
@@ -1955,12 +2017,12 @@ class GatewaySlashCommandsMixin:
|
||||
return t("gateway.reasoning.set_session", effort=effort)
|
||||
|
||||
async def _handle_memory_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /memory — review pending memory writes + set write mode.
|
||||
"""Handle /memory — review pending memory writes + toggle the approval gate.
|
||||
|
||||
Memory entries are small enough to review inline in a chat bubble, so
|
||||
the full pending/approve/reject/mode flow works on every platform.
|
||||
Mode changes persist to config.yaml and evict the cached agent so the
|
||||
new write_mode takes effect on the next message.
|
||||
the full pending/approve/reject/approval flow works on every platform.
|
||||
Gate changes persist to config.yaml and evict the cached agent so the
|
||||
new setting takes effect on the next message.
|
||||
"""
|
||||
from gateway.run import _hermes_home
|
||||
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
||||
@@ -1972,15 +2034,15 @@ class GatewaySlashCommandsMixin:
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
|
||||
def _set_mode(mode: str):
|
||||
def _set_approval(enabled: bool):
|
||||
import yaml
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
user_config.setdefault("memory", {})["write_mode"] = mode
|
||||
user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
# New write_mode must take effect next message → drop cached agent.
|
||||
# New setting must take effect next message → drop cached agent.
|
||||
self._evict_cached_agent(session_key)
|
||||
|
||||
# Apply approved writes against a fresh on-disk store (the gateway has
|
||||
@@ -1989,11 +2051,69 @@ class GatewaySlashCommandsMixin:
|
||||
store.load_from_disk()
|
||||
|
||||
out = handle_pending_subcommand(
|
||||
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_mode,
|
||||
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,
|
||||
)
|
||||
if out is None:
|
||||
out = ("Unknown /memory subcommand. Use: pending, approve <id>, "
|
||||
"reject <id>, mode <on|off|approve>.")
|
||||
"reject <id>, approval <on|off>.")
|
||||
return out
|
||||
|
||||
async def _handle_skills_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /skills on the gateway — pending skill-write review only.
|
||||
|
||||
The full skills hub (search/browse/install) stays CLI-only; this
|
||||
handler covers the write-approval review surface (pending / approve /
|
||||
reject / diff / approval) so a skill staged from a gateway session can
|
||||
be reviewed from that same session. Gated by ``skills.write_approval``
|
||||
via the CommandDef's ``gateway_config_gate``; also answers when staged
|
||||
writes still exist after the gate was turned off (so they are never
|
||||
stranded).
|
||||
|
||||
``diff`` output is truncated for chat bubbles — the full diff lives in
|
||||
the CLI (``/skills diff <id>``) and the pending JSON file.
|
||||
"""
|
||||
from gateway.run import _hermes_home
|
||||
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
||||
from tools import write_approval as wa
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
args = raw_args.split() if raw_args else []
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
|
||||
gate_on = wa.write_approval_enabled(wa.SKILLS)
|
||||
wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
|
||||
if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
|
||||
return ("Skill write approval is off (skills.write_approval). "
|
||||
"Enable it with /skills approval on, then review staged "
|
||||
"writes here with /skills pending.")
|
||||
|
||||
def _set_approval(enabled: bool):
|
||||
import yaml
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
# New setting must take effect next message → drop cached agent.
|
||||
self._evict_cached_agent(session_key)
|
||||
|
||||
out = handle_pending_subcommand(
|
||||
wa.SKILLS, args, set_mode_fn=_set_approval,
|
||||
)
|
||||
if out is None:
|
||||
return ("Unknown /skills subcommand on this platform. Use: pending, "
|
||||
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
|
||||
"(Search/install are CLI-only.)")
|
||||
|
||||
# Chat bubbles can't hold a full skill diff — truncate and point at
|
||||
# the real review surfaces.
|
||||
if args and args[0].lower() == "diff" and len(out) > 3000:
|
||||
pending_id = args[1] if len(args) > 1 else "<id>"
|
||||
out = (out[:3000]
|
||||
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
|
||||
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
|
||||
return out
|
||||
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> str:
|
||||
@@ -3311,7 +3431,8 @@ class GatewaySlashCommandsMixin:
|
||||
if is_managed():
|
||||
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
git_dir = project_root / '.git'
|
||||
|
||||
if not git_dir.exists():
|
||||
|
||||
+60
-15
@@ -19,29 +19,74 @@ __release_date__ = "2026.6.5"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
|
||||
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
|
||||
|
||||
Windows services and terminals default to cp1252, which cannot encode
|
||||
box-drawing characters used in CLI output. This causes unhandled
|
||||
UnicodeEncodeError crashes on gateway startup.
|
||||
Several environments select a legacy, non-UTF-8 encoding for the standard
|
||||
streams:
|
||||
|
||||
- Windows services and terminals default to cp1252.
|
||||
- Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian
|
||||
installs and Raspberry Pi) select latin-1 or ASCII.
|
||||
|
||||
The CLI prints box-drawing characters (┌│├└─) and the ⚕ glyph in the setup
|
||||
wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec
|
||||
raises an unhandled UnicodeEncodeError that crashes the command before it
|
||||
can even start — e.g. `hermes setup` on a fresh Pi.
|
||||
|
||||
This runs at import time so it protects every CLI subcommand, on any
|
||||
platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not
|
||||
already UTF-8, preferring TextIOWrapper.reconfigure() so the existing
|
||||
stream object is fixed in place (cached `sys.stdout` references keep
|
||||
working) and falling back to reopening the file descriptor with
|
||||
closefd=False (the CPython-recommended safe variant).
|
||||
|
||||
No-op when the streams are already UTF-8: a healthy UTF-8 system sees no
|
||||
stream change and no environment mutation.
|
||||
|
||||
Note: this is intentionally the earliest, platform-agnostic guard.
|
||||
hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry
|
||||
points and layers on the Windows-only extras (console code-page flip,
|
||||
EDITOR default, PATH augmentation); its stream reconfiguration is a
|
||||
harmless idempotent no-op once we have already repaired the streams here.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
repaired = False
|
||||
|
||||
for stream_name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, stream_name, None)
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
|
||||
new_stream = open(
|
||||
stream.fileno(), "w", encoding="utf-8",
|
||||
buffering=1, closefd=False,
|
||||
)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
except (AttributeError, OSError):
|
||||
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
|
||||
if encoding == "utf8":
|
||||
continue
|
||||
|
||||
# Preferred: reconfigure the existing TextIOWrapper in place. This
|
||||
# preserves object identity so any code already holding a reference
|
||||
# to the old sys.stdout benefits from the repair too.
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
repaired = True
|
||||
continue
|
||||
|
||||
# Fallback: reopen the underlying file descriptor as UTF-8. Used
|
||||
# for streams that don't expose reconfigure() (e.g. some wrapped
|
||||
# or replaced streams). closefd=False keeps the original fd open.
|
||||
new_stream = open(
|
||||
stream.fileno(), "w", encoding="utf-8",
|
||||
errors="replace", buffering=1, closefd=False,
|
||||
)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
repaired = True
|
||||
except (AttributeError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
# Only nudge child processes toward UTF-8 when we actually detected a
|
||||
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
|
||||
# locale already, so leave the environment untouched (minimal footprint).
|
||||
if repaired:
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
|
||||
_ensure_utf8()
|
||||
|
||||
+91
-7
@@ -2665,12 +2665,23 @@ def _xai_wait_for_callback(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
timeout_seconds: float = 180.0,
|
||||
manual_paste_redirect_uri: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + max(5.0, timeout_seconds)
|
||||
if manual_paste_redirect_uri and sys.stdin.isatty():
|
||||
print()
|
||||
print("If xAI shows a Grok Build code instead of redirecting,")
|
||||
print("paste that code here and press Enter.")
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if result["code"] or result["error"]:
|
||||
return result
|
||||
if manual_paste_redirect_uri:
|
||||
raw_paste = _read_ready_stdin_line()
|
||||
if raw_paste and raw_paste.strip():
|
||||
pasted = _parse_pasted_callback(raw_paste)
|
||||
pasted["_manual_paste"] = True
|
||||
return pasted
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
server.shutdown()
|
||||
@@ -2694,6 +2705,21 @@ def _xai_wait_for_callback(
|
||||
)
|
||||
|
||||
|
||||
def _read_ready_stdin_line() -> Optional[str]:
|
||||
"""Return one pending stdin line without blocking, if the terminal has one."""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return None
|
||||
import select
|
||||
|
||||
ready, _, _ = select.select([sys.stdin], [], [], 0)
|
||||
if not ready:
|
||||
return None
|
||||
return sys.stdin.readline()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _spotify_token_payload_to_state(
|
||||
token_payload: Dict[str, Any],
|
||||
*,
|
||||
@@ -6149,6 +6175,40 @@ def _reset_config_provider() -> Path:
|
||||
return config_path
|
||||
|
||||
|
||||
def _confirm_expensive_model_selection(
|
||||
model_id: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
) -> bool:
|
||||
"""Prompt before saving a model whose known pricing exceeds guardrails."""
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
warning = expensive_model_warning(
|
||||
model_id,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
if warning is None:
|
||||
return True
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print(warning.message)
|
||||
print("=" * 72)
|
||||
try:
|
||||
response = input("Switch anyway? [y/N]: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return False
|
||||
return response in {"y", "yes"}
|
||||
|
||||
|
||||
def _prompt_model_selection(
|
||||
model_ids: List[str],
|
||||
current_model: str = "",
|
||||
@@ -6156,6 +6216,9 @@ def _prompt_model_selection(
|
||||
unavailable_models: Optional[List[str]] = None,
|
||||
portal_url: str = "",
|
||||
unavailable_message: str = "",
|
||||
confirm_provider: str = "",
|
||||
confirm_base_url: str = "",
|
||||
confirm_api_key: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
|
||||
|
||||
@@ -6169,6 +6232,18 @@ def _prompt_model_selection(
|
||||
|
||||
_unavailable = unavailable_models or []
|
||||
|
||||
def _confirmed_selection(mid: str) -> Optional[str]:
|
||||
if not mid:
|
||||
return None
|
||||
if confirm_provider and not _confirm_expensive_model_selection(
|
||||
mid,
|
||||
provider=confirm_provider,
|
||||
base_url=confirm_base_url,
|
||||
api_key=confirm_api_key,
|
||||
):
|
||||
return None
|
||||
return mid
|
||||
|
||||
# Reorder: current model first, then the rest (deduplicated)
|
||||
ordered = []
|
||||
if current_model and current_model in model_ids:
|
||||
@@ -6284,13 +6359,13 @@ def _prompt_model_selection(
|
||||
return None
|
||||
print()
|
||||
if idx < len(ordered):
|
||||
return ordered[idx]
|
||||
return _confirmed_selection(ordered[idx])
|
||||
elif idx == len(ordered):
|
||||
try:
|
||||
custom = input("Enter model name: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return custom if custom else None
|
||||
return _confirmed_selection(custom) if custom else None
|
||||
return None
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
@@ -6322,10 +6397,10 @@ def _prompt_model_selection(
|
||||
return None
|
||||
idx = int(choice)
|
||||
if 1 <= idx <= n:
|
||||
return ordered[idx - 1]
|
||||
return _confirmed_selection(ordered[idx - 1])
|
||||
elif idx == n + 1:
|
||||
custom = input("Enter model name: ").strip()
|
||||
return custom if custom else None
|
||||
return _confirmed_selection(custom) if custom else None
|
||||
elif idx == n + 2:
|
||||
return None
|
||||
print(f"Please enter 1-{n + 2}")
|
||||
@@ -6669,6 +6744,7 @@ def _xai_oauth_loopback_login(
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
allow_missing_state = False
|
||||
if manual_paste:
|
||||
# No HTTP listener — synthesize a redirect_uri matching what
|
||||
# the server would have bound to so the authorize URL the user
|
||||
@@ -6695,6 +6771,7 @@ def _xai_oauth_loopback_login(
|
||||
print("Open this URL to authorize Hermes with xAI:")
|
||||
print(authorize_url)
|
||||
callback = _prompt_manual_callback_paste(redirect_uri)
|
||||
allow_missing_state = True
|
||||
else:
|
||||
server, thread, callback_result, redirect_uri = _xai_start_callback_server()
|
||||
try:
|
||||
@@ -6734,6 +6811,7 @@ def _xai_oauth_loopback_login(
|
||||
thread,
|
||||
callback_result,
|
||||
timeout_seconds=max(30.0, timeout_seconds * 9),
|
||||
manual_paste_redirect_uri=redirect_uri,
|
||||
)
|
||||
except AuthError as exc:
|
||||
if (
|
||||
@@ -6750,6 +6828,7 @@ def _xai_oauth_loopback_login(
|
||||
callback = _prompt_manual_callback_paste(redirect_uri)
|
||||
if callback.get("code") is None and callback.get("error") is None:
|
||||
raise exc
|
||||
allow_missing_state = True
|
||||
except Exception:
|
||||
try:
|
||||
server.shutdown()
|
||||
@@ -6770,7 +6849,7 @@ def _xai_oauth_loopback_login(
|
||||
code="xai_authorization_failed",
|
||||
)
|
||||
callback_state = callback.get("state")
|
||||
# Manual-paste bare-code path: when a user pastes only the opaque
|
||||
# Manual bare-code paths: when a user pastes only the opaque
|
||||
# authorization code (no ``code=``/``state=`` query parameters),
|
||||
# ``_parse_pasted_callback`` returns ``state=None``. xAI's consent
|
||||
# page renders the code in-page rather than redirecting through the
|
||||
@@ -6778,10 +6857,12 @@ def _xai_oauth_loopback_login(
|
||||
# VPS, container consoles) the bare code is the only thing the user
|
||||
# can obtain. PKCE (code_verifier) still binds the exchange to this
|
||||
# client, so the local state-equality check is redundant on the
|
||||
# bare-code path — we substitute the locally generated state to keep
|
||||
# bare-code paths — we substitute the locally generated state to keep
|
||||
# the rest of the validation chain (and the token exchange) unchanged.
|
||||
# See #26923 (AccursedGalaxy comment, 2026-05-20).
|
||||
if callback_state is None and manual_paste:
|
||||
if callback.get("_manual_paste"):
|
||||
allow_missing_state = True
|
||||
if callback_state is None and (manual_paste or allow_missing_state):
|
||||
callback_state = state
|
||||
if callback_state != state:
|
||||
raise AuthError(
|
||||
@@ -7698,6 +7779,9 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_portal,
|
||||
unavailable_message=unavailable_message,
|
||||
confirm_provider="nous",
|
||||
confirm_base_url=inference_base_url,
|
||||
confirm_api_key=runtime_key,
|
||||
)
|
||||
elif unavailable_models:
|
||||
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
|
||||
@@ -268,7 +268,8 @@ def check_for_updates() -> Optional[int]:
|
||||
# Prefer the running code's location over the profile-scoped path.
|
||||
# $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all;
|
||||
# Path(__file__) always resolves to the actual installed checkout.
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_dir = get_hermes_source_root()
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
if not (repo_dir / ".git").exists():
|
||||
@@ -293,7 +294,8 @@ def _resolve_repo_dir() -> Optional[Path]:
|
||||
because ``$HERMES_HOME/hermes-agent/`` may be a stale copy carried
|
||||
over by ``--clone-all``.
|
||||
"""
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_dir = get_hermes_source_root()
|
||||
if not (repo_dir / ".git").exists():
|
||||
hermes_home = get_hermes_home()
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
@@ -728,17 +730,15 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
# Pip-install warning — `pip install hermes-agent` is not the supported
|
||||
# install path (it exists on PyPI for internal/CI reasons, not end users).
|
||||
# Such installs miss the git checkout + installer-managed deps, so updates,
|
||||
# self-update, and issue triage don't behave correctly. Warn, don't block.
|
||||
# PyPI install warning — `pip install hermes-agent` is not a supported
|
||||
# install path. Direct users to the official installer.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "pip":
|
||||
right_lines.append(
|
||||
"[bold yellow]⚠ pip install not officially supported[/]"
|
||||
"[dim yellow] — exists for reasons other than user install; "
|
||||
"expect instability and an inability to support issues[/]"
|
||||
"[bold yellow]⚠ the hermes-agent python package is no longer supported[/]"
|
||||
"[dim yellow] please reinstall via our official installer "
|
||||
"curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over the install-method check
|
||||
|
||||
@@ -27,10 +27,11 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
# Path is resolved relative to this module so it works regardless of cwd —
|
||||
# matches the pattern used by ``banner._resolve_repo_dir``.
|
||||
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
|
||||
_BUILD_SHA_FILE = get_hermes_source_root() / ".hermes_build_sha"
|
||||
|
||||
|
||||
def get_build_sha(short: int = 8) -> Optional[str]:
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ from hermes_cli.setup import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
_OPENCLAW_SCRIPT = (
|
||||
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||
|
||||
@@ -1306,12 +1306,12 @@ class CLICommandsMixin:
|
||||
parts = cmd.strip().split()
|
||||
args = parts[1:] if len(parts) > 1 else []
|
||||
if args and args[0].lower() in {"pending", "approve", "apply", "reject",
|
||||
"deny", "drop", "diff", "mode"}:
|
||||
"deny", "drop", "diff", "approval", "mode"}:
|
||||
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
||||
from tools import write_approval as wa
|
||||
out = handle_pending_subcommand(
|
||||
wa.SKILLS, args,
|
||||
set_mode_fn=lambda m: self._save_write_mode("skills", m),
|
||||
set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
|
||||
)
|
||||
if out is not None:
|
||||
print(out)
|
||||
@@ -1320,7 +1320,7 @@ class CLICommandsMixin:
|
||||
handle_skills_slash(cmd, ChatConsole())
|
||||
|
||||
def _handle_memory_command(self, cmd: str):
|
||||
"""Handle /memory slash command — pending review + write-mode control."""
|
||||
"""Handle /memory slash command — pending review + approval-gate toggle."""
|
||||
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
||||
from tools import write_approval as wa
|
||||
parts = cmd.strip().split()
|
||||
@@ -1329,17 +1329,17 @@ class CLICommandsMixin:
|
||||
out = handle_pending_subcommand(
|
||||
wa.MEMORY, args,
|
||||
memory_store=store,
|
||||
set_mode_fn=lambda m: self._save_write_mode("memory", m),
|
||||
set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled),
|
||||
)
|
||||
if out is None:
|
||||
out = ("Unknown /memory subcommand. "
|
||||
"Use: pending, approve <id>, reject <id>, mode <on|off|approve>.")
|
||||
"Use: pending, approve <id>, reject <id>, approval <on|off>.")
|
||||
print(out)
|
||||
|
||||
def _save_write_mode(self, subsystem: str, mode: str):
|
||||
"""Persist <subsystem>.write_mode to config (for /memory|/skills mode)."""
|
||||
def _save_write_approval(self, subsystem: str, enabled: bool):
|
||||
"""Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
|
||||
from cli import save_config_value
|
||||
save_config_value(f"{subsystem}.write_mode", mode)
|
||||
save_config_value(f"{subsystem}.write_approval", bool(enabled))
|
||||
|
||||
def _handle_background_command(self, cmd: str):
|
||||
"""Handle /background <prompt> — run a prompt in a separate background session.
|
||||
|
||||
@@ -167,12 +167,13 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
cli_only=True),
|
||||
CommandDef("skills", "Search, install, inspect, or manage skills",
|
||||
"Tools & Skills", cli_only=True,
|
||||
gateway_config_gate="skills.write_approval",
|
||||
subcommands=("search", "browse", "inspect", "install", "audit",
|
||||
"pending", "approve", "reject", "diff", "mode")),
|
||||
CommandDef("memory", "Review pending memory writes / set write mode",
|
||||
"pending", "approve", "reject", "diff", "approval")),
|
||||
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
|
||||
"Tools & Skills",
|
||||
args_hint="[pending|approve|reject|mode] [id|on|off|approve]",
|
||||
subcommands=("pending", "approve", "reject", "mode")),
|
||||
args_hint="[pending|approve|reject|approval] [id|on|off]",
|
||||
subcommands=("pending", "approve", "reject", "approval")),
|
||||
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
||||
"Tools & Skills"),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
|
||||
+92
-34
@@ -379,7 +379,8 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
if (project_root / ".git").is_dir():
|
||||
return "git"
|
||||
return "pip"
|
||||
@@ -435,9 +436,10 @@ def recommended_update_command_for_method(method: str) -> str:
|
||||
if is_uv_tool_install():
|
||||
return "uv tool upgrade hermes-agent"
|
||||
import shutil
|
||||
if shutil.which("uv"):
|
||||
return "uv pip install --upgrade hermes-agent"
|
||||
return "pip install --upgrade hermes-agent"
|
||||
if shutil.which("pipx") and "pipx" in __import__("sys").prefix.split(__import__("os").sep):
|
||||
return "pipx upgrade hermes-agent"
|
||||
# PyPI-based installs are no longer supported — direct to the installer.
|
||||
return "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||||
return "hermes update"
|
||||
|
||||
|
||||
@@ -600,9 +602,6 @@ def get_env_path() -> Path:
|
||||
"""Get the .env file path (for API keys)."""
|
||||
return get_hermes_home() / ".env"
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project installation directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]:
|
||||
"""Read the HERMES_UID / HERMES_GID env vars set by Docker deployments.
|
||||
@@ -1290,6 +1289,14 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
"tts_audio_tags": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Triage specifier — flesh out a rough one-liner in the Kanban
|
||||
# Triage column into a concrete spec, then promote it to ``todo``.
|
||||
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
|
||||
@@ -1556,7 +1563,7 @@ DEFAULT_CONFIG = {
|
||||
# Each provider supports an optional `max_text_length:` override for the
|
||||
# per-request input-character cap. Omit it to use the provider's documented
|
||||
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
|
||||
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
"tts": {
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
|
||||
"edge": {
|
||||
@@ -1572,6 +1579,19 @@ DEFAULT_CONFIG = {
|
||||
"voice": "alloy",
|
||||
# Voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
},
|
||||
"gemini": {
|
||||
"model": "gemini-2.5-flash-preview-tts",
|
||||
"voice": "Kore",
|
||||
# When true, Gemini 3.1 TTS uses a hidden auxiliary-model rewrite
|
||||
# pass to insert freeform square-bracket audio tags into the TTS
|
||||
# script. Visible chat replies are unchanged.
|
||||
"audio_tags": False,
|
||||
# Optional local Markdown/text file with Gemini TTS performance
|
||||
# direction. It may include AUDIO PROFILE, SCENE, DIRECTOR'S NOTES,
|
||||
# SAMPLE CONTEXT, and either a `{transcript}` placeholder or no
|
||||
# transcript section; Hermes appends the live transcript when absent.
|
||||
"persona_prompt_file": "",
|
||||
},
|
||||
"xai": {
|
||||
"voice_id": "eve", # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
|
||||
"language": "en",
|
||||
@@ -1653,18 +1673,19 @@ DEFAULT_CONFIG = {
|
||||
"memory": {
|
||||
"memory_enabled": True,
|
||||
"user_profile_enabled": True,
|
||||
# Write gate for the memory tool (add/replace/remove), applied to BOTH
|
||||
# Approval gate for memory writes (add/replace/remove), applied to BOTH
|
||||
# foreground agent turns and the background self-improvement review fork
|
||||
# (the source of unprompted "wrong assumption" saves users reported):
|
||||
# on — write freely (default, current behaviour)
|
||||
# off — never write; the memory tool returns a clean disabled result
|
||||
# approve — foreground writes block on an inline approve/deny prompt
|
||||
# (entries are small enough to review in a chat bubble);
|
||||
# background-review writes are staged for review instead of
|
||||
# committed (a daemon thread cannot block on a prompt).
|
||||
# Pending entries: /memory pending, /memory approve <id>,
|
||||
# /memory reject <id>.
|
||||
"write_mode": "on",
|
||||
# (the source of unprompted "wrong assumption" saves users reported).
|
||||
# false (default) — write freely; the gate is off (pre-gate behaviour)
|
||||
# true — require approval: foreground writes prompt inline
|
||||
# (entries are small enough to review in a chat
|
||||
# bubble); background-review writes are staged
|
||||
# instead of committed (a daemon thread cannot block
|
||||
# on a prompt). Review staged entries with
|
||||
# /memory pending, /memory approve <id>,
|
||||
# /memory reject <id>.
|
||||
# To disable memory entirely, use memory_enabled: false instead.
|
||||
"write_approval": False,
|
||||
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
|
||||
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
|
||||
# External memory provider plugin (empty = built-in only).
|
||||
@@ -1769,17 +1790,18 @@ DEFAULT_CONFIG = {
|
||||
# External hub installs (trusted/community sources) are always
|
||||
# scanned regardless of this setting.
|
||||
"guard_agent_created": False,
|
||||
# Write gate for skill_manage (create/edit/patch/write_file/delete/
|
||||
# Approval gate for skill_manage (create/edit/patch/write_file/delete/
|
||||
# remove_file), applied to BOTH foreground agent turns and the
|
||||
# background self-improvement review fork:
|
||||
# on — write freely (default, current behaviour)
|
||||
# off — never write; skill_manage returns a clean disabled result
|
||||
# approve — stage the write for review instead of committing.
|
||||
# Pending skills are listed with /skills pending, reviewed
|
||||
# with /skills diff <id> (full diff — CLI/dashboard/file,
|
||||
# never crammed into a chat bubble), and applied with
|
||||
# /skills approve <id> or dropped with /skills reject <id>.
|
||||
"write_mode": "on",
|
||||
# background self-improvement review fork.
|
||||
# false (default) — write freely; the gate is off (pre-gate behaviour)
|
||||
# true — require approval: stage the write for review
|
||||
# instead of committing (a SKILL.md is too large to
|
||||
# review inline, so skills always stage rather than
|
||||
# prompt). List with /skills pending, inspect with
|
||||
# /skills diff <id> (full diff — CLI/dashboard/file,
|
||||
# never crammed into a chat bubble), apply with
|
||||
# /skills approve <id> or drop with /skills reject <id>.
|
||||
"write_approval": False,
|
||||
},
|
||||
|
||||
# Curator — background skill maintenance.
|
||||
@@ -2463,7 +2485,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 28,
|
||||
"_config_version": 29,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -4734,6 +4756,34 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
if not quiet:
|
||||
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
|
||||
|
||||
# ── Version 28 → 29: rename memory/skills write_mode → write_approval ──
|
||||
# The tri-state write_mode (on|off|approve) was replaced by a clear boolean
|
||||
# write_approval (default false = gate off, writes flow freely; true =
|
||||
# require approval). Only an explicit "approve" carried gating intent, so
|
||||
# it maps to true; everything else (on/off/unset) → false. The old
|
||||
# "off = block all writes" mode is dropped — memory_enabled: false disables
|
||||
# memory entirely. Only rewrite a key the user actually persisted; never
|
||||
# invent one.
|
||||
if current_ver < 29:
|
||||
config = read_raw_config()
|
||||
touched = False
|
||||
for subsystem in ("memory", "skills"):
|
||||
sub = config.get(subsystem)
|
||||
if not isinstance(sub, dict) or "write_mode" not in sub:
|
||||
continue
|
||||
old = sub.pop("write_mode")
|
||||
old_norm = old.strip().lower() if isinstance(old, str) else old
|
||||
sub["write_approval"] = (old_norm == "approve")
|
||||
config[subsystem] = sub
|
||||
touched = True
|
||||
results["config_added"].append(
|
||||
f"{subsystem}.write_mode → write_approval={sub['write_approval']}"
|
||||
)
|
||||
if touched:
|
||||
save_config(config)
|
||||
if not quiet:
|
||||
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
@@ -5719,19 +5769,21 @@ def save_env_value(key: str, value: str):
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
atomic_replace(tmp_path, env_path)
|
||||
# Restore original permissions before _secure_file may tighten them.
|
||||
# Preserve the original file mode (e.g. 0640 for Docker volume mounts)
|
||||
# instead of letting _secure_file unconditionally tighten to 0600.
|
||||
if original_mode is not None:
|
||||
try:
|
||||
os.chmod(env_path, original_mode)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
_secure_file(env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
|
||||
os.environ[key] = value
|
||||
invalidate_env_cache()
|
||||
@@ -5776,18 +5828,22 @@ def remove_env_value(key: str) -> bool:
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
atomic_replace(tmp_path, env_path)
|
||||
# Preserve the original file mode (e.g. 0640 for Docker volume
|
||||
# mounts) instead of letting _secure_file unconditionally tighten
|
||||
# to 0600. Mirrors save_env_value().
|
||||
if original_mode is not None:
|
||||
try:
|
||||
os.chmod(env_path, original_mode)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
_secure_file(env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
invalidate_env_cache()
|
||||
@@ -5874,6 +5930,8 @@ def redact_key(key: str) -> str:
|
||||
|
||||
def show_config():
|
||||
"""Display current configuration."""
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
config = load_config()
|
||||
|
||||
print()
|
||||
@@ -5886,7 +5944,7 @@ def show_config():
|
||||
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Config: {get_config_path()}")
|
||||
print(f" Secrets: {get_env_path()}")
|
||||
print(f" Install: {get_project_root()}")
|
||||
print(f" Install: {get_hermes_source_root()}")
|
||||
|
||||
# API Keys
|
||||
print()
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
@@ -14,12 +14,14 @@ automatically.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -163,17 +165,13 @@ def _ensure_qrcode_installed() -> bool:
|
||||
|
||||
import subprocess
|
||||
|
||||
# Try uv first (Hermes convention), then pip
|
||||
for cmd in (
|
||||
[sys.executable, "-m", "uv", "pip", "install", "qrcode"],
|
||||
[sys.executable, "-m", "pip", "install", "-q", "qrcode"],
|
||||
):
|
||||
result = pip_install(["qrcode"], quiet=True)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
import qrcode # noqa: F401,F811
|
||||
return True
|
||||
except (subprocess.CalledProcessError, ImportError, FileNotFoundError):
|
||||
continue
|
||||
except ImportError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
"""hermes_cli.doctor — diagnostic checks for Hermes Agent setup.
|
||||
|
||||
This module is the public face of the doctor package. It exposes the same
|
||||
names the old flat doctor.py did so all existing imports and monkeypatches
|
||||
in tests keep working without change:
|
||||
|
||||
from hermes_cli.doctor import run_doctor, HERMES_HOME, PROJECT_ROOT, _DHH
|
||||
from hermes_cli.doctor import _has_provider_env_config, _PROVIDER_ENV_HINTS
|
||||
from hermes_cli.doctor import _apply_doctor_tool_availability_overrides
|
||||
from hermes_cli.doctor import _honcho_is_configured_for_doctor
|
||||
from hermes_cli.doctor import _doctor_tool_availability_detail
|
||||
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
||||
from hermes_cli.doctor import _build_apikey_providers_list
|
||||
from hermes_cli.doctor import shutil # tests patch shutil.which via doctor_mod.shutil
|
||||
import hermes_cli.doctor as doctor_mod # monkeypatching PROJECT_ROOT, HERMES_HOME etc.
|
||||
|
||||
Internal layout
|
||||
---------------
|
||||
hermes_cli/doctor/
|
||||
__init__.py ← you are here
|
||||
_output.py ← ANSI rendering helpers (no external deps)
|
||||
_registry.py ← register() decorator, DiagnosticReport, run_checks()
|
||||
checks/
|
||||
__init__.py ← imports all check modules (registers side-effects)
|
||||
_helpers.py ← shared utils (safe_which, is_termux, …)
|
||||
python_env.py
|
||||
security.py
|
||||
dep_mgmt.py
|
||||
config_files.py
|
||||
xai_retirement.py
|
||||
auth_providers.py
|
||||
directory_structure.py
|
||||
gateway_service.py
|
||||
command_install.py
|
||||
external_tools.py
|
||||
api_connectivity.py
|
||||
tool_availability.py
|
||||
skills_hub.py
|
||||
memory_provider.py
|
||||
profiles.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil # noqa: F401 — tests monkeypatch hermes_cli.doctor.shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Module-level globals (monkeypatched in tests) ─────────────────────────
|
||||
# These are the same names the flat doctor.py exposed.
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_env_path
|
||||
from hermes_constants import display_hermes_home, get_hermes_source_root
|
||||
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
HERMES_HOME = get_hermes_home()
|
||||
_DHH = display_hermes_home()
|
||||
|
||||
# ── Lazy env bootstrap — same as old doctor.py top-level code ─────────────
|
||||
_env_path = get_env_path()
|
||||
try:
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Backward-compat re-exports from sub-modules ───────────────────────────
|
||||
# Import these after the globals so sub-modules can `from hermes_cli.doctor import HERMES_HOME`
|
||||
|
||||
from hermes_cli.doctor.checks.config_files import ( # noqa: F401
|
||||
_PROVIDER_ENV_HINTS,
|
||||
_has_provider_env_config,
|
||||
)
|
||||
from hermes_cli.doctor.checks.tool_availability import ( # noqa: F401
|
||||
_apply_doctor_tool_availability_overrides,
|
||||
_honcho_is_configured_for_doctor,
|
||||
_doctor_tool_availability_detail,
|
||||
)
|
||||
from hermes_cli.doctor.checks.api_connectivity import ( # noqa: F401
|
||||
_has_healthy_oauth_fallback as _has_healthy_oauth_fallback_for_apikey_provider,
|
||||
_build_apikey_providers_list,
|
||||
_APIKEY_PROVIDERS_CACHE,
|
||||
)
|
||||
|
||||
# Termux helpers (some tests import directly)
|
||||
from hermes_cli.doctor.checks._helpers import ( # noqa: F401
|
||||
is_termux as _is_termux,
|
||||
python_install_cmd as _python_install_cmd,
|
||||
system_package_install_cmd as _system_package_install_cmd,
|
||||
)
|
||||
|
||||
# ── Platform check ────────────────────────────────────────────────────────
|
||||
|
||||
def _check_unsupported_platform() -> None:
|
||||
if sys.platform == "darwin" and os.uname().machine == "x86_64":
|
||||
from hermes_cli.doctor._output import color, _Ansi
|
||||
print(color(
|
||||
"⚠️ WARNING: macOS x86_64 (Intel) is explicitly unsupported.\n"
|
||||
"We no longer accept PRs or provide fixes for this platform.\n"
|
||||
"Consider migrating to a supported platform (macOS arm64 / Apple Silicon).",
|
||||
_Ansi.YELLOW,
|
||||
))
|
||||
print()
|
||||
|
||||
|
||||
# ── run_doctor entry point ────────────────────────────────────────────────
|
||||
|
||||
def run_doctor(args) -> None:
|
||||
"""Run all registered diagnostic checks.
|
||||
|
||||
Called by ``hermes doctor`` (via ``hermes_cli/main.py``) and also
|
||||
directly by tests. ``args`` is an ``argparse.Namespace`` with at least:
|
||||
args.fix — bool, whether to attempt auto-fixes
|
||||
args.ack — str | None, advisory ID to acknowledge
|
||||
"""
|
||||
should_fix = getattr(args, "fix", False)
|
||||
ack_target = getattr(args, "ack", None)
|
||||
|
||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
||||
|
||||
# Fast path: `hermes doctor --ack <id>`
|
||||
if ack_target:
|
||||
_handle_ack(ack_target)
|
||||
return
|
||||
|
||||
# Trigger all @register() decorators by importing the checks package
|
||||
import hermes_cli.doctor.checks # noqa: F401
|
||||
|
||||
from hermes_cli.doctor._output import print_banner, color, _Ansi
|
||||
from hermes_cli.doctor._registry import DiagnosticReport, run_checks
|
||||
|
||||
print_banner()
|
||||
_check_unsupported_platform()
|
||||
|
||||
report = DiagnosticReport(should_fix=should_fix)
|
||||
run_checks(report)
|
||||
report.print_summary()
|
||||
|
||||
|
||||
def _handle_ack(ack_target: str) -> None:
|
||||
from hermes_cli.doctor._output import color, _Ansi
|
||||
from hermes_cli.security_advisories import ADVISORIES, ack_advisory
|
||||
|
||||
valid_ids = {a.id for a in ADVISORIES}
|
||||
if ack_target not in valid_ids:
|
||||
print(color(
|
||||
f"Unknown advisory ID: {ack_target!r}. Known IDs: "
|
||||
f"{', '.join(sorted(valid_ids)) or '(none)'}",
|
||||
_Ansi.RED,
|
||||
))
|
||||
sys.exit(2)
|
||||
if ack_advisory(ack_target):
|
||||
print(color(
|
||||
f" ✓ Acknowledged advisory {ack_target}. "
|
||||
f"It will no longer trigger startup banners.",
|
||||
_Ansi.GREEN,
|
||||
))
|
||||
else:
|
||||
print(color(
|
||||
f" ✗ Failed to persist ack for {ack_target}. "
|
||||
f"Check ~/.hermes/config.yaml is writable.",
|
||||
_Ansi.RED,
|
||||
))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Terminal output rendering for doctor diagnostics.
|
||||
|
||||
Pure stdlib — uses inline ANSI codes instead of importing hermes_cli.colors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _should_use_color() -> bool:
|
||||
"""Return True when colored output is appropriate.
|
||||
|
||||
Respects NO_COLOR (https://no-color.org/) and TERM=dumb.
|
||||
"""
|
||||
if os.environ.get("NO_COLOR") is not None:
|
||||
return False
|
||||
if os.environ.get("TERM") == "dumb":
|
||||
return False
|
||||
try:
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
except (AttributeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class _Ansi:
|
||||
"""ANSI escape code constants."""
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
BLUE = "\033[34m"
|
||||
MAGENTA = "\033[35m"
|
||||
CYAN = "\033[36m"
|
||||
|
||||
|
||||
def color(text: str, *codes: str) -> str:
|
||||
"""Apply ANSI color codes to text (only when color output is appropriate)."""
|
||||
if not _should_use_color():
|
||||
return text
|
||||
return "".join(codes) + text + _Ansi.RESET
|
||||
|
||||
|
||||
def check_ok(text: str, detail: str = "") -> None:
|
||||
"""Print an OK check line."""
|
||||
glyph = color("✓", _Ansi.GREEN)
|
||||
line = f" {glyph} {text}"
|
||||
if detail:
|
||||
line += f" {color(detail, _Ansi.DIM)}"
|
||||
print(line)
|
||||
|
||||
|
||||
def check_warn(text: str, detail: str = "") -> None:
|
||||
"""Print a warning check line."""
|
||||
glyph = color("⚠", _Ansi.YELLOW)
|
||||
line = f" {glyph} {text}"
|
||||
if detail:
|
||||
line += f" {color(detail, _Ansi.DIM)}"
|
||||
print(line)
|
||||
|
||||
|
||||
def check_fail(text: str, detail: str = "") -> None:
|
||||
"""Print a failure check line."""
|
||||
glyph = color("✗", _Ansi.RED)
|
||||
line = f" {glyph} {text}"
|
||||
if detail:
|
||||
line += f" {color(detail, _Ansi.DIM)}"
|
||||
print(line)
|
||||
|
||||
|
||||
def check_info(text: str) -> None:
|
||||
"""Print an informational check line."""
|
||||
glyph = color("→", _Ansi.CYAN)
|
||||
print(f" {glyph} {text}")
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
"""Print a section banner: blank line + bold cyan ◆ title."""
|
||||
print()
|
||||
print(color(f"◆ {title}", _Ansi.CYAN, _Ansi.BOLD))
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
"""Print the doctor header banner."""
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", _Ansi.CYAN))
|
||||
print(color("│ 🩺 Hermes Doctor │", _Ansi.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", _Ansi.CYAN))
|
||||
print()
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Check registration and diagnostic reporting framework.
|
||||
|
||||
Pure stdlib — no external dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from hermes_cli.doctor._output import (
|
||||
check_ok,
|
||||
check_warn,
|
||||
check_fail,
|
||||
check_info,
|
||||
section as _print_section,
|
||||
color,
|
||||
_Ansi,
|
||||
)
|
||||
|
||||
|
||||
# ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class RegisteredCheck:
|
||||
section: str
|
||||
name: str
|
||||
fn: Callable
|
||||
priority: int = 0
|
||||
|
||||
|
||||
_CHECKS: list[RegisteredCheck] = []
|
||||
|
||||
|
||||
def register(section: str, name: str = "", priority: int = 0) -> Callable:
|
||||
"""Decorator to register a diagnostic check.
|
||||
|
||||
Args:
|
||||
section: Section heading this check appears under.
|
||||
name: Short identifier for this specific sub-check.
|
||||
Used in the auto-caught exception message.
|
||||
Defaults to the function name.
|
||||
priority: Run order within the section — lower runs first (default 0).
|
||||
|
||||
The decorated function receives a single ``DiagnosticReport`` argument.
|
||||
Any uncaught exception is caught by the runner, which emits a ⚠ warning
|
||||
and continues — checks don't need defensive blanket try/except.
|
||||
|
||||
Example::
|
||||
|
||||
@register("Python Environment", "python-version")
|
||||
def check_python_version(report):
|
||||
py = sys.version_info
|
||||
if py >= (3, 11):
|
||||
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
|
||||
else:
|
||||
report.fail(
|
||||
"Python too old",
|
||||
detail="(3.11+ required)",
|
||||
fix="Upgrade Python to 3.10+",
|
||||
)
|
||||
|
||||
For auto-fixable issues pass a ``fix_fn`` callable to ``report.fail`` or
|
||||
``report.add_issue``::
|
||||
|
||||
@register("Config Files", "env-file")
|
||||
def check_env_file(report):
|
||||
if not env_path.exists():
|
||||
def _fix(report):
|
||||
env_path.touch()
|
||||
report.ok("Created empty .env")
|
||||
report.fail(".env missing", fix="run hermes setup", fix_fn=_fix)
|
||||
"""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
_CHECKS.append(RegisteredCheck(
|
||||
section=section,
|
||||
name=name or fn.__name__,
|
||||
fn=fn,
|
||||
priority=priority,
|
||||
))
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
def get_registered_checks() -> list[RegisteredCheck]:
|
||||
"""Return all registered checks in run order."""
|
||||
seen: dict[str, int] = {}
|
||||
order = 0
|
||||
for c in _CHECKS:
|
||||
if c.section not in seen:
|
||||
seen[c.section] = order
|
||||
order += 1
|
||||
return sorted(_CHECKS, key=lambda c: (seen[c.section], c.priority, c.name))
|
||||
|
||||
|
||||
# ── Issue record ──────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class _Issue:
|
||||
"""An issue collected during a check run."""
|
||||
text: str
|
||||
fix_fn: Callable | None = None # None → manual-only
|
||||
section: str = ""
|
||||
check: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Warning:
|
||||
"""A warning collected during a check run."""
|
||||
text: str
|
||||
section: str = ""
|
||||
check: str = ""
|
||||
|
||||
|
||||
# ── Diagnostic Report ────────────────────────────────────────────────────
|
||||
|
||||
class DiagnosticReport:
|
||||
"""Passed to every check function.
|
||||
|
||||
Section headers are deferred: a header only prints when the first
|
||||
finding (ok/warn/fail/info) is emitted under it, so checks that
|
||||
return early without output never print stray banners.
|
||||
|
||||
Fix model
|
||||
---------
|
||||
Every ``fail()`` and ``add_issue()`` call accepts an optional
|
||||
``fix_fn`` and an optional human-readable ``fix`` string.
|
||||
|
||||
* ``fix_fn`` — zero-arg callable executed when ``--fix`` is active.
|
||||
It receives the report so it can emit ok/warn/fail/info
|
||||
lines describing what it did.
|
||||
* ``fix`` — short instruction shown in the issue summary when
|
||||
``--fix`` is NOT active or when no ``fix_fn`` was given.
|
||||
|
||||
In ``--fix`` mode:
|
||||
- issues *with* a ``fix_fn`` are executed immediately; the issue
|
||||
is removed from the summary if the fn succeeds.
|
||||
- issues *without* a ``fix_fn`` remain in the summary as manual.
|
||||
|
||||
In normal mode:
|
||||
- issues *with* a ``fix_fn`` are shown as "✦ fixable — re-run with --fix"
|
||||
- issues *without* a ``fix_fn`` are shown as "✗ manual"
|
||||
"""
|
||||
|
||||
def __init__(self, should_fix: bool = False) -> None:
|
||||
self._should_fix = should_fix
|
||||
self._issues: list[_Issue] = []
|
||||
self._fixed_issues: list[_Issue] = []
|
||||
self._warnings: list[_Warning] = []
|
||||
self._fixed: int = 0
|
||||
self._pending_section: str | None = None
|
||||
self._printed_section: str | None = None
|
||||
# Set by the runner before each check so findings know their origin
|
||||
self._current_section: str = ""
|
||||
self._current_check: str = ""
|
||||
|
||||
# ── Section management ────────────────────────────────────────────────
|
||||
|
||||
def section(self, title: str) -> None:
|
||||
"""Declare the current section (header is deferred until first output)."""
|
||||
self._pending_section = title
|
||||
self._current_section = title
|
||||
|
||||
def _flush_section(self) -> None:
|
||||
if self._pending_section and self._pending_section != self._printed_section:
|
||||
_print_section(self._pending_section)
|
||||
self._printed_section = self._pending_section
|
||||
|
||||
# ── Finding emitters ─────────────────────────────────────────────────
|
||||
|
||||
def ok(self, text: str, detail: str = "") -> None:
|
||||
self._flush_section()
|
||||
check_ok(text, detail)
|
||||
|
||||
def warn(self, text: str, detail: str = "") -> None:
|
||||
self._flush_section()
|
||||
check_warn(text, detail)
|
||||
label = text + (f" {detail}" if detail else "")
|
||||
self._warnings.append(_Warning(label, self._current_section, self._current_check))
|
||||
|
||||
def fail(
|
||||
self,
|
||||
text: str,
|
||||
detail: str = "",
|
||||
*,
|
||||
fix: str = "",
|
||||
fix_fn: Callable | None = None,
|
||||
) -> None:
|
||||
"""Emit a ✗ failure line and record the issue.
|
||||
|
||||
Args:
|
||||
text: Primary description of the problem.
|
||||
detail: Optional dim detail suffix on the same line.
|
||||
fix: Short human-readable instruction shown in the summary.
|
||||
fix_fn: Zero-arg callable that auto-fixes the problem when
|
||||
called. It receives this report for output. If
|
||||
``--fix`` is active it is called immediately;
|
||||
otherwise the issue is annotated as auto-fixable.
|
||||
"""
|
||||
self._flush_section()
|
||||
check_fail(text, detail)
|
||||
self._record_issue(fix or text, fix_fn)
|
||||
|
||||
def info(self, text: str) -> None:
|
||||
self._flush_section()
|
||||
check_info(text)
|
||||
|
||||
def add_issue(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
fix_fn: Callable | None = None,
|
||||
) -> None:
|
||||
"""Add an issue to the summary without printing a fail line.
|
||||
|
||||
Use when a check wants to register a problem that was already
|
||||
surfaced via warn() but still deserves a summary entry.
|
||||
|
||||
Args:
|
||||
text: Issue description for the summary.
|
||||
fix_fn: Optional auto-fix callable (same semantics as fail).
|
||||
"""
|
||||
self._record_issue(text, fix_fn)
|
||||
|
||||
# ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
def _record_issue(self, text: str, fix_fn: Callable | None) -> None:
|
||||
issue = _Issue(text, fix_fn, self._current_section, self._current_check)
|
||||
if self._should_fix and fix_fn is not None:
|
||||
try:
|
||||
fix_fn(self)
|
||||
self._fixed += 1
|
||||
self._fixed_issues.append(issue)
|
||||
return
|
||||
except Exception as exc:
|
||||
check_warn(f"Auto-fix failed", f"({type(exc).__name__}: {exc})")
|
||||
# Fall through and add to remaining issues
|
||||
self._issues.append(issue)
|
||||
|
||||
# ── Colour helpers ────────────────────────────────────────────────────
|
||||
|
||||
def color(self, text: str, *codes: str) -> str:
|
||||
return color(text, *codes)
|
||||
|
||||
GREEN = _Ansi.GREEN
|
||||
YELLOW = _Ansi.YELLOW
|
||||
RED = _Ansi.RED
|
||||
CYAN = _Ansi.CYAN
|
||||
DIM = _Ansi.DIM
|
||||
BOLD = _Ansi.BOLD
|
||||
|
||||
def raw_print(self, text: str = "") -> None:
|
||||
self._flush_section()
|
||||
print(text)
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
def print_summary(self) -> None:
|
||||
fixable = [i for i in self._issues if i.fix_fn is not None]
|
||||
manual = [i for i in self._issues if i.fix_fn is None]
|
||||
|
||||
print()
|
||||
|
||||
# ── --fix mode: report what was done, then leftover sections ─────
|
||||
if self._should_fix and self._fixed > 0:
|
||||
if not self._issues:
|
||||
print(color("─" * 60, _Ansi.GREEN))
|
||||
print(color(
|
||||
f" ✓ Fixed {self._fixed} issue(s). All checks passed! 🎉",
|
||||
_Ansi.GREEN, _Ansi.BOLD,
|
||||
))
|
||||
print()
|
||||
_render_grouped(self._fixed_issues, bullet="✓")
|
||||
print()
|
||||
return
|
||||
print(color("─" * 60, _Ansi.YELLOW))
|
||||
print(
|
||||
color(f" ✓ Fixed {self._fixed} issue(s).", _Ansi.GREEN, _Ansi.BOLD)
|
||||
+ color(f" {len(self._issues)} still require attention.", _Ansi.YELLOW)
|
||||
)
|
||||
print()
|
||||
_render_grouped(self._fixed_issues, bullet="✓")
|
||||
print()
|
||||
|
||||
# ── All-clear ────────────────────────────────────────────────────
|
||||
elif not self._issues:
|
||||
print(color("─" * 60, _Ansi.GREEN))
|
||||
print(color(" All checks passed! 🎉", _Ansi.GREEN, _Ansi.BOLD))
|
||||
print()
|
||||
return
|
||||
|
||||
# ── Header (no-fix mode) ─────────────────────────────────────────
|
||||
else:
|
||||
print(color("─" * 60, _Ansi.YELLOW))
|
||||
|
||||
# ── Auto-fixable issues ──────────────────────────────────────────
|
||||
if fixable:
|
||||
print(color(
|
||||
f" ✦ {len(fixable)} auto-fixable issue{"" if len(fixable) == 1 else "s"}"
|
||||
+ (" — run `hermes doctor --fix` to resolve:" if not self._should_fix else ":"),
|
||||
_Ansi.CYAN, _Ansi.BOLD,
|
||||
))
|
||||
_render_grouped(fixable)
|
||||
print()
|
||||
|
||||
# ── Manual issues ────────────────────────────────────────────────
|
||||
if manual:
|
||||
print(color(f" ✗ {len(manual)} issue{"" if len(manual) == 1 else "s"} require manual attention:", _Ansi.RED, _Ansi.BOLD))
|
||||
_render_grouped(manual)
|
||||
print()
|
||||
|
||||
# ── Warnings (informational, not blocking) ───────────────────────
|
||||
if self._warnings:
|
||||
print(color(f" ⚠ {len(self._warnings)} warning{"" if len(self._warnings) == 1 else "s"}:", _Ansi.YELLOW, _Ansi.BOLD))
|
||||
_render_grouped(self._warnings)
|
||||
print()
|
||||
|
||||
|
||||
|
||||
def _fmt_label(section: str, check: str) -> str:
|
||||
"""Unused — kept for any external callers; grouping is now done in print_summary."""
|
||||
return ""
|
||||
|
||||
|
||||
def _render_grouped(items: list, bullet: str = "•") -> None:
|
||||
"""Render a list of _Issue or _Warning grouped by section, with sub-headers."""
|
||||
# Preserve insertion order of sections
|
||||
seen: dict[str, list] = {}
|
||||
for item in items:
|
||||
key = item.section or ""
|
||||
seen.setdefault(key, []).append(item)
|
||||
|
||||
for section_name, group in seen.items():
|
||||
if section_name:
|
||||
print(f" {color(section_name, _Ansi.DIM)}")
|
||||
for item in group:
|
||||
text = item.text if hasattr(item, "text") else item
|
||||
print(f" {bullet} {text}")
|
||||
|
||||
|
||||
# ── Runner ────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_checks(report: DiagnosticReport) -> None:
|
||||
"""Run all registered checks, catching any unexpected exceptions."""
|
||||
for check in get_registered_checks():
|
||||
report.section(check.section)
|
||||
report._current_section = check.section
|
||||
report._current_check = check.name
|
||||
try:
|
||||
check.fn(report)
|
||||
except Exception as exc:
|
||||
report.warn(
|
||||
f"Check '{check.name}' failed unexpectedly",
|
||||
f"({type(exc).__name__}: {exc})",
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Core types for the doctor diagnostic framework.
|
||||
|
||||
Pure stdlib — no external dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class Severity(Enum):
|
||||
"""Diagnostic severity levels."""
|
||||
OK = "ok"
|
||||
INFO = "info"
|
||||
WARN = "warn"
|
||||
FAIL = "fail"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
"""A single diagnostic finding from a check."""
|
||||
severity: Severity
|
||||
text: str
|
||||
detail: str = ""
|
||||
fix: str = "" # Fix instruction for the summary
|
||||
auto_fixable: bool = False
|
||||
|
||||
|
||||
# Convenience constructors
|
||||
def ok(text: str, detail: str = "") -> Finding:
|
||||
"""Create an OK finding."""
|
||||
return Finding(Severity.OK, text, detail)
|
||||
|
||||
|
||||
def info(text: str) -> Finding:
|
||||
"""Create an informational finding."""
|
||||
return Finding(Severity.INFO, text)
|
||||
|
||||
|
||||
def warn(text: str, detail: str = "") -> Finding:
|
||||
"""Create a warning finding."""
|
||||
return Finding(Severity.WARN, text, detail)
|
||||
|
||||
|
||||
def fail(text: str, detail: str = "", fix: str = "") -> Finding:
|
||||
"""Create a failure finding with optional fix instruction."""
|
||||
return Finding(Severity.FAIL, text, detail, fix)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""Result from running a single check function."""
|
||||
section: str
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
"""A registered diagnostic check."""
|
||||
name: str
|
||||
section: str
|
||||
fn: Callable # (ctx: DoctorContext, report: DiagnosticReport) -> None
|
||||
priority: int = 0 # Ordering within section (lower = earlier)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Doctor checks package — importing this registers all checks."""
|
||||
|
||||
from hermes_cli.doctor.checks import ( # noqa: F401 — side-effect imports
|
||||
python_env,
|
||||
security,
|
||||
dep_mgmt,
|
||||
config_files,
|
||||
xai_retirement,
|
||||
auth_providers,
|
||||
directory_structure,
|
||||
gateway_service,
|
||||
command_install,
|
||||
external_tools,
|
||||
api_connectivity,
|
||||
tool_availability,
|
||||
skills_hub,
|
||||
memory_provider,
|
||||
profiles,
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Shared utility functions for doctor checks.
|
||||
|
||||
Pure stdlib — no external dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _safe_which(cmd: str) -> str | None:
|
||||
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
|
||||
try:
|
||||
return shutil.which(cmd)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_termux() -> bool:
|
||||
"""Return True when running inside Termux on Android."""
|
||||
return bool(
|
||||
os.environ.get("TERMUX_VERSION")
|
||||
or "com.termux/files" in os.environ.get("PREFIX", "")
|
||||
)
|
||||
|
||||
|
||||
# Re-export as the private name tests expect
|
||||
_is_termux = is_termux
|
||||
|
||||
|
||||
def python_install_cmd() -> str:
|
||||
"""Return the pip install command appropriate for the platform."""
|
||||
return "python -m pip install" if is_termux() else "uv pip install"
|
||||
|
||||
|
||||
def system_package_install_cmd(pkg: str) -> str:
|
||||
"""Return the package manager install command for the given package."""
|
||||
if is_termux():
|
||||
return f"pkg install {pkg}"
|
||||
if sys.platform == "darwin":
|
||||
return f"brew install {pkg}"
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
"""Return ordered setup steps for browser tools on Termux."""
|
||||
steps: list[str] = []
|
||||
step = 1
|
||||
if not node_installed:
|
||||
steps.append(f"{step}) pkg install nodejs")
|
||||
step += 1
|
||||
steps.append(f"{step}) npm install -g agent-browser")
|
||||
steps.append(f"{step + 1}) agent-browser install")
|
||||
return steps
|
||||
|
||||
|
||||
def termux_install_all_fallback_notes() -> list[str]:
|
||||
"""Return informational notes for Termux compatibility."""
|
||||
return [
|
||||
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
|
||||
"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).",
|
||||
"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).",
|
||||
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
|
||||
]
|
||||
|
||||
|
||||
def resolve_project_root() -> Path:
|
||||
"""Resolve the hermes-agent project root directory.
|
||||
|
||||
Lightweight stdlib-only resolution: walks up from this file to find
|
||||
pyproject.toml, which marks the project root.
|
||||
"""
|
||||
# This file is at hermes_cli/doctor/checks/_helpers.py
|
||||
# Project root is 4 levels up
|
||||
here = Path(__file__).resolve()
|
||||
candidate = here.parent.parent.parent.parent # hermes_cli/doctor/checks/_helpers.py -> hermes_cli -> project root
|
||||
# Verify by checking pyproject.toml or setup.py exists
|
||||
if (candidate / "pyproject.toml").exists() or (candidate / "setup.py").exists():
|
||||
return candidate
|
||||
# Fallback: try parent.parent.parent (if the file moved)
|
||||
for parent in here.parents:
|
||||
if (parent / "pyproject.toml").exists():
|
||||
return parent
|
||||
# Last resort
|
||||
return candidate
|
||||
|
||||
|
||||
def resolve_hermes_home() -> Path:
|
||||
"""Resolve the Hermes home directory.
|
||||
|
||||
Reads HERMES_HOME env var, falls back to platform-native default.
|
||||
Lightweight stdlib-only implementation.
|
||||
"""
|
||||
val = os.environ.get("HERMES_HOME", "").strip()
|
||||
if val:
|
||||
return Path(val)
|
||||
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"
|
||||
# Check for active profile
|
||||
default_home = Path.home() / ".hermes"
|
||||
try:
|
||||
active_path = default_home / "active_profile"
|
||||
active = active_path.read_text().strip() if active_path.exists() else ""
|
||||
except (OSError, UnicodeDecodeError):
|
||||
active = ""
|
||||
if active and active != "default":
|
||||
return default_home / "profiles" / active
|
||||
return default_home
|
||||
|
||||
|
||||
def resolve_display_hermes_home() -> str:
|
||||
"""Return a user-friendly display path for HERMES_HOME."""
|
||||
home = resolve_hermes_home()
|
||||
try:
|
||||
rel = home.relative_to(Path.home())
|
||||
return f"~/{rel}"
|
||||
except ValueError:
|
||||
return str(home)
|
||||
@@ -0,0 +1,304 @@
|
||||
"""API connectivity checks — run in parallel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import os
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
from hermes_cli.doctor._output import color, _Ansi
|
||||
|
||||
|
||||
# ── Individual probes ─────────────────────────────────────────────────────
|
||||
# Each returns (label, lines, issues) where lines = list of (glyph, label, detail).
|
||||
|
||||
def _probe_openrouter():
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
|
||||
key = os.getenv("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
return ("OpenRouter API", [(color("⚠", _Ansi.YELLOW), "OpenRouter API", color("(not configured)", _Ansi.DIM))], [])
|
||||
|
||||
import httpx
|
||||
r = httpx.get(OPENROUTER_MODELS_URL, headers={"Authorization": f"Bearer {key}"}, timeout=10)
|
||||
if r.status_code == 200:
|
||||
return ("OpenRouter API", [(color("✓", _Ansi.GREEN), "OpenRouter API", "")], [])
|
||||
if r.status_code == 401:
|
||||
return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color("(invalid API key)", _Ansi.DIM))], ["Check OPENROUTER_API_KEY in .env"])
|
||||
if r.status_code == 402:
|
||||
return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color("(out of credits — payment required)", _Ansi.DIM))],
|
||||
["OpenRouter account has insufficient credits. Fix: run 'hermes config set model.provider <provider>' to switch providers, or fund your OpenRouter account at https://openrouter.ai/settings/credits"])
|
||||
if r.status_code == 429:
|
||||
return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color("(rate limited)", _Ansi.DIM))],
|
||||
["OpenRouter rate limit hit — consider switching to a different provider or waiting"])
|
||||
return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color(f"(HTTP {r.status_code})", _Ansi.DIM))], [])
|
||||
|
||||
|
||||
def _probe_anthropic():
|
||||
from hermes_cli.auth import get_anthropic_key
|
||||
key = get_anthropic_key()
|
||||
if not key:
|
||||
return ("Anthropic API", [], [])
|
||||
|
||||
import httpx
|
||||
from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
is_oauth = _is_oauth_token(key)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
headers["x-api-key"] = key
|
||||
r = httpx.get("https://api.anthropic.com/v1/models", headers=headers, timeout=10)
|
||||
if is_oauth and r.status_code == 400 and "long context beta" in r.text.lower() and "not yet available" in r.text.lower():
|
||||
headers["anthropic-beta"] = ",".join(
|
||||
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
|
||||
)
|
||||
r = httpx.get("https://api.anthropic.com/v1/models", headers=headers, timeout=10)
|
||||
if r.status_code == 200:
|
||||
return ("Anthropic API", [(color("✓", _Ansi.GREEN), "Anthropic API", "")], [])
|
||||
if r.status_code == 401:
|
||||
return ("Anthropic API", [(color("✗", _Ansi.RED), "Anthropic API", color("(invalid API key)", _Ansi.DIM))], [])
|
||||
return ("Anthropic API", [(color("⚠", _Ansi.YELLOW), "Anthropic API", color("(couldn't verify)", _Ansi.DIM))], [])
|
||||
|
||||
|
||||
def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_hc):
|
||||
key = ""
|
||||
for ev in env_vars:
|
||||
key = os.getenv(ev, "")
|
||||
if key:
|
||||
break
|
||||
if not key:
|
||||
return (pname, [], [])
|
||||
|
||||
label = pname.ljust(20)
|
||||
if not supports_hc:
|
||||
return (pname, [(color("✓", _Ansi.GREEN), label, color("(key configured)", _Ansi.DIM))], [])
|
||||
|
||||
import httpx
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from utils import base_url_host_matches
|
||||
|
||||
base = os.getenv(base_env, "") if base_env else ""
|
||||
if not base and key.startswith("sk-kimi-"):
|
||||
base = "https://api.kimi.com/coding/v1"
|
||||
if base and base.rstrip("/").endswith("/anthropic"):
|
||||
from agent.auxiliary_client import _to_openai_base_url
|
||||
base = _to_openai_base_url(base)
|
||||
if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"):
|
||||
base = base.rstrip("/") + "/v1"
|
||||
url = (base.rstrip("/") + "/models") if base else default_url
|
||||
headers = {"Authorization": f"Bearer {key}", "User-Agent": _HERMES_USER_AGENT}
|
||||
if base_url_host_matches(base, "api.kimi.com"):
|
||||
headers["User-Agent"] = "claude-code/0.1.0"
|
||||
if url and base_url_host_matches(url, "generativelanguage.googleapis.com"):
|
||||
headers.pop("Authorization", None)
|
||||
headers["x-goog-api-key"] = key
|
||||
r = httpx.get(url, headers=headers, timeout=10)
|
||||
if pname == "Alibaba/DashScope" and not base and r.status_code == 401:
|
||||
r = httpx.get("https://dashscope.aliyuncs.com/compatible-mode/v1/models", headers=headers, timeout=10)
|
||||
if r.status_code == 200:
|
||||
return (pname, [(color("✓", _Ansi.GREEN), label, "")], [])
|
||||
if r.status_code == 401:
|
||||
return (pname, [(color("✗", _Ansi.RED), label, color("(invalid API key)", _Ansi.DIM))], [f"Check {env_vars[0]} in .env"])
|
||||
return (pname, [(color("⚠", _Ansi.YELLOW), label, color(f"(HTTP {r.status_code})", _Ansi.DIM))], [])
|
||||
|
||||
|
||||
def _probe_bedrock():
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
if not has_aws_credentials():
|
||||
return ("AWS Bedrock", [], [])
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as _BotoConfig
|
||||
|
||||
auth_var = resolve_aws_auth_env_var()
|
||||
region = resolve_bedrock_region()
|
||||
label = "AWS Bedrock".ljust(20)
|
||||
cfg = _BotoConfig(connect_timeout=5, read_timeout=10, retries={"max_attempts": 1})
|
||||
client = boto3.client("bedrock", region_name=region, config=cfg)
|
||||
resp = client.list_foundation_models()
|
||||
n = len(resp.get("modelSummaries", []))
|
||||
return ("AWS Bedrock", [(color("✓", _Ansi.GREEN), label, color(f"({auth_var}, {region}, {n} models)", _Ansi.DIM))], [])
|
||||
|
||||
|
||||
def _probe_azure_entra():
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
|
||||
if not isinstance(model_cfg, dict):
|
||||
return ("Azure Foundry (Entra ID)", [], [])
|
||||
if str(model_cfg.get("provider") or "").strip().lower() != "azure-foundry":
|
||||
return ("Azure Foundry (Entra ID)", [], [])
|
||||
if str(model_cfg.get("auth_mode") or "").strip().lower() != "entra_id":
|
||||
return ("Azure Foundry (Entra ID)", [], [])
|
||||
|
||||
label = "Azure Foundry (Entra ID)".ljust(28)
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT,
|
||||
describe_active_credential, has_azure_identity_installed,
|
||||
)
|
||||
if not has_azure_identity_installed():
|
||||
return ("Azure Foundry (Entra ID)",
|
||||
[(color("⚠", _Ansi.YELLOW), label, color("(azure-identity not installed)", _Ansi.DIM))],
|
||||
["Install azure-identity: uv pip install azure-identity"])
|
||||
|
||||
entra_cfg = model_cfg.get("entra") or {}
|
||||
if not isinstance(entra_cfg, dict):
|
||||
entra_cfg = {}
|
||||
scope = str(entra_cfg.get("scope") or "").strip() or SCOPE_AI_AZURE_DEFAULT
|
||||
info = describe_active_credential(config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0)
|
||||
if info.get("ok"):
|
||||
env_sources = info.get("env_sources") or []
|
||||
tag = ", ".join(env_sources) if env_sources else "default credential chain"
|
||||
return ("Azure Foundry (Entra ID)",
|
||||
[(color("✓", _Ansi.GREEN), label, color(f"({tag}, scope={scope})", _Ansi.DIM))], [])
|
||||
err = info.get("error") or "credential chain exhausted"
|
||||
hint = info.get("hint") or (
|
||||
"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET, "
|
||||
"or attach a managed identity to this VM."
|
||||
)
|
||||
return ("Azure Foundry (Entra ID)",
|
||||
[(color("⚠", _Ansi.YELLOW), label, color(f"({err})", _Ansi.DIM))],
|
||||
[f"Azure Foundry Entra: {err}. {hint}"])
|
||||
|
||||
|
||||
def _has_healthy_oauth_fallback(provider_label: str) -> bool:
|
||||
normalized = (provider_label or "").strip().lower()
|
||||
if normalized in {"google / gemini", "gemini"}:
|
||||
try:
|
||||
from hermes_cli.auth import get_gemini_oauth_auth_status
|
||||
return bool((get_gemini_oauth_auth_status() or {}).get("logged_in"))
|
||||
except Exception:
|
||||
return False
|
||||
if normalized == "minimax":
|
||||
try:
|
||||
from hermes_cli.auth import get_minimax_oauth_auth_status
|
||||
return bool((get_minimax_oauth_auth_status() or {}).get("logged_in"))
|
||||
except Exception:
|
||||
return False
|
||||
if normalized == "xai":
|
||||
try:
|
||||
from hermes_cli.auth import get_xai_oauth_auth_status
|
||||
return bool((get_xai_oauth_auth_status() or {}).get("logged_in"))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# Cache for the expensive provider list build
|
||||
_APIKEY_PROVIDERS_CACHE: list | None = None
|
||||
|
||||
|
||||
def _build_apikey_providers_list() -> list:
|
||||
"""Build the API-key provider health-check list and cache it."""
|
||||
_static = [
|
||||
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
|
||||
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
|
||||
("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
|
||||
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
|
||||
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
|
||||
("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
|
||||
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
|
||||
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
|
||||
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
|
||||
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
|
||||
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
|
||||
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
|
||||
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
|
||||
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
|
||||
("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False),
|
||||
]
|
||||
_known_names = {t[0] for t in _static}
|
||||
_name_to_canonical = {
|
||||
"Z.AI / GLM": "zai", "Kimi / Moonshot": "kimi-coding",
|
||||
"StepFun Step Plan": "stepfun", "Kimi / Moonshot (China)": "kimi-coding-cn",
|
||||
"Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek",
|
||||
"Hugging Face": "huggingface", "NVIDIA NIM": "nvidia",
|
||||
"Alibaba/DashScope": "alibaba", "MiniMax": "minimax",
|
||||
"MiniMax (China)": "minimax-cn",
|
||||
"Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen",
|
||||
"OpenCode Go": "opencode-go",
|
||||
}
|
||||
_known_canonical = set(_name_to_canonical.values())
|
||||
_dedicated = {"anthropic", "openrouter", "bedrock"}
|
||||
_known_canonical.update(_dedicated)
|
||||
|
||||
try:
|
||||
from providers import list_providers
|
||||
from providers.base import ProviderProfile as _PP
|
||||
try:
|
||||
from hermes_cli.providers import normalize_provider as _nrm
|
||||
except Exception:
|
||||
def _nrm(n): return (n or "").strip().lower()
|
||||
|
||||
for pp in list_providers():
|
||||
if not isinstance(pp, _PP) or pp.auth_type != "api_key" or not pp.env_vars:
|
||||
continue
|
||||
label = pp.display_name or pp.name
|
||||
if label in _known_names or pp.name in _known_canonical:
|
||||
continue
|
||||
candidates = {_nrm(pp.name)} | {_nrm(a) for a in (pp.aliases or ())}
|
||||
if candidates & _dedicated:
|
||||
continue
|
||||
key_vars = tuple(v for v in pp.env_vars if not v.endswith(("_BASE_URL", "_URL")))
|
||||
base_var = next((v for v in pp.env_vars if v.endswith(("_BASE_URL", "_URL"))), None)
|
||||
if not key_vars:
|
||||
continue
|
||||
models_url = (
|
||||
(pp.models_url or (pp.base_url.rstrip("/") + "/models")) if pp.base_url else None
|
||||
)
|
||||
hc = getattr(pp, "supports_health_check", True)
|
||||
_static.append((label, key_vars, models_url, base_var, hc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _static
|
||||
|
||||
|
||||
@register("API Connectivity", "api-connectivity", priority=10)
|
||||
def check_api_connectivity(report):
|
||||
global _APIKEY_PROVIDERS_CACHE
|
||||
if _APIKEY_PROVIDERS_CACHE is None:
|
||||
_APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()
|
||||
|
||||
probes = [("OpenRouter API", _probe_openrouter), ("Anthropic API", _probe_anthropic)]
|
||||
for pname, env_vars, default_url, base_env, supports in _APIKEY_PROVIDERS_CACHE:
|
||||
probes.append((pname, lambda p=pname, e=env_vars, u=default_url, b=base_env, s=supports:
|
||||
_probe_apikey_provider(p, e, u, b, s)))
|
||||
probes.append(("AWS Bedrock", _probe_bedrock))
|
||||
probes.append(("Azure Foundry (Entra ID)", _probe_azure_entra))
|
||||
|
||||
print(
|
||||
f" {color(f'Running {len(probes)} connectivity checks in parallel…', _Ansi.DIM)}",
|
||||
end="", flush=True,
|
||||
)
|
||||
|
||||
prev_imds = os.environ.get("AWS_EC2_METADATA_DISABLED")
|
||||
os.environ["AWS_EC2_METADATA_DISABLED"] = "true"
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="doctor-probe") as ex:
|
||||
futures = [ex.submit(fn) for _, fn in probes]
|
||||
results = []
|
||||
for f in futures:
|
||||
try:
|
||||
results.append(f.result())
|
||||
except Exception as exc:
|
||||
results.append((None, [(color("⚠", _Ansi.YELLOW), "probe", color(f"({exc})", _Ansi.DIM))], []))
|
||||
finally:
|
||||
if prev_imds is None:
|
||||
os.environ.pop("AWS_EC2_METADATA_DISABLED", None)
|
||||
else:
|
||||
os.environ["AWS_EC2_METADATA_DISABLED"] = prev_imds
|
||||
|
||||
print("\r" + " " * 70 + "\r", end="")
|
||||
for label, lines, issues in results:
|
||||
for glyph, lbl, detail in lines:
|
||||
if detail:
|
||||
print(f" {glyph} {lbl} {detail}")
|
||||
else:
|
||||
print(f" {glyph} {lbl}")
|
||||
if issues and not _has_healthy_oauth_fallback(label or ""):
|
||||
for issue in issues:
|
||||
report.add_issue(issue)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Auth provider checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
from hermes_cli.doctor.checks._helpers import _safe_which
|
||||
|
||||
|
||||
@register("Auth Providers", "nous-auth", priority=10)
|
||||
def check_nous_auth(report):
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
status = get_nous_auth_status()
|
||||
if status.get("logged_in"):
|
||||
report.ok("Nous Portal auth", "(logged in)")
|
||||
else:
|
||||
report.warn("Nous Portal auth", "(not logged in)")
|
||||
|
||||
|
||||
@register("Auth Providers", "codex-auth", priority=20)
|
||||
def check_codex_auth(report):
|
||||
from hermes_cli.auth import get_codex_auth_status
|
||||
status = get_codex_auth_status()
|
||||
if status.get("logged_in"):
|
||||
report.ok("OpenAI Codex auth", "(logged in)")
|
||||
else:
|
||||
report.warn("OpenAI Codex auth", "(not logged in)")
|
||||
if status.get("error"):
|
||||
report.info(status["error"])
|
||||
if not _safe_which("codex"):
|
||||
report.info(
|
||||
"codex CLI not installed "
|
||||
"(optional — only required to import tokens from an existing Codex CLI login)"
|
||||
)
|
||||
|
||||
|
||||
@register("Auth Providers", "gemini-oauth", priority=30)
|
||||
def check_gemini_oauth(report):
|
||||
from hermes_cli.auth import get_gemini_oauth_auth_status
|
||||
status = get_gemini_oauth_auth_status()
|
||||
if status.get("logged_in"):
|
||||
pieces = [x for x in [status.get("email"), status.get("project_id") and f"project={status['project_id']}"] if x]
|
||||
suffix = f" ({', '.join(pieces)})" if pieces else ""
|
||||
report.ok("Google Gemini OAuth", f"(logged in{suffix})")
|
||||
else:
|
||||
report.warn("Google Gemini OAuth", "(not logged in)")
|
||||
|
||||
|
||||
@register("Auth Providers", "minimax-oauth", priority=40)
|
||||
def check_minimax_oauth(report):
|
||||
from hermes_cli.auth import get_minimax_oauth_auth_status
|
||||
status = get_minimax_oauth_auth_status()
|
||||
if status.get("logged_in"):
|
||||
report.ok("MiniMax OAuth", f"(logged in, region={status.get('region', 'global')})")
|
||||
else:
|
||||
report.warn("MiniMax OAuth", "(not logged in)")
|
||||
|
||||
|
||||
@register("Auth Providers", "xai-oauth", priority=50)
|
||||
def check_xai_oauth(report):
|
||||
from hermes_cli.auth import get_xai_oauth_auth_status
|
||||
status = get_xai_oauth_auth_status() or {}
|
||||
if status.get("logged_in"):
|
||||
report.ok("xAI OAuth", "(logged in)")
|
||||
else:
|
||||
report.warn("xAI OAuth", "(not logged in)")
|
||||
if status.get("error"):
|
||||
report.info(status["error"])
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Command installation checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Command Installation", "symlink-check", priority=10)
|
||||
def check_command_installation(report):
|
||||
if sys.platform == "win32":
|
||||
return
|
||||
|
||||
from hermes_cli.doctor import PROJECT_ROOT
|
||||
|
||||
venv_bin = None
|
||||
for venv_name in ("venv", ".venv"):
|
||||
c = PROJECT_ROOT / venv_name / "bin" / "hermes"
|
||||
if c.exists():
|
||||
venv_bin = c
|
||||
break
|
||||
|
||||
prefix = os.environ.get("PREFIX", "")
|
||||
is_termux = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in prefix
|
||||
if is_termux and prefix:
|
||||
cmd_dir = Path(prefix) / "bin"
|
||||
cmd_display = "$PREFIX/bin"
|
||||
else:
|
||||
cmd_dir = Path.home() / ".local" / "bin"
|
||||
cmd_display = "~/.local/bin"
|
||||
cmd_link = cmd_dir / "hermes"
|
||||
|
||||
if venv_bin is None:
|
||||
report.warn(
|
||||
"Venv entry point not found",
|
||||
"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')",
|
||||
)
|
||||
report.add_issue(
|
||||
f"reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'"
|
||||
)
|
||||
return
|
||||
|
||||
report.ok(f"Venv entry point exists ({venv_bin.relative_to(PROJECT_ROOT)})")
|
||||
|
||||
if cmd_link.is_symlink():
|
||||
target = cmd_link.resolve()
|
||||
expected = venv_bin.resolve()
|
||||
if target == expected:
|
||||
report.ok(f"{cmd_display}/hermes → correct target")
|
||||
else:
|
||||
def _fix(r):
|
||||
cmd_link.unlink()
|
||||
cmd_link.symlink_to(venv_bin)
|
||||
r.ok(f"Fixed symlink: {cmd_display}/hermes → {venv_bin}")
|
||||
|
||||
report.warn(
|
||||
f"{cmd_display}/hermes points to wrong target",
|
||||
f"(→ {target}, expected → {expected})",
|
||||
)
|
||||
report.add_issue(f"broken symlink at {cmd_display}/hermes", fix_fn=_fix)
|
||||
elif cmd_link.exists():
|
||||
report.ok(f"{cmd_display}/hermes exists (non-symlink)")
|
||||
else:
|
||||
def _fix(r):
|
||||
cmd_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd_link.symlink_to(venv_bin)
|
||||
r.ok(f"Created symlink: {cmd_display}/hermes → {venv_bin}")
|
||||
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
||||
if str(cmd_dir) not in path_dirs:
|
||||
r.warn(
|
||||
f"{cmd_display} is not on your PATH",
|
||||
'(add it to your shell config: export PATH="$HOME/.local/bin:$PATH")',
|
||||
)
|
||||
r.add_issue(f"add {cmd_display} to your PATH")
|
||||
|
||||
report.fail(
|
||||
f"{cmd_display}/hermes not found",
|
||||
"(hermes command may not work outside the venv)",
|
||||
fix=f"run `hermes doctor --fix` to create symlink",
|
||||
fix_fn=_fix,
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Configuration file checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
_PROVIDER_ENV_HINTS = (
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"OPENAI_BASE_URL", "NOUS_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
|
||||
"KIMI_API_KEY", "KIMI_CN_API_KEY", "GMI_API_KEY", "MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", "DEEPSEEK_API_KEY", "DASHSCOPE_API_KEY",
|
||||
"HF_TOKEN", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", "XIAOMI_API_KEY",
|
||||
"TOKENHUB_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _has_provider_env_config(content: str) -> bool:
|
||||
return any(key in content for key in _PROVIDER_ENV_HINTS)
|
||||
|
||||
|
||||
@register("Configuration Files", "env-file", priority=10)
|
||||
def check_env_file(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH
|
||||
|
||||
env_path = HERMES_HOME / ".env"
|
||||
if env_path.exists():
|
||||
report.ok(f"{_DHH}/.env file exists")
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
if _has_provider_env_config(content):
|
||||
report.ok("API key or custom endpoint configured")
|
||||
else:
|
||||
report.warn(f"No API key found in {_DHH}/.env")
|
||||
report.add_issue("run 'hermes setup' to configure API keys")
|
||||
elif (PROJECT_ROOT / ".env").exists():
|
||||
report.ok(".env file exists (in project directory)")
|
||||
else:
|
||||
def _fix(r):
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.touch()
|
||||
try:
|
||||
os.chmod(str(env_path), 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
r.ok(f"Created empty {_DHH}/.env")
|
||||
r.info("run 'hermes setup' to configure API keys")
|
||||
|
||||
report.fail(
|
||||
f"{_DHH}/.env file missing",
|
||||
fix="run 'hermes setup' to create one",
|
||||
fix_fn=_fix,
|
||||
)
|
||||
|
||||
|
||||
@register("Configuration Files", "config-yaml", priority=20)
|
||||
def check_config_yaml(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
fallback = PROJECT_ROOT / "cli-config.yaml"
|
||||
if fallback.exists():
|
||||
report.ok("cli-config.yaml exists (in project directory)")
|
||||
return
|
||||
|
||||
def _fix(r):
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
example = PROJECT_ROOT / "cli-config.yaml.example"
|
||||
if example.exists():
|
||||
shutil.copy2(str(example), str(config_path))
|
||||
r.ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
|
||||
else:
|
||||
from hermes_cli.config import DEFAULT_CONFIG, save_config
|
||||
save_config(DEFAULT_CONFIG)
|
||||
r.ok(f"Created {_DHH}/config.yaml from defaults")
|
||||
|
||||
report.warn(
|
||||
"config.yaml not found",
|
||||
"(using defaults)",
|
||||
)
|
||||
report.add_issue(
|
||||
f"{_DHH}/config.yaml missing",
|
||||
fix_fn=_fix,
|
||||
)
|
||||
return
|
||||
|
||||
report.ok(f"{_DHH}/config.yaml exists")
|
||||
_check_model_provider_config(report, config_path)
|
||||
|
||||
|
||||
def _check_model_provider_config(report, config_path):
|
||||
import yaml as _yaml
|
||||
from hermes_cli.doctor import _DHH
|
||||
|
||||
cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
model_section = cfg.get("model") or {}
|
||||
provider_raw = (model_section.get("provider") or "").strip()
|
||||
provider = provider_raw.lower()
|
||||
default_model = (model_section.get("default") or model_section.get("model") or "").strip()
|
||||
|
||||
known_providers: set[str] = set()
|
||||
_resolve_auth_provider = None
|
||||
_normalize_catalog_provider = None
|
||||
_resolve_provider_full = None
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider as _rap
|
||||
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
|
||||
_resolve_auth_provider = _rap
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.providers import normalize_provider as _ncp, resolve_provider_full as _rpf
|
||||
_normalize_catalog_provider = _ncp
|
||||
_resolve_provider_full = _rpf
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
custom_providers = []
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers
|
||||
custom_providers = get_compatible_custom_providers(cfg) or []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user_providers = cfg.get("providers")
|
||||
if isinstance(user_providers, dict):
|
||||
known_providers.update(str(n).strip().lower() for n in user_providers if str(n).strip())
|
||||
for entry in custom_providers:
|
||||
if isinstance(entry, dict):
|
||||
n = str(entry.get("name") or "").strip()
|
||||
if n:
|
||||
known_providers.add("custom:" + n.lower().replace(" ", "-"))
|
||||
|
||||
valid_ids = set(known_providers)
|
||||
if _normalize_catalog_provider:
|
||||
for kp in known_providers:
|
||||
try:
|
||||
valid_ids.add(_normalize_catalog_provider(kp))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
accepted = {provider} if provider else set()
|
||||
runtime_provider = provider
|
||||
if provider and _resolve_auth_provider and provider not in {"auto", "custom"}:
|
||||
try:
|
||||
runtime_provider = _resolve_auth_provider(provider)
|
||||
accepted.add(runtime_provider)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
catalog_provider = provider
|
||||
if provider and _resolve_provider_full and provider not in {"auto", "custom"}:
|
||||
pdef = _resolve_provider_full(provider, user_providers, custom_providers)
|
||||
catalog_provider = pdef.id if pdef is not None else None
|
||||
if catalog_provider:
|
||||
accepted.add(catalog_provider)
|
||||
|
||||
if provider and provider != "auto":
|
||||
if catalog_provider is None or (known_providers and not (accepted & valid_ids)):
|
||||
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
|
||||
report.fail(
|
||||
f"model.provider '{provider_raw}' is not a recognised provider",
|
||||
f"(known: {known_list})",
|
||||
fix=f"run 'hermes config set model.provider <valid_provider>' — valid: {known_list}",
|
||||
)
|
||||
|
||||
policy_id = str(runtime_provider or catalog_provider or "").strip().lower()
|
||||
slug_ok_providers = {"openrouter", "auto", "kilocode", "opencode-zen", "huggingface", "lmstudio", "nous"}
|
||||
slug_ok = policy_id in slug_ok_providers or policy_id == "custom" or policy_id.startswith("custom:")
|
||||
if default_model and "/" in default_model and policy_id and not slug_ok:
|
||||
report.warn(
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
|
||||
"(vendor-prefixed slugs belong to aggregators like openrouter)",
|
||||
)
|
||||
report.add_issue(
|
||||
f"model.default '{default_model}' is vendor-prefixed for provider '{provider_raw}' — "
|
||||
"set model.provider to 'openrouter', or drop the vendor prefix"
|
||||
)
|
||||
|
||||
if runtime_provider and runtime_provider not in ("auto", "custom"):
|
||||
if runtime_provider == "openrouter":
|
||||
from hermes_cli.config import get_env_value
|
||||
configured = bool(
|
||||
str(get_env_value("OPENROUTER_API_KEY") or "").strip()
|
||||
or str(get_env_value("OPENAI_API_KEY") or "").strip()
|
||||
)
|
||||
else:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
|
||||
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
|
||||
configured = True
|
||||
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
|
||||
status = get_auth_status(runtime_provider) or {}
|
||||
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key"))
|
||||
if not configured:
|
||||
report.fail(
|
||||
f"model.provider '{runtime_provider}' is set but no API key is configured",
|
||||
"(check ~/.hermes/.env or run 'hermes setup')",
|
||||
fix=f"run 'hermes setup' or set the API key in {_DHH}/.env",
|
||||
)
|
||||
|
||||
|
||||
@register("Configuration Files", "config-version", priority=30)
|
||||
def check_config_version(report):
|
||||
from hermes_cli.doctor import HERMES_HOME
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
from hermes_cli.config import check_config_version as _ccv, migrate_config
|
||||
current_ver, latest_ver = _ccv()
|
||||
if current_ver < latest_ver:
|
||||
def _fix(r):
|
||||
migrate_config(interactive=False, quiet=False)
|
||||
r.ok("Config migrated to latest version")
|
||||
|
||||
report.warn(f"Config version outdated (v{current_ver} → v{latest_ver})", "(new settings available)")
|
||||
report.add_issue(
|
||||
"config.yaml is outdated — run 'hermes setup' to migrate",
|
||||
fix_fn=_fix,
|
||||
)
|
||||
else:
|
||||
report.ok(f"Config version up to date (v{current_ver})")
|
||||
|
||||
|
||||
@register("Configuration Files", "stale-root-keys", priority=40)
|
||||
def check_stale_root_keys(report):
|
||||
from hermes_cli.doctor import HERMES_HOME
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
import yaml
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw_config = yaml.safe_load(f) or {}
|
||||
|
||||
stale = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)]
|
||||
if not stale:
|
||||
return
|
||||
|
||||
def _fix(r):
|
||||
raw_model = raw_config.get("model")
|
||||
if isinstance(raw_model, dict):
|
||||
model_section = raw_model
|
||||
elif isinstance(raw_model, str) and raw_model.strip():
|
||||
model_section = {"default": raw_model.strip()}
|
||||
raw_config["model"] = model_section
|
||||
else:
|
||||
model_section = {}
|
||||
raw_config["model"] = model_section
|
||||
for k in stale:
|
||||
if not model_section.get(k):
|
||||
model_section[k] = raw_config.pop(k)
|
||||
else:
|
||||
raw_config.pop(k)
|
||||
from utils import atomic_yaml_write
|
||||
atomic_yaml_write(config_path, raw_config)
|
||||
r.ok("Migrated stale root-level keys into model section")
|
||||
|
||||
report.warn(
|
||||
f"Stale root-level config keys: {', '.join(stale)}",
|
||||
"(should be under 'model:' section)",
|
||||
)
|
||||
report.add_issue(
|
||||
f"stale root-level keys {stale} in config.yaml",
|
||||
fix_fn=_fix,
|
||||
)
|
||||
|
||||
|
||||
@register("Configuration Files", "max-iterations-ghost", priority=50)
|
||||
def check_max_iterations_ghost(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, _DHH
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
import yaml
|
||||
from hermes_cli.config import load_env, remove_env_value
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw_config = yaml.safe_load(f) or {}
|
||||
|
||||
agent_cfg = raw_config.get("agent")
|
||||
cfg_max_turns = agent_cfg.get("max_turns") if isinstance(agent_cfg, dict) else None
|
||||
if cfg_max_turns is None:
|
||||
cfg_max_turns = raw_config.get("max_turns")
|
||||
|
||||
env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
|
||||
if not (cfg_max_turns is not None and env_ghost is not None
|
||||
and str(cfg_max_turns).strip() != str(env_ghost).strip()):
|
||||
return
|
||||
|
||||
def _fix(r):
|
||||
if remove_env_value("HERMES_MAX_ITERATIONS"):
|
||||
r.ok(
|
||||
"Removed stale HERMES_MAX_ITERATIONS from .env "
|
||||
f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)"
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"could not remove HERMES_MAX_ITERATIONS from {_DHH}/.env — edit manually"
|
||||
)
|
||||
|
||||
report.warn(
|
||||
f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows agent.max_turns={cfg_max_turns} in config.yaml",
|
||||
"(stale ghost from an earlier `hermes setup` run)",
|
||||
)
|
||||
report.add_issue(
|
||||
"stale HERMES_MAX_ITERATIONS in .env shadows config.yaml",
|
||||
fix_fn=_fix,
|
||||
)
|
||||
|
||||
|
||||
@register("Config Structure", "config-structure-validation", priority=10)
|
||||
def check_config_structure(report):
|
||||
from hermes_cli.config import validate_config_structure
|
||||
config_issues = validate_config_structure()
|
||||
if not config_issues:
|
||||
return
|
||||
for ci in config_issues:
|
||||
if ci.severity == "error":
|
||||
report.fail(ci.message)
|
||||
else:
|
||||
report.warn(ci.message)
|
||||
for hint_line in ci.hint.splitlines():
|
||||
report.info(hint_line)
|
||||
report.add_issue(ci.message)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Dependency management checks: venv integrity, uv, required packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
from hermes_cli.doctor.checks._helpers import python_install_cmd
|
||||
|
||||
|
||||
@register("Virtual Environment Integrity", "venv-structure", priority=10)
|
||||
def check_venv_integrity(report):
|
||||
from hermes_cli.managed_uv import get_venv_path, resolve_uv
|
||||
|
||||
venv_path = get_venv_path()
|
||||
if not venv_path.exists():
|
||||
report.warn("Venv directory missing", "(will be recreated on next install/update)")
|
||||
return
|
||||
|
||||
has_uv = bool(resolve_uv() or shutil.which("uv"))
|
||||
if not has_uv:
|
||||
def _fix(r):
|
||||
r.raw_print(" -> Attempting atomic venv recreation...")
|
||||
from hermes_cli.managed_uv import recreate_venv_atomically, get_venv_path as _gvp
|
||||
if recreate_venv_atomically(_gvp().parent, group="all"):
|
||||
r.ok("Venv successfully recreated and swapped to uv-native state")
|
||||
else:
|
||||
raise RuntimeError("Recreation failed — please run the Hermes installer")
|
||||
|
||||
report.fail(
|
||||
"Legacy pip venv detected (uv missing)",
|
||||
"(dependency management will fail)",
|
||||
fix="run `hermes doctor --fix` to recreate",
|
||||
fix_fn=_fix,
|
||||
)
|
||||
else:
|
||||
report.ok("Venv structure valid and uv-native")
|
||||
|
||||
|
||||
@register("Dependency Management", "uv-available", priority=10)
|
||||
def check_uv_available(report):
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
uv_bin = resolve_uv()
|
||||
if uv_bin:
|
||||
report.ok(f"Managed uv available ({uv_bin})")
|
||||
else:
|
||||
path_uv = shutil.which("uv")
|
||||
if path_uv:
|
||||
report.ok(f"System uv available ({path_uv})")
|
||||
else:
|
||||
report.fail(
|
||||
"uv is missing",
|
||||
"(dependency installation will fail. Install uv via the Hermes installer, or `pkg install uv` on Termux)",
|
||||
)
|
||||
|
||||
|
||||
@register("Required Packages", "required-packages", priority=10)
|
||||
def check_required_packages(report):
|
||||
required = [
|
||||
("openai", "OpenAI SDK"),
|
||||
("rich", "Rich (terminal UI)"),
|
||||
("dotenv", "python-dotenv"),
|
||||
("yaml", "PyYAML"),
|
||||
("httpx", "HTTPX"),
|
||||
]
|
||||
optional = [
|
||||
("croniter", "Croniter (cron expressions)"),
|
||||
("telegram", "python-telegram-bot"),
|
||||
("discord", "discord.py"),
|
||||
]
|
||||
install_cmd = python_install_cmd()
|
||||
|
||||
for module, name in required:
|
||||
try:
|
||||
__import__(module)
|
||||
report.ok(name)
|
||||
except ImportError:
|
||||
report.fail(name, "(missing)", fix=f"Install: {install_cmd} {module}")
|
||||
|
||||
for module, name in optional:
|
||||
try:
|
||||
__import__(module)
|
||||
report.ok(name, "(optional)")
|
||||
except ImportError:
|
||||
report.warn(name, "(optional, not installed)")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Directory structure and state file checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Directory Structure", "hermes-home", priority=10)
|
||||
def check_hermes_home_dir(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, _DHH
|
||||
|
||||
if not HERMES_HOME.exists():
|
||||
def _fix(r):
|
||||
HERMES_HOME.mkdir(parents=True, exist_ok=True)
|
||||
r.ok(f"Created {_DHH} directory")
|
||||
report.warn(f"{_DHH} not found", "(will be created on first use)")
|
||||
report.add_issue(f"{_DHH} directory missing", fix_fn=_fix)
|
||||
else:
|
||||
report.ok(f"{_DHH} directory exists")
|
||||
|
||||
for subdir in ("cron", "sessions", "logs", "skills", "memories"):
|
||||
p = HERMES_HOME / subdir
|
||||
if not p.exists():
|
||||
def _fix(r, _p=p, _s=subdir):
|
||||
_p.mkdir(parents=True, exist_ok=True)
|
||||
r.ok(f"Created {_DHH}/{_s}/")
|
||||
report.warn(f"{_DHH}/{subdir}/ not found", "(will be created on first use)")
|
||||
report.add_issue(f"{_DHH}/{subdir}/ missing", fix_fn=_fix)
|
||||
else:
|
||||
report.ok(f"{_DHH}/{subdir}/ exists")
|
||||
|
||||
|
||||
@register("Directory Structure", "soul-md", priority=20)
|
||||
def check_soul_md(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, _DHH
|
||||
|
||||
soul_path = HERMES_HOME / "SOUL.md"
|
||||
if soul_path.exists():
|
||||
content = soul_path.read_text(encoding="utf-8").strip()
|
||||
lines = [l for l in content.splitlines()
|
||||
if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
|
||||
if lines:
|
||||
report.ok(f"{_DHH}/SOUL.md exists (persona configured)")
|
||||
else:
|
||||
report.info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
|
||||
else:
|
||||
def _fix(r):
|
||||
soul_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
soul_path.write_text(
|
||||
"# Hermes Agent Persona\n\n"
|
||||
"<!-- Edit this file to customize how Hermes communicates. -->\n\n"
|
||||
"You are Hermes, a helpful AI assistant.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
r.ok(f"Created {_DHH}/SOUL.md with basic template")
|
||||
|
||||
report.warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
|
||||
report.add_issue(f"{_DHH}/SOUL.md missing", fix_fn=_fix)
|
||||
|
||||
|
||||
@register("Directory Structure", "memories-dir", priority=30)
|
||||
def check_memories_dir(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, _DHH
|
||||
|
||||
memories_dir = HERMES_HOME / "memories"
|
||||
if not memories_dir.exists():
|
||||
def _fix(r):
|
||||
memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
r.ok(f"Created {_DHH}/memories/")
|
||||
report.warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
|
||||
report.add_issue(f"{_DHH}/memories/ missing", fix_fn=_fix)
|
||||
return
|
||||
|
||||
report.ok(f"{_DHH}/memories/ directory exists")
|
||||
for fname in ("MEMORY.md", "USER.md"):
|
||||
fpath = memories_dir / fname
|
||||
if fpath.exists():
|
||||
size = len(fpath.read_text(encoding="utf-8").strip())
|
||||
report.ok(f"{fname} exists ({size} chars)")
|
||||
else:
|
||||
report.info(f"{fname} not created yet (will be created when the agent first writes a memory)")
|
||||
|
||||
|
||||
@register("Directory Structure", "state-db", priority=40)
|
||||
def check_state_db(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, _DHH
|
||||
|
||||
db_path = HERMES_HOME / "state.db"
|
||||
if not db_path.exists():
|
||||
report.info(f"{_DHH}/state.db not created yet (will be created on first session)")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
||||
conn.close()
|
||||
report.ok(f"{_DHH}/state.db exists ({count} sessions)")
|
||||
except Exception as e:
|
||||
from hermes_state import is_malformed_db_error, repair_state_db_schema
|
||||
if is_malformed_db_error(e):
|
||||
def _fix(r):
|
||||
db_report = repair_state_db_schema(db_path)
|
||||
if not db_report.get("repaired"):
|
||||
raise RuntimeError(
|
||||
f"{db_report.get('error')}; backup at {db_report.get('backup_path')}"
|
||||
)
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
||||
conn.close()
|
||||
except Exception:
|
||||
count = "?"
|
||||
backup = Path(db_report["backup_path"]).name if db_report.get("backup_path") else "n/a"
|
||||
r.ok(
|
||||
f"Repaired state.db schema ({count} sessions recovered)",
|
||||
f"(strategy: {db_report.get('strategy')}; backup: {backup})",
|
||||
)
|
||||
|
||||
report.warn(
|
||||
f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)",
|
||||
f"({e})",
|
||||
)
|
||||
report.add_issue("state.db schema malformed", fix_fn=_fix)
|
||||
else:
|
||||
report.warn(f"{_DHH}/state.db exists but has issues: {e}")
|
||||
|
||||
|
||||
@register("Directory Structure", "wal-file", priority=50)
|
||||
def check_wal_file(report):
|
||||
from hermes_cli.doctor import HERMES_HOME, _DHH
|
||||
|
||||
db_path = HERMES_HOME / "state.db"
|
||||
wal_path = HERMES_HOME / "state.db-wal"
|
||||
if not wal_path.exists() or not db_path.exists():
|
||||
return
|
||||
|
||||
wal_size = wal_path.stat().st_size
|
||||
if wal_size > 50 * 1024 * 1024:
|
||||
def _fix(r):
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
conn.close()
|
||||
new_size = wal_path.stat().st_size if wal_path.exists() else 0
|
||||
r.ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)")
|
||||
|
||||
report.warn(
|
||||
f"WAL file is large ({wal_size // (1024 * 1024)} MB)",
|
||||
"(may indicate missed checkpoints)",
|
||||
)
|
||||
report.add_issue("large WAL file — checkpoint needed", fix_fn=_fix)
|
||||
elif wal_size > 10 * 1024 * 1024:
|
||||
report.info(f"WAL file is {wal_size // (1024 * 1024)} MB (normal for active sessions)")
|
||||
@@ -0,0 +1,274 @@
|
||||
"""External tool checks: git, ripgrep, docker, ssh, daytona, node, npm audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
from hermes_cli.doctor.checks._helpers import (
|
||||
_safe_which,
|
||||
is_termux,
|
||||
system_package_install_cmd,
|
||||
termux_browser_setup_steps,
|
||||
termux_install_all_fallback_notes,
|
||||
)
|
||||
|
||||
|
||||
@register("External Tools", "git", priority=10)
|
||||
def check_git(report):
|
||||
if _safe_which("git"):
|
||||
report.ok("git")
|
||||
else:
|
||||
report.warn("git not found", "(hermes update cannot work)")
|
||||
|
||||
|
||||
@register("External Tools", "ripgrep", priority=20)
|
||||
def check_ripgrep(report):
|
||||
if _safe_which("rg"):
|
||||
report.ok("ripgrep (rg)", "(faster file search)")
|
||||
else:
|
||||
report.warn("ripgrep (rg) not found", "(file search uses grep fallback)")
|
||||
report.info(f"Install for faster search: {system_package_install_cmd('ripgrep')}")
|
||||
|
||||
|
||||
@register("External Tools", "docker", priority=30)
|
||||
def check_docker(report):
|
||||
from hermes_cli.doctor import PROJECT_ROOT
|
||||
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
running_in_container = False
|
||||
try:
|
||||
from hermes_constants import is_container as _is_container
|
||||
running_in_container = _is_container()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if running_in_container and terminal_env != "docker":
|
||||
report.info(
|
||||
"Running inside a container — using local terminal backend "
|
||||
"(docker-in-docker is not configured by default)"
|
||||
)
|
||||
return
|
||||
|
||||
if terminal_env == "docker":
|
||||
if not _safe_which("docker"):
|
||||
report.fail(
|
||||
"docker not found",
|
||||
"(required for TERMINAL_ENV=docker)",
|
||||
fix="Install Docker or change TERMINAL_ENV",
|
||||
)
|
||||
return
|
||||
try:
|
||||
res = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
res = None
|
||||
if res is not None and res.returncode == 0:
|
||||
report.ok("docker", "(daemon running)")
|
||||
else:
|
||||
report.fail("docker daemon not running", "", fix="Start Docker daemon")
|
||||
elif _safe_which("docker"):
|
||||
report.ok("docker", "(optional)")
|
||||
elif is_termux():
|
||||
report.info("Docker backend is not available inside Termux (expected on Android)")
|
||||
else:
|
||||
report.warn("docker not found", "(optional)")
|
||||
|
||||
|
||||
@register("External Tools", "ssh-backend", priority=40)
|
||||
def check_ssh_backend(report):
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
if terminal_env != "ssh":
|
||||
return
|
||||
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST")
|
||||
if not ssh_host:
|
||||
report.fail(
|
||||
"TERMINAL_SSH_HOST not set",
|
||||
"(required for TERMINAL_ENV=ssh)",
|
||||
fix="Set TERMINAL_SSH_HOST in .env",
|
||||
)
|
||||
return
|
||||
|
||||
ssh_user = os.getenv("TERMINAL_SSH_USER")
|
||||
ssh_port = os.getenv("TERMINAL_SSH_PORT")
|
||||
ssh_key = os.getenv("TERMINAL_SSH_KEY")
|
||||
target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host
|
||||
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
|
||||
if ssh_port:
|
||||
cmd += ["-p", ssh_port]
|
||||
if ssh_key:
|
||||
cmd += ["-i", os.path.expanduser(ssh_key)]
|
||||
cmd += [target, "echo ok"]
|
||||
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
res = None
|
||||
|
||||
if res is not None and res.returncode == 0:
|
||||
report.ok(f"SSH connection to {ssh_host}")
|
||||
else:
|
||||
report.fail(
|
||||
f"SSH connection to {ssh_host}", "",
|
||||
fix=f"Check SSH configuration for {ssh_host}",
|
||||
)
|
||||
|
||||
|
||||
@register("External Tools", "daytona-backend", priority=50)
|
||||
def check_daytona_backend(report):
|
||||
if os.getenv("TERMINAL_ENV", "local") != "daytona":
|
||||
return
|
||||
|
||||
if not os.getenv("DAYTONA_API_KEY"):
|
||||
report.fail(
|
||||
"DAYTONA_API_KEY not set",
|
||||
"(required for TERMINAL_ENV=daytona)",
|
||||
fix="Set DAYTONA_API_KEY environment variable",
|
||||
)
|
||||
else:
|
||||
report.ok("Daytona API key", "(configured)")
|
||||
|
||||
try:
|
||||
from daytona import Daytona # noqa: F401
|
||||
report.ok("daytona SDK", "(installed)")
|
||||
except ImportError:
|
||||
report.fail(
|
||||
"daytona SDK not installed",
|
||||
"(pip install daytona)",
|
||||
fix="Install daytona SDK: pip install daytona",
|
||||
)
|
||||
|
||||
|
||||
@register("External Tools", "node-and-browser", priority=60)
|
||||
def check_node_and_browser(report):
|
||||
from hermes_cli.doctor import PROJECT_ROOT
|
||||
|
||||
if not _safe_which("node"):
|
||||
if is_termux():
|
||||
report.info("Node.js not found (browser tools are optional in the tested Termux path)")
|
||||
report.info("Install Node.js on Termux with: pkg install nodejs")
|
||||
report.info("Termux browser setup:")
|
||||
for step in termux_browser_setup_steps(node_installed=False):
|
||||
report.info(step)
|
||||
else:
|
||||
report.warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
return
|
||||
|
||||
report.ok("Node.js")
|
||||
|
||||
agent_browser_ok = False
|
||||
if (PROJECT_ROOT / "node_modules" / "agent-browser").exists():
|
||||
report.ok("agent-browser (Node.js)", "(browser automation)")
|
||||
agent_browser_ok = True
|
||||
elif _safe_which("agent-browser"):
|
||||
report.ok("agent-browser", "(browser automation)")
|
||||
agent_browser_ok = True
|
||||
elif is_termux():
|
||||
report.info("agent-browser is not installed (expected in the tested Termux path)")
|
||||
report.info("Install it manually later with: npm install -g agent-browser && agent-browser install")
|
||||
report.info("Termux browser setup:")
|
||||
for step in termux_browser_setup_steps(node_installed=True):
|
||||
report.info(step)
|
||||
else:
|
||||
report.warn("agent-browser not installed", "(run: npm install)")
|
||||
|
||||
if agent_browser_ok and not is_termux():
|
||||
from tools.browser_tool import (
|
||||
_chromium_installed,
|
||||
_is_camofox_mode,
|
||||
_get_cloud_provider,
|
||||
_get_cdp_override,
|
||||
_using_lightpanda_engine,
|
||||
)
|
||||
skip = (
|
||||
_is_camofox_mode()
|
||||
or bool(_get_cdp_override())
|
||||
or _get_cloud_provider() is not None
|
||||
or _using_lightpanda_engine()
|
||||
)
|
||||
if not skip:
|
||||
if _chromium_installed():
|
||||
report.ok("Playwright Chromium", "(browser engine)")
|
||||
else:
|
||||
report.warn(
|
||||
"Playwright Chromium not installed",
|
||||
"(browser_* tools will be hidden from the agent)",
|
||||
)
|
||||
install_cmd = (
|
||||
f"cd {PROJECT_ROOT} && npx playwright install chromium"
|
||||
if sys.platform == "win32"
|
||||
else f"cd {PROJECT_ROOT} && npx playwright install --with-deps chromium"
|
||||
)
|
||||
report.info(f"Install with: {install_cmd}")
|
||||
|
||||
|
||||
@register("External Tools", "npm-audit", priority=70)
|
||||
def check_npm_audit(report):
|
||||
import json
|
||||
from hermes_cli.doctor import PROJECT_ROOT
|
||||
|
||||
npm_bin = _safe_which("npm")
|
||||
if not npm_bin:
|
||||
return
|
||||
|
||||
audit_targets = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]),
|
||||
(PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
|
||||
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
|
||||
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []),
|
||||
]
|
||||
for npm_dir, label, extra in audit_targets:
|
||||
check_dir = PROJECT_ROOT if extra else npm_dir
|
||||
if not (check_dir / "node_modules").exists():
|
||||
continue
|
||||
# best-effort: failures are silently ignored
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[npm_bin, "audit", "--json", *extra],
|
||||
cwd=str(npm_dir),
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
data = json.loads(res.stdout) if res.stdout.strip() else {}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
vc = data.get("metadata", {}).get("vulnerabilities", {})
|
||||
critical = vc.get("critical", 0)
|
||||
high = vc.get("high", 0)
|
||||
moderate = vc.get("moderate", 0)
|
||||
total = critical + high + moderate
|
||||
|
||||
if extra and extra[0] == "--workspace":
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix {' '.join(extra)}"
|
||||
elif extra == ["--workspaces=false"]:
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
|
||||
else:
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix"
|
||||
|
||||
if total == 0:
|
||||
report.ok(f"{label} deps", "(no known vulnerabilities)")
|
||||
elif critical > 0 or high > 0:
|
||||
report.warn(
|
||||
f"{label} deps",
|
||||
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})",
|
||||
)
|
||||
report.add_issue(
|
||||
f"{label} has {total} npm "
|
||||
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
|
||||
)
|
||||
else:
|
||||
report.ok(
|
||||
f"{label} deps",
|
||||
f"({moderate} moderate {'vulnerability' if moderate == 1 else 'vulnerabilities'})",
|
||||
)
|
||||
|
||||
|
||||
@register("External Tools", "termux-notes", priority=80)
|
||||
def check_termux_notes(report):
|
||||
if not is_termux():
|
||||
return
|
||||
report.info("Termux compatibility fallbacks:")
|
||||
for note in termux_install_all_fallback_notes():
|
||||
report.info(note)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Gateway service checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Gateway Service", "linger-check", priority=10)
|
||||
def check_gateway_service_linger(report):
|
||||
from hermes_cli.gateway import get_systemd_linger_status, get_systemd_unit_path, is_linux
|
||||
from hermes_cli.service_manager import detect_service_manager
|
||||
|
||||
if not is_linux() or detect_service_manager() == "s6":
|
||||
return
|
||||
|
||||
unit_path = get_systemd_unit_path()
|
||||
if not unit_path.exists():
|
||||
return
|
||||
|
||||
linger_enabled, linger_detail = get_systemd_linger_status()
|
||||
if linger_enabled is True:
|
||||
report.ok("Systemd linger enabled", "(gateway service survives logout)")
|
||||
elif linger_enabled is False:
|
||||
report.warn("Systemd linger disabled", "(gateway may stop after logout)")
|
||||
report.info("Run: sudo loginctl enable-linger $USER")
|
||||
report.add_issue("Enable linger for the gateway user service: sudo loginctl enable-linger $USER")
|
||||
else:
|
||||
report.warn("Could not verify systemd linger", f"({linger_detail})")
|
||||
|
||||
|
||||
@register("s6 Supervision", "s6-supervision", priority=10)
|
||||
def check_s6_supervision(report):
|
||||
from hermes_cli.service_manager import S6ServiceManager, detect_service_manager
|
||||
|
||||
if detect_service_manager() != "s6":
|
||||
return
|
||||
|
||||
mgr = S6ServiceManager()
|
||||
for static in ("main-hermes", "dashboard"):
|
||||
if mgr.is_running(static):
|
||||
report.ok(f"{static}: up")
|
||||
else:
|
||||
report.info(f"{static}: down (expected if not enabled via env)")
|
||||
|
||||
profiles = mgr.list_profile_gateways()
|
||||
if not profiles:
|
||||
report.info("No per-profile gateways registered yet — create one with `hermes profile create <name>`")
|
||||
return
|
||||
|
||||
up = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}"))
|
||||
suffix = f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else ""
|
||||
report.ok(f"Per-profile gateways: {up}/{len(profiles)} supervised up{suffix}")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""GitHub auth check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Skills Hub", priority=20)
|
||||
def check_github_auth(report):
|
||||
"""Check GitHub authentication status."""
|
||||
from hermes_cli.doctor import _DHH
|
||||
|
||||
try:
|
||||
from hermes_cli.config import get_env_value
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _gh_authenticated() -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "auth", "status", "--json", "authenticated"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
|
||||
if github_token:
|
||||
report.ok("GitHub token configured (authenticated API access)")
|
||||
elif _gh_authenticated():
|
||||
report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
|
||||
else:
|
||||
report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Memory provider checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Memory Provider", "memory-provider", priority=10)
|
||||
def check_memory_provider(report):
|
||||
from hermes_cli.doctor import HERMES_HOME
|
||||
|
||||
provider = ""
|
||||
import yaml as _yaml
|
||||
cfg_path = HERMES_HOME / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
raw = _yaml.safe_load(f) or {}
|
||||
provider = (raw.get("memory") or {}).get("provider", "")
|
||||
|
||||
if not provider:
|
||||
report.ok("Built-in memory active", "(no external provider configured — this is fine)")
|
||||
elif provider == "honcho":
|
||||
_check_honcho(report)
|
||||
elif provider == "mem0":
|
||||
_check_mem0(report)
|
||||
else:
|
||||
_check_generic(report, provider)
|
||||
|
||||
|
||||
def _check_honcho(report):
|
||||
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
|
||||
hcfg = HonchoClientConfig.from_global_config()
|
||||
cfg_path = resolve_config_path()
|
||||
|
||||
if not cfg_path.exists():
|
||||
if hcfg.api_key or hcfg.base_url:
|
||||
report.ok("Honcho configured via environment variables",
|
||||
f"config file {cfg_path} not found, using HONCHO_API_KEY env var")
|
||||
else:
|
||||
report.warn("Honcho config not found", "run: hermes memory setup")
|
||||
elif not hcfg.enabled:
|
||||
report.info(f"Honcho disabled (set enabled: true in {cfg_path} to activate)")
|
||||
elif not (hcfg.api_key or hcfg.base_url):
|
||||
report.fail("Honcho API key or base URL not set", "run: hermes memory setup",
|
||||
fix="No Honcho API key — run 'hermes memory setup'")
|
||||
else:
|
||||
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
|
||||
reset_honcho_client()
|
||||
get_honcho_client(hcfg)
|
||||
report.ok("Honcho connected",
|
||||
f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}")
|
||||
|
||||
|
||||
def _check_mem0(report):
|
||||
from plugins.memory.mem0 import _load_config as _lc
|
||||
cfg = _lc()
|
||||
if cfg.get("api_key"):
|
||||
report.ok("Mem0 API key configured")
|
||||
report.info(f"user_id={cfg.get('user_id', '?')} agent_id={cfg.get('agent_id', '?')}")
|
||||
else:
|
||||
report.fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)",
|
||||
fix="Mem0 is set as memory provider but API key is missing")
|
||||
|
||||
|
||||
def _check_generic(report, provider_name):
|
||||
from plugins.memory import load_memory_provider
|
||||
p = load_memory_provider(provider_name)
|
||||
if p and p.is_available():
|
||||
report.ok(f"{provider_name} provider active")
|
||||
elif p:
|
||||
report.warn(f"{provider_name} configured but not available", "run: hermes memory status")
|
||||
else:
|
||||
report.warn(f"{provider_name} plugin not found", "run: hermes memory setup")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Named profiles check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Profiles", "named-profiles", priority=10)
|
||||
def check_profiles(report):
|
||||
from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
|
||||
|
||||
named = [p for p in list_profiles() if not p.is_default]
|
||||
if not named:
|
||||
return
|
||||
|
||||
report.ok(f"{len(named)} profile(s) found")
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
for p in named:
|
||||
parts = []
|
||||
if p.gateway_running:
|
||||
parts.append("gateway running")
|
||||
if p.model:
|
||||
parts.append(p.model[:30])
|
||||
if not (p.path / "config.yaml").exists():
|
||||
parts.append("⚠ missing config")
|
||||
if not (p.path / ".env").exists():
|
||||
parts.append("no .env")
|
||||
if not (wrapper_dir / p.name).exists():
|
||||
parts.append("no alias")
|
||||
report.ok(f" {p.name}: {', '.join(parts) if parts else 'configured'}")
|
||||
|
||||
if wrapper_dir.is_dir():
|
||||
for wrapper in wrapper_dir.iterdir():
|
||||
if not wrapper.is_file():
|
||||
continue
|
||||
content = wrapper.read_text()
|
||||
if "hermes -p" in content:
|
||||
m = re.search(r"hermes -p (\S+)", content)
|
||||
if m and not profile_exists(m.group(1)):
|
||||
report.warn(f"Orphan alias: {wrapper.name} → profile '{m.group(1)}' no longer exists")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Python environment checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Python Environment", "python-version", priority=10)
|
||||
def check_python_version(report):
|
||||
py = sys.version_info
|
||||
if py >= (3, 11):
|
||||
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
|
||||
elif py >= (3, 10):
|
||||
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
|
||||
report.warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)")
|
||||
else:
|
||||
report.fail(
|
||||
f"Python {py.major}.{py.minor}.{py.micro}",
|
||||
"(3.10+ required)",
|
||||
fix="Upgrade Python to 3.10+",
|
||||
)
|
||||
|
||||
|
||||
@register("Python Environment", "venv-active", priority=20)
|
||||
def check_venv_active(report):
|
||||
if sys.prefix != sys.base_prefix:
|
||||
report.ok("Virtual environment active")
|
||||
else:
|
||||
report.warn("Not in virtual environment", "(recommended)")
|
||||
|
||||
|
||||
@register("Python Environment", "version-consistency", priority=30)
|
||||
def check_version_consistency(report):
|
||||
"""Verify pyproject.toml version matches hermes_cli.__version__."""
|
||||
from hermes_cli.doctor import PROJECT_ROOT
|
||||
from hermes_cli import __version__ as init_version
|
||||
|
||||
pyproject = PROJECT_ROOT / "pyproject.toml"
|
||||
try:
|
||||
text = pyproject.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return # Installed wheel — nothing to cross-check
|
||||
|
||||
in_project = False
|
||||
pyproject_version = None
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
in_project = line == "[project]"
|
||||
continue
|
||||
if in_project and line.startswith("version") and "=" in line:
|
||||
value = line.split("=", 1)[1].split("#", 1)[0].strip().strip("\"\'")
|
||||
pyproject_version = value or None
|
||||
break
|
||||
|
||||
if pyproject_version is None:
|
||||
return
|
||||
|
||||
if pyproject_version == init_version:
|
||||
report.ok("Version files consistent", f"({init_version})")
|
||||
else:
|
||||
report.fail(
|
||||
"Version mismatch between source files",
|
||||
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
|
||||
fix=(
|
||||
"Re-sync version files (e.g. run 'hermes update', or set "
|
||||
"hermes_cli/__init__.py __version__ to match pyproject.toml)"
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Security advisory checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Security Advisories", "security-advisories", priority=10)
|
||||
def check_security_advisories(report):
|
||||
from hermes_cli.security_advisories import (
|
||||
detect_compromised,
|
||||
filter_unacked,
|
||||
full_remediation_text,
|
||||
get_acked_ids,
|
||||
)
|
||||
|
||||
all_hits = detect_compromised()
|
||||
fresh_hits = filter_unacked(all_hits)
|
||||
|
||||
if not fresh_hits:
|
||||
report.ok("No active security advisories")
|
||||
return
|
||||
|
||||
for hit in fresh_hits:
|
||||
report.fail(
|
||||
f"{hit.advisory.title}",
|
||||
f"({hit.package}=={hit.installed_version})",
|
||||
)
|
||||
for line in full_remediation_text(hit):
|
||||
if line:
|
||||
report.raw_print(f" {report.color(line, report.YELLOW)}")
|
||||
else:
|
||||
report.raw_print()
|
||||
report.add_issue(
|
||||
f"Resolve security advisory {hit.advisory.id}: "
|
||||
f"uninstall {hit.package}=={hit.installed_version} and "
|
||||
f"rotate credentials, then run "
|
||||
f"`hermes doctor --ack {hit.advisory.id}`."
|
||||
)
|
||||
|
||||
acked_ids = get_acked_ids()
|
||||
for h in all_hits:
|
||||
if h.advisory.id in acked_ids:
|
||||
report.warn(
|
||||
f"{h.package}=={h.installed_version} still installed "
|
||||
f"(advisory {h.advisory.id} acknowledged)",
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Skills Hub and GitHub auth checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("Skills Hub", "skills-hub-dir", priority=10)
|
||||
def check_skills_hub(report):
|
||||
from hermes_cli.doctor import HERMES_HOME
|
||||
|
||||
hub_dir = HERMES_HOME / "skills" / ".hub"
|
||||
if not hub_dir.exists():
|
||||
report.warn("Skills Hub directory not initialized", "(run: hermes skills list)")
|
||||
return
|
||||
|
||||
report.ok("Skills Hub directory exists")
|
||||
lock_file = hub_dir / "lock.json"
|
||||
if lock_file.exists():
|
||||
lock_data = json.loads(lock_file.read_text())
|
||||
count = len(lock_data.get("installed", {}))
|
||||
report.ok(f"Lock file OK ({count} hub-installed skill(s))")
|
||||
else:
|
||||
report.warn("Lock file", "(corrupted or unreadable)")
|
||||
|
||||
quarantine = hub_dir / "quarantine"
|
||||
q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0
|
||||
if q_count > 0:
|
||||
report.warn(f"{q_count} skill(s) in quarantine", "(pending review)")
|
||||
|
||||
|
||||
@register("Skills Hub", "github-auth", priority=20)
|
||||
def check_github_auth(report):
|
||||
from hermes_cli.doctor import _DHH
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
def _gh_authenticated() -> bool:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["gh", "auth", "status", "--json", "authenticated"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
return res.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
|
||||
if github_token:
|
||||
report.ok("GitHub token configured (authenticated API access)")
|
||||
elif _gh_authenticated():
|
||||
report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
|
||||
else:
|
||||
report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tool availability checks using model_tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
def _is_kanban_worker_env_gate(item: dict) -> bool:
|
||||
if item.get("name") != "kanban":
|
||||
return False
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return False
|
||||
tools = item.get("tools") or []
|
||||
return bool(tools) and all(str(t).startswith("kanban_") for t in tools)
|
||||
|
||||
|
||||
def _honcho_is_configured_for_doctor() -> bool:
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
cfg = HonchoClientConfig.from_global_config()
|
||||
return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _doctor_tool_availability_detail(toolset: str) -> str:
|
||||
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_doctor_tool_availability_overrides(available, unavailable):
|
||||
"""Adjust runtime-gated tool availability for doctor diagnostics.
|
||||
|
||||
Indirects through the hermes_cli.doctor module so that monkeypatching
|
||||
doctor._honcho_is_configured_for_doctor in tests works as expected.
|
||||
"""
|
||||
import hermes_cli.doctor as _doctor_mod
|
||||
updated = list(available)
|
||||
remaining = []
|
||||
for item in unavailable:
|
||||
name = item.get("name")
|
||||
if _is_kanban_worker_env_gate(item):
|
||||
if "kanban" not in updated:
|
||||
updated.append("kanban")
|
||||
continue
|
||||
if name == "honcho" and _doctor_mod._honcho_is_configured_for_doctor():
|
||||
if "honcho" not in updated:
|
||||
updated.append("honcho")
|
||||
continue
|
||||
remaining.append(item)
|
||||
return updated, remaining
|
||||
|
||||
|
||||
@register("Tool Availability", "tool-availability", priority=10)
|
||||
def check_tool_availability(report):
|
||||
from hermes_cli.doctor import PROJECT_ROOT
|
||||
import sys
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
from model_tools import check_tool_availability as _cta, TOOLSET_REQUIREMENTS
|
||||
|
||||
available, unavailable = _apply_doctor_tool_availability_overrides(*_cta())
|
||||
|
||||
for tid in available:
|
||||
info = TOOLSET_REQUIREMENTS.get(tid, {})
|
||||
report.ok(info.get("name", tid), _doctor_tool_availability_detail(tid))
|
||||
|
||||
for item in unavailable:
|
||||
env_vars = item.get("missing_vars") or item.get("env_vars") or []
|
||||
if env_vars:
|
||||
report.warn(item["name"], f"(missing {', '.join(env_vars)})")
|
||||
else:
|
||||
report.warn(item["name"], "(system dependency not met)")
|
||||
|
||||
if any(u.get("missing_vars") or u.get("env_vars") for u in unavailable):
|
||||
report.add_issue("Run 'hermes setup' to configure missing API keys for full tool access")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""xAI model retirement check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.doctor._registry import register
|
||||
|
||||
|
||||
@register("xAI Model Retirement (May 15, 2026)", "xai-retirement", priority=10)
|
||||
def check_xai_retirement(report):
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.xai_retirement import MIGRATION_GUIDE_URL, find_retired_xai_refs, format_issue
|
||||
|
||||
cfg = load_config()
|
||||
retired_refs = find_retired_xai_refs(cfg)
|
||||
if not retired_refs:
|
||||
report.ok("No retired xAI models in config")
|
||||
return
|
||||
for ref in retired_refs:
|
||||
report.warn(format_issue(ref))
|
||||
report.info(f"Migration guide: {MIGRATION_GUIDE_URL}")
|
||||
report.add_issue(
|
||||
f"Update {len(retired_refs)} retired xAI model reference(s) "
|
||||
f"in config.yaml — see {MIGRATION_GUIDE_URL}"
|
||||
)
|
||||
+4
-4
@@ -13,9 +13,9 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
|
||||
from hermes_cli.config import get_hermes_home, get_env_path, load_config
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_constants import display_hermes_home, get_hermes_source_root
|
||||
from agent.skill_utils import is_excluded_skill_path
|
||||
|
||||
|
||||
@@ -224,10 +224,10 @@ def run_dump(args):
|
||||
env_path = get_env_path()
|
||||
load_hermes_dotenv(
|
||||
hermes_home=env_path.parent,
|
||||
project_env=get_project_root() / ".env",
|
||||
project_env=get_hermes_source_root() / ".env",
|
||||
)
|
||||
|
||||
project_root = get_project_root()
|
||||
project_root = get_hermes_source_root()
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
try:
|
||||
|
||||
+13
-29
@@ -15,7 +15,8 @@ import textwrap
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
from gateway.status import terminate_pid
|
||||
from gateway.restart import (
|
||||
@@ -2139,34 +2140,15 @@ def get_launchd_plist_path() -> Path:
|
||||
def _detect_venv_dir() -> Path | None:
|
||||
"""Detect the active virtualenv directory.
|
||||
|
||||
Checks ``sys.prefix`` first (works regardless of the directory name),
|
||||
then ``VIRTUAL_ENV`` env var (covers uv-managed environments where
|
||||
sys.prefix == sys.base_prefix), then falls back to probing common
|
||||
directory names under PROJECT_ROOT.
|
||||
Returns ``None`` when no virtualenv can be found.
|
||||
Delegates to ``get_venv_path()`` from ``hermes_cli.managed_uv``,
|
||||
which is the single source of truth for locating the project venv.
|
||||
Returns ``None`` only when the resolved path doesn't exist on disk
|
||||
(pre-install state).
|
||||
"""
|
||||
# If we're running inside a virtualenv, sys.prefix points to it.
|
||||
if sys.prefix != sys.base_prefix:
|
||||
venv = Path(sys.prefix)
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
from hermes_cli.managed_uv import get_venv_path
|
||||
p = get_venv_path()
|
||||
return p if p.is_dir() else None
|
||||
|
||||
# uv and some other tools set VIRTUAL_ENV without changing sys.prefix.
|
||||
# This catches `uv run` where sys.prefix == sys.base_prefix but the
|
||||
# environment IS a venv. (#8620)
|
||||
_virtual_env = os.environ.get("VIRTUAL_ENV")
|
||||
if _virtual_env:
|
||||
venv = Path(_virtual_env)
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
|
||||
# Fallback: check common virtualenv directory names under the project root.
|
||||
for candidate in (".venv", "venv"):
|
||||
venv = PROJECT_ROOT / candidate
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_python_path() -> str:
|
||||
@@ -2355,7 +2337,8 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
python_path = get_python_path()
|
||||
working_dir = _stable_service_working_dir()
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
from hermes_cli.managed_uv import get_venv_path as _gvp
|
||||
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
|
||||
|
||||
path_entries = _build_service_path_dirs()
|
||||
resolved_node = shutil.which("node")
|
||||
@@ -3193,7 +3176,8 @@ def generate_launchd_plist() -> str:
|
||||
# the systemd unit), then capture the user's full shell PATH so every
|
||||
# user-installed tool (node, ffmpeg, …) is reachable.
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
from hermes_cli.managed_uv import get_venv_path as _gvp
|
||||
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
|
||||
# Resolve the directory containing the node binary (e.g. Homebrew, nvm)
|
||||
# so it's explicitly in PATH even if the user's shell PATH changes later.
|
||||
priority_dirs = _build_service_path_dirs()
|
||||
|
||||
@@ -183,7 +183,8 @@ def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None
|
||||
if extra_args:
|
||||
args.extend(extra_args)
|
||||
params = subprocess.list2cmdline(args)
|
||||
cwd = str(Path(__file__).resolve().parent.parent)
|
||||
from hermes_constants import get_hermes_source_root
|
||||
cwd = str(get_hermes_source_root())
|
||||
elevated_python = _derive_venv_pythonw(sys.executable)
|
||||
try:
|
||||
result = ctypes.windll.shell32.ShellExecuteW(
|
||||
|
||||
+256
-109
@@ -320,7 +320,8 @@ def _require_tty(command_name: str) -> None:
|
||||
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
@@ -499,6 +500,7 @@ from hermes_cli import __version__, __release_date__
|
||||
# (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and
|
||||
# existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged.
|
||||
from hermes_cli.model_setup_flows import (
|
||||
_prompt_auth_credentials_choice,
|
||||
_model_flow_openrouter,
|
||||
_model_flow_nous,
|
||||
_model_flow_openai_codex,
|
||||
@@ -2830,7 +2832,12 @@ def select_provider_and_model(args=None):
|
||||
member_labels = [
|
||||
provider_labels.get(m, m) for m in selected_members
|
||||
]
|
||||
member_idx = _prompt_provider_choice(member_labels, default=member_default)
|
||||
group_label = ordered[provider_idx][1].split(" ▸", 1)[0]
|
||||
member_idx = _prompt_provider_choice(
|
||||
member_labels,
|
||||
default=member_default,
|
||||
title=f"Select {group_label} provider:",
|
||||
)
|
||||
if member_idx is None:
|
||||
print("No change.")
|
||||
return
|
||||
@@ -2974,6 +2981,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
|
||||
("approval", "Approval", "smart command approval"),
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("title_generation", "Title generation", "session titles"),
|
||||
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
|
||||
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
|
||||
@@ -3263,6 +3271,7 @@ def _aux_flow_provider_model(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
pricing=pricing,
|
||||
confirm_provider=provider_slug,
|
||||
)
|
||||
if selected is None:
|
||||
print("No change.")
|
||||
@@ -3331,7 +3340,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
|
||||
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
|
||||
|
||||
|
||||
def _prompt_provider_choice(choices, *, default=0):
|
||||
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
|
||||
"""Show provider selection menu with curses arrow-key navigation.
|
||||
|
||||
Falls back to a numbered list when curses is unavailable (e.g. piped
|
||||
@@ -3341,7 +3350,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
try:
|
||||
from hermes_cli.setup import _curses_prompt_choice
|
||||
|
||||
idx = _curses_prompt_choice("Select provider:", choices, default)
|
||||
idx = _curses_prompt_choice(title, choices, default)
|
||||
if idx >= 0:
|
||||
print()
|
||||
return idx
|
||||
@@ -3349,7 +3358,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
pass
|
||||
|
||||
# Fallback: numbered list
|
||||
print("Select provider:")
|
||||
print(title)
|
||||
for i, c in enumerate(choices, 1):
|
||||
marker = "→" if i - 1 == default else " "
|
||||
print(f" {marker} {i}. {c}")
|
||||
@@ -4508,10 +4517,10 @@ def _nixos_build_env() -> dict[str, str] | None:
|
||||
return None
|
||||
|
||||
# Tier 1: fast path — hermes venv python3, no nix-shell overhead
|
||||
for venv_name in ("venv", ".venv"):
|
||||
venv_python = PROJECT_ROOT / venv_name / "bin" / "python3"
|
||||
if venv_python.exists():
|
||||
return {**os.environ, "PYTHON": str(venv_python)}
|
||||
from hermes_cli.managed_uv import get_venv_path as _gvp
|
||||
venv_python = _gvp() / "bin" / "python3"
|
||||
if venv_python.exists():
|
||||
return {**os.environ, "PYTHON": str(venv_python)}
|
||||
|
||||
# Tier 2: nix-shell fallback — resolves the absolute python3 path once.
|
||||
# Slower (~2–5 s for the nix-shell eval) but always works, even without
|
||||
@@ -5776,35 +5785,14 @@ def _update_via_zip(args):
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin = ensure_uv()
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
uv_bin = _ensure_uv_for_termux()
|
||||
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
uv_env.pop("PYTHONPATH", None)
|
||||
uv_env.pop("PYTHONHOME", None)
|
||||
_install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
|
||||
_install_python_dependencies_with_optional_fallback()
|
||||
else:
|
||||
# Use sys.executable to explicitly call the venv's pip module,
|
||||
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
|
||||
# Some environments lose pip inside the venv; bootstrap it back with
|
||||
# ensurepip before trying the editable install.
|
||||
try:
|
||||
subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
_install_python_dependencies_with_optional_fallback(pip_cmd)
|
||||
# Degenerate fallback: managed uv failed to install.
|
||||
_install_python_dependencies_with_optional_fallback(group="termux-all" if _is_termux_env() else "all")
|
||||
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
@@ -6385,6 +6373,150 @@ def _load_installable_optional_extras(group: str = "all") -> list[str]:
|
||||
return referenced
|
||||
|
||||
|
||||
# Install-scoped breadcrumb dropped right before ``hermes update`` mutates the
|
||||
# venv and cleared only after the dependency install verifies clean. If a user
|
||||
# kills the update mid-install (Ctrl-C, terminal close, WSL OOM), the marker
|
||||
# survives and the next ``hermes`` launch finishes the install instead of
|
||||
# limping along on a half-built venv (e.g. pip wiped, a core dep like Pillow
|
||||
# never landed). Lives next to the venv (not under $HERMES_HOME) because the
|
||||
# venv is shared across all profiles, so a single marker covers every profile.
|
||||
def _update_marker_path() -> Path:
|
||||
return PROJECT_ROOT / ".update-incomplete"
|
||||
|
||||
|
||||
def _write_update_incomplete_marker() -> None:
|
||||
"""Drop the interrupted-install breadcrumb. Never raises."""
|
||||
try:
|
||||
_update_marker_path().write_text(
|
||||
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not write update-incomplete marker: %s", exc)
|
||||
|
||||
|
||||
def _clear_update_incomplete_marker() -> None:
|
||||
"""Remove the interrupted-install breadcrumb. Never raises."""
|
||||
try:
|
||||
_update_marker_path().unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug("Could not clear update-incomplete marker: %s", exc)
|
||||
|
||||
|
||||
def _recover_from_interrupted_install() -> None:
|
||||
"""Finish a dependency install that a prior ``hermes update`` left half-done.
|
||||
|
||||
Triggered on launch when ``.update-incomplete`` is present — meaning the
|
||||
code was pulled but the dep install was killed before it verified clean.
|
||||
Re-runs the editable ``.[all]`` install via the managed ``uv`` binary
|
||||
(guaranteed by ``ensure_uv()``), falls back to plain pip if degraded,
|
||||
runs core-dependency verification, then clears the marker.
|
||||
|
||||
Never raises: a recovery failure must not block launch. If it can't
|
||||
self-heal it prints the one-line manual command and leaves the marker so
|
||||
the next launch tries again.
|
||||
|
||||
Concurrency: the marker lives next to the shared venv, so a gateway start
|
||||
plus a CLI launch (or two profiles starting at once) can both see it. An
|
||||
``O_EXCL`` lockfile ensures only one process runs the reinstall; the
|
||||
others skip and let the winner clear the marker.
|
||||
|
||||
Output: everything — our status lines AND the streamed pip/uv install
|
||||
(which inherits fd 1) — is routed to stderr. Launches whose stdout is a
|
||||
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
|
||||
install noise on stdout.
|
||||
"""
|
||||
if not _update_marker_path().exists():
|
||||
return
|
||||
|
||||
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
|
||||
# those don't run the source-tree update path, so a stray marker is not ours
|
||||
# to act on. Just clear it.
|
||||
if not (PROJECT_ROOT / "pyproject.toml").is_file():
|
||||
_clear_update_incomplete_marker()
|
||||
return
|
||||
|
||||
# Single-flight guard: atomically claim the recovery lock. If another
|
||||
# process holds it, skip — it is running the same reinstall into the same
|
||||
# shared venv right now. A crashed holder leaves a stale lock; break it
|
||||
# after an hour (well past any realistic install) so recovery can't be
|
||||
# wedged forever.
|
||||
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, f"{os.getpid()}\n".encode())
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
try:
|
||||
if _time.time() - lock_path.stat().st_mtime > 3600:
|
||||
lock_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
except OSError as exc:
|
||||
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
|
||||
# the install itself will surface the real problem.
|
||||
logger.debug("Could not create install-recovery lock: %s", exc)
|
||||
|
||||
saved_stdout_fd = None
|
||||
saved_sys_stdout = sys.stdout
|
||||
try:
|
||||
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
|
||||
# for the duration of recovery (see docstring: ACP stdout safety).
|
||||
try:
|
||||
saved_stdout_fd = os.dup(1)
|
||||
os.dup2(2, 1)
|
||||
except OSError:
|
||||
saved_stdout_fd = None
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
print(
|
||||
"⚠ A previous `hermes update` was interrupted mid-install — "
|
||||
"finishing dependency installation now..."
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
|
||||
# ensure_uv() guarantees the managed uv binary is present, bootstrapping
|
||||
# it via the official installer if a killed install removed it.
|
||||
uv_bin = ensure_uv()
|
||||
if uv_bin:
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
group="termux-all" if _is_termux_env() else "all",
|
||||
)
|
||||
else:
|
||||
# Degenerate fallback: managed uv failed to install.
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
group="termux-all" if _is_termux_env() else "all",
|
||||
)
|
||||
|
||||
_clear_update_incomplete_marker()
|
||||
print("✓ Dependency installation recovered — your install is healthy again.")
|
||||
except Exception as exc:
|
||||
# Leave the marker in place so the next launch retries. Give the user
|
||||
# the exact manual recovery command in the meantime.
|
||||
logger.debug("Interrupted-install recovery failed: %s", exc)
|
||||
print("✗ Could not auto-recover the interrupted install.")
|
||||
print(" Recover manually by ensuring uv is installed, then run:")
|
||||
print(f" cd {PROJECT_ROOT}")
|
||||
print(" uv pip install -e '.[all]'")
|
||||
print(" (Or re-run the Hermes installer: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash)")
|
||||
finally:
|
||||
sys.stdout = saved_sys_stdout
|
||||
if saved_stdout_fd is not None:
|
||||
try:
|
||||
os.dup2(saved_stdout_fd, 1)
|
||||
os.close(saved_stdout_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
lock_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _run_install_with_heartbeat(
|
||||
cmd: list[str],
|
||||
*,
|
||||
@@ -6869,7 +7001,6 @@ def _refresh_active_lazy_features() -> None:
|
||||
|
||||
|
||||
def _install_python_dependencies_with_optional_fallback(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
group: str = "all",
|
||||
@@ -6883,7 +7014,14 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
in the venv Scripts dir before each install attempt so uv can write fresh
|
||||
copies (Windows blocks REPLACE on a running .exe but allows RENAME). See
|
||||
``_quarantine_running_hermes_exe`` for the rationale.
|
||||
|
||||
Uses ``get_pip_cmd()`` for dependency installation — this function builds
|
||||
non-standard install commands (``-e .[group]``, ``--user``, etc.) that
|
||||
don't fit the ``pip_install()`` helper's signature.
|
||||
"""
|
||||
from hermes_cli.managed_uv import get_pip_cmd
|
||||
|
||||
install_cmd_prefix = get_pip_cmd()
|
||||
scripts_dir = _venv_scripts_dir() if _is_windows() else None
|
||||
|
||||
def _install(args: list[str]) -> None:
|
||||
@@ -7152,7 +7290,6 @@ def _is_android_python() -> bool:
|
||||
|
||||
|
||||
def _install_psutil_android_compat(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
@@ -7181,13 +7318,14 @@ def _install_psutil_android_compat(
|
||||
urllib.request.urlretrieve(PSUTIL_URL, archive)
|
||||
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
|
||||
|
||||
from hermes_cli.managed_uv import get_pip_cmd as _gpc
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)],
|
||||
_gpc() + ["install", "--no-build-isolation", str(src_root)],
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
def _ensure_uv_for_termux() -> str | None:
|
||||
"""Best-effort uv bootstrap on Termux for faster update installs.
|
||||
|
||||
The normal path (``ensure_uv()`` in managed_uv) installs the managed
|
||||
@@ -7204,7 +7342,8 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
return None
|
||||
try:
|
||||
print(" → Termux detected: trying to install uv for faster dependency updates...")
|
||||
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
|
||||
from hermes_cli.managed_uv import get_pip_cmd as _gpc
|
||||
subprocess.run(_gpc() + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
|
||||
except Exception:
|
||||
pass
|
||||
# After pip install, check managed path first, then PATH
|
||||
@@ -7858,12 +7997,19 @@ def cmd_update(args):
|
||||
|
||||
|
||||
def _cmd_update_pip(args):
|
||||
"""Update Hermes via pip (for PyPI installs)."""
|
||||
"""Update Hermes via its managed install path.
|
||||
|
||||
- ``uv tool install`` / ``uv tool upgrade`` path: use ``uv tool upgrade hermes-agent``.
|
||||
- ``pipx`` path: use ``pipx upgrade hermes-agent``.
|
||||
- Direct git-checkout (standard install.sh path): delegate to the git-based
|
||||
``_cmd_update_impl`` which pulls, reinstalls deps, etc.
|
||||
- Plain ``pip install hermes-agent`` from PyPI is no longer supported.
|
||||
Users on that path should reinstall via the official installer.
|
||||
"""
|
||||
from hermes_cli import __version__
|
||||
from hermes_cli.config import is_uv_tool_install
|
||||
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
@@ -7871,46 +8017,33 @@ def _cmd_update_pip(args):
|
||||
update_managed_uv()
|
||||
|
||||
uv = ensure_uv()
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
pipx = shutil.which("pipx") if pipx_managed else None
|
||||
|
||||
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
|
||||
# exported (uv refuses to install without it when the launcher shim
|
||||
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
|
||||
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
|
||||
# set it for them.
|
||||
export_virtualenv = False
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but managed uv install failed.")
|
||||
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
print(f"→ Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
elif pipx_managed and pipx:
|
||||
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
|
||||
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
|
||||
cmd = [pipx, "upgrade", "hermes-agent"]
|
||||
elif uv:
|
||||
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
|
||||
if in_venv:
|
||||
# Launcher shim runs the venv interpreter but doesn't export
|
||||
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
|
||||
export_virtualenv = True
|
||||
else:
|
||||
# Outside any venv, ``--system`` lets uv target the active
|
||||
# interpreter, matching pip's default behaviour.
|
||||
cmd.insert(3, "--system")
|
||||
print(f"→ Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
# Not a uv-tool or pipx install. The supported path is the git
|
||||
# checkout created by install.sh — it has a .git dir and uses
|
||||
# ``hermes update`` (the git-pull + reinstall path) not pip.
|
||||
# A raw ``pip install hermes-agent`` from PyPI is no longer supported.
|
||||
print("✗ Cannot update: PyPI-based installs (pip install hermes-agent) are no longer supported.")
|
||||
print()
|
||||
print(" Please reinstall using the official installer, then use `hermes update`:")
|
||||
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"→ Running: {' '.join(cmd)}")
|
||||
run_kwargs = {}
|
||||
if export_virtualenv:
|
||||
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
|
||||
result = subprocess.run(cmd, **run_kwargs)
|
||||
if result.returncode != 0:
|
||||
print("✗ Update failed")
|
||||
sys.exit(1)
|
||||
@@ -7918,6 +8051,7 @@ def _cmd_update_pip(args):
|
||||
print("✓ Update complete! Restart hermes to use the new version.")
|
||||
|
||||
|
||||
|
||||
def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"""Body of ``cmd_update`` — kept separate so the wrapper can always
|
||||
restore stdio even on ``sys.exit``."""
|
||||
@@ -8316,6 +8450,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
|
||||
# breaks on this machine, keep base deps and reinstall the remaining extras
|
||||
# individually so update does not silently strip working capabilities.
|
||||
#
|
||||
# Drop the interrupted-install breadcrumb BEFORE touching the venv. If
|
||||
# the install is killed mid-flight (Ctrl-C, terminal close, WSL OOM),
|
||||
# the marker survives and the next ``hermes`` launch finishes the
|
||||
# install via ``_recover_from_interrupted_install``. Cleared only after
|
||||
# the install + core-dependency verification completes below.
|
||||
_write_update_incomplete_marker()
|
||||
print("→ Updating Python dependencies...")
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
@@ -8323,51 +8464,42 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin = ensure_uv()
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
install_group = "all"
|
||||
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
uv_env.pop("PYTHONPATH", None)
|
||||
uv_env.pop("PYTHONHOME", None)
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using uv + curated termux-all optional profile...")
|
||||
if _is_termux_env(uv_env) and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat([uv_bin, "pip"], env=uv_env)
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
[uv_bin, "pip"], env=uv_env, group=install_group
|
||||
)
|
||||
else:
|
||||
# Use sys.executable to explicitly call the venv's pip module,
|
||||
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
|
||||
# Some environments lose pip inside the venv; bootstrap it back with
|
||||
# ensurepip before trying the editable install.
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
if _is_termux_env():
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using curated termux-all optional profile...")
|
||||
print(" → Termux detected: using uv + curated termux-all optional profile...")
|
||||
if _is_termux_env() and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat(pip_cmd)
|
||||
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
|
||||
_install_psutil_android_compat()
|
||||
_install_python_dependencies_with_optional_fallback(group=install_group)
|
||||
else:
|
||||
# Degenerate fallback: managed uv failed to install.
|
||||
uv_bin = ensure_uv()
|
||||
if uv_bin:
|
||||
if _is_termux_env():
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using curated termux-all optional profile...")
|
||||
if _is_termux_env() and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat()
|
||||
_install_python_dependencies_with_optional_fallback(group=install_group)
|
||||
else:
|
||||
# Ultimate degenerate fallback: no uv at all.
|
||||
if _is_termux_env():
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using curated termux-all optional profile...")
|
||||
if _is_termux_env() and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat()
|
||||
_install_python_dependencies_with_optional_fallback(group=install_group)
|
||||
|
||||
# Core Python deps installed AND verified (the fallback helper runs
|
||||
# _verify_core_dependencies_installed). Clear the interrupted-install
|
||||
# breadcrumb now — the remaining steps (lazy refresh, node deps, web
|
||||
# UI, desktop rebuild) are non-core and can't brick the venv.
|
||||
_clear_update_incomplete_marker()
|
||||
|
||||
_refresh_active_lazy_features()
|
||||
|
||||
@@ -10066,10 +10198,9 @@ def cmd_dashboard(args):
|
||||
except ImportError as e:
|
||||
print("Web UI dependencies not installed (need fastapi + uvicorn).")
|
||||
print(
|
||||
f"Re-install the package into this interpreter so metadata updates apply:\n"
|
||||
f"Re-install the package using uv so metadata updates apply:\n"
|
||||
f" cd {PROJECT_ROOT}\n"
|
||||
f" {sys.executable} -m pip install -e .\n"
|
||||
"If `pip` is missing in this venv, use: uv pip install -e ."
|
||||
f" uv pip install -e .\n"
|
||||
)
|
||||
print(f"Import error: {e}")
|
||||
sys.exit(1)
|
||||
@@ -10683,6 +10814,22 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Self-heal a venv left half-built by an interrupted ``hermes update``
|
||||
# (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is
|
||||
# *running* update — that flow writes and clears its own marker, and we
|
||||
# don't want a recovery install racing the real one. Never raises.
|
||||
#
|
||||
# The substring match is deliberately loose: argv isn't parsed yet at this
|
||||
# point, and the failure modes are asymmetric. Over-matching (e.g.
|
||||
# ``hermes skills install update``) merely defers recovery one launch;
|
||||
# under-matching (missing ``hermes -p work update``) would race a recovery
|
||||
# install against the real one. Loose wins.
|
||||
try:
|
||||
if "update" not in sys.argv[1:]:
|
||||
_recover_from_interrupted_install()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _try_termux_fast_tui_launch():
|
||||
return
|
||||
if _try_termux_fast_cli_launch():
|
||||
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -250,5 +251,213 @@ def _install_uv_windows(env: dict[str, str]) -> None:
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
def get_pip_cmd() -> list[str]:
|
||||
"""Return the authoritative pip command prefix.
|
||||
|
||||
Hermes strictly requires `uv` for dependency management.
|
||||
Fallback hierarchy:
|
||||
1. Managed uv at `$HERMES_HOME/bin/uv` (guaranteed by `ensure_uv()`).
|
||||
2. System/PATH `uv` (e.g., Termux `pkg install uv`, Homebrew, etc.).
|
||||
|
||||
If neither is found, this raises a RuntimeError. We NEVER fall back to
|
||||
raw `[sys.executable, "-m", "pip"]`, as that re-introduces the
|
||||
ensurepip/PEP-668/venv-contamination bugs this architecture was built to eliminate.
|
||||
"""
|
||||
uv_bin = resolve_uv()
|
||||
if uv_bin:
|
||||
return [uv_bin, "pip"]
|
||||
|
||||
# Secondary fallback: check PATH for uv (critical for Termux `pkg install uv` support)
|
||||
path_uv = shutil.which("uv")
|
||||
if path_uv:
|
||||
return [path_uv, "pip"]
|
||||
|
||||
# HARD FAIL: uv is a strict requirement. Do not silently degrade to raw pip.
|
||||
raise RuntimeError(
|
||||
"uv is not installed or not found in PATH. "
|
||||
"Hermes strictly requires uv for dependency management. "
|
||||
"Please run `hermes doctor` to diagnose and fix your environment, "
|
||||
"or install uv manually (e.g., `pkg install uv` on Termux, or via the Hermes installer)."
|
||||
)
|
||||
|
||||
|
||||
def get_venv_root() -> Path:
|
||||
"""Return the root path of the active virtual environment.
|
||||
|
||||
Prefers `sys.prefix` (the standard Python way to identify a venv).
|
||||
Falls back to the parent of the parent of `sys.executable`
|
||||
(e.g., `/path/to/venv/bin/python` -> `/path/to/venv`).
|
||||
"""
|
||||
if sys.prefix != sys.base_prefix:
|
||||
return Path(sys.prefix)
|
||||
return Path(sys.executable).parent.parent
|
||||
|
||||
|
||||
def get_venv_path() -> Path:
|
||||
"""Return the project's virtual environment directory.
|
||||
|
||||
This is the single authoritative way to locate the Hermes venv on disk.
|
||||
Previously scattered as ``PROJECT_ROOT / "venv"`` or ``PROJECT_ROOT / ".venv"``
|
||||
throughout the codebase.
|
||||
|
||||
Resolution order:
|
||||
1. The active venv (``sys.prefix``) when hermes is running inside one.
|
||||
This covers every production layout:
|
||||
- dev checkout: <checkout>/.venv or <checkout>/venv
|
||||
- normal user install: ~/.hermes/hermes-agent/venv
|
||||
- root/system install: /usr/local/lib/hermes-agent/venv
|
||||
- docker: /opt/hermes/.venv
|
||||
- nix package install: /nix/store/<hash>-hermes-agent-python3.12-env
|
||||
- nix devshell: <checkout>/.venv
|
||||
Hermes is always invoked through its own venv wrapper, so sys.prefix
|
||||
reliably points to the right place.
|
||||
2. ``<source_root>/.venv`` if it exists on disk (pre-activation fallback).
|
||||
3. ``<source_root>/venv`` if it exists on disk.
|
||||
4. ``<source_root>/venv`` as the canonical default (may not exist yet,
|
||||
e.g. before the first install run).
|
||||
"""
|
||||
# we're unning inside a venv
|
||||
if sys.prefix != sys.base_prefix:
|
||||
return Path(sys.prefix).resolve()
|
||||
|
||||
# we're not in a venv, probe conventional names under the source root
|
||||
from hermes_constants import get_hermes_source_root
|
||||
source_root = get_hermes_source_root()
|
||||
for name in (".venv", "venv"):
|
||||
candidate = source_root / name
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return source_root / "venv"
|
||||
|
||||
|
||||
def pip_install(
|
||||
packages: list[str],
|
||||
*,
|
||||
venv_root: Optional[Path] = None,
|
||||
timeout: int = 300,
|
||||
capture_output: bool = True,
|
||||
quiet: bool = False,
|
||||
upgrade: bool = False,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Install packages using the managed uv binary (with degenerate pip fallback).
|
||||
|
||||
This is the single, authoritative way to install Python dependencies in Hermes.
|
||||
It automatically:
|
||||
1. Resolves the correct venv root.
|
||||
2. Sets `VIRTUAL_ENV` and prepends the venv `bin` to `PATH`.
|
||||
3. Strips `PYTHONPATH` and `PYTHONHOME` to prevent venv contamination
|
||||
(critical for Termux/Android compatibility).
|
||||
4. Uses `get_pip_cmd()` to guarantee the managed uv binary is used.
|
||||
"""
|
||||
if venv_root is None:
|
||||
venv_root = get_venv_root()
|
||||
|
||||
cmd = get_pip_cmd() + ["install"]
|
||||
if upgrade:
|
||||
cmd.append("--upgrade")
|
||||
if quiet:
|
||||
cmd.append("--quiet")
|
||||
cmd.extend(packages)
|
||||
|
||||
env = {**os.environ}
|
||||
env["VIRTUAL_ENV"] = str(venv_root)
|
||||
|
||||
# Ensure venv bin is first in PATH
|
||||
venv_bin = str(venv_root / "bin")
|
||||
env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
# Clean up PYTHONPATH/PYTHONHOME to avoid venv contamination
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
# Synthesize a failure result so callers can handle it uniformly
|
||||
return subprocess.CompletedProcess(
|
||||
args=cmd,
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
|
||||
|
||||
def recreate_venv_atomically(project_root: Path, group: str = "all") -> bool:
|
||||
"""Atomically recreate the venv to ensure a clean, uv-native state.
|
||||
|
||||
This is the safest way to migrate from a legacy pip-created venv or
|
||||
repair a corrupted venv. It builds a fresh `venv.new`, installs dependencies,
|
||||
and then atomically swaps `venv` -> `venv.bak` and `venv.new` -> `venv`.
|
||||
|
||||
This guarantees we never accidentally strip dependencies or leave legacy
|
||||
pip cruft behind, as we are building a pristine environment from scratch.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
target_venv = project_root / "venv"
|
||||
new_venv = project_root / "venv.new"
|
||||
backup_venv = project_root / "venv.bak"
|
||||
|
||||
uv_bin = resolve_uv() or shutil.which("uv")
|
||||
if not uv_bin:
|
||||
logger.error("Cannot recreate venv: uv is not installed or found in PATH.")
|
||||
return False
|
||||
|
||||
print(f" → Creating fresh venv at {new_venv}...")
|
||||
# 1. Create fresh venv
|
||||
res = subprocess.run(
|
||||
[uv_bin, "venv", str(new_venv)],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if res.returncode != 0:
|
||||
logger.error("Failed to create new venv: %s", res.stderr)
|
||||
return False
|
||||
|
||||
print(f" → Installing dependencies into new venv ({group})...")
|
||||
# 2. Install dependencies into the new venv
|
||||
env = {**os.environ, "VIRTUAL_ENV": str(new_venv)}
|
||||
env["PATH"] = f"{new_venv / 'bin'}{os.pathsep}{env.get('PATH', '')}"
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
|
||||
res = subprocess.run(
|
||||
[uv_bin, "pip", "install", "-e", f".[{group}]"],
|
||||
cwd=project_root,
|
||||
capture_output=True, text=True, timeout=600,
|
||||
env=env, stdin=subprocess.DEVNULL
|
||||
)
|
||||
if res.returncode != 0:
|
||||
logger.error("Failed to install dependencies in new venv: %s", res.stderr)
|
||||
# Clean up failed new venv
|
||||
shutil.rmtree(new_venv, ignore_errors=True)
|
||||
return False
|
||||
|
||||
print(" -> Dependencies installed successfully. Performing atomic swap...")
|
||||
# 3. Atomic swap
|
||||
try:
|
||||
if target_venv.exists():
|
||||
if backup_venv.exists():
|
||||
shutil.rmtree(backup_venv, ignore_errors=True)
|
||||
target_venv.rename(backup_venv)
|
||||
|
||||
new_venv.rename(target_venv)
|
||||
print(" OK Venv successfully recreated and swapped.")
|
||||
print(" -> (Old venv backed up to venv.bak. You can safely delete it if everything works.)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to atomically swap venvs: %s", e)
|
||||
# Attempt to restore if swap failed mid-way
|
||||
if not target_venv.exists() and backup_venv.exists():
|
||||
backup_venv.rename(target_venv)
|
||||
return False
|
||||
|
||||
|
||||
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
|
||||
True # dont remove me. ask ethernet
|
||||
@@ -129,7 +129,8 @@ def _catalog_root() -> Path:
|
||||
"""Return the optional-mcps/ directory shipped with this Hermes install."""
|
||||
# Prefer the env-var override / packaged location; fall back to the repo's
|
||||
# optional-mcps/ next to the package (source checkout).
|
||||
return get_optional_mcps_dir(Path(__file__).parent.parent / "optional-mcps")
|
||||
from hermes_constants import get_hermes_source_root
|
||||
return get_optional_mcps_dir(get_hermes_source_root() / "optional-mcps")
|
||||
|
||||
|
||||
def _parse_env_spec(raw: Any) -> EnvVarSpec:
|
||||
|
||||
@@ -8,10 +8,13 @@ the provider's config schema. Writes config to config.yaml + .env.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
@@ -96,33 +99,15 @@ def _install_dependencies(provider_name: str) -> None:
|
||||
|
||||
print(f"\n Installing dependencies: {', '.join(missing)}")
|
||||
|
||||
import shutil
|
||||
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path:
|
||||
install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing
|
||||
manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}"
|
||||
else:
|
||||
pip_cmd = shutil.which("pip3") or shutil.which("pip")
|
||||
if not pip_cmd:
|
||||
print(f" ⚠ uv not found — cannot install dependencies")
|
||||
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(f" Then re-run: hermes memory setup")
|
||||
return
|
||||
print(f" ⚠ uv not found. Falling back to standard pip...")
|
||||
install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing
|
||||
manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}"
|
||||
|
||||
manual_cmd = f"uv pip install {' '.join(missing)}"
|
||||
try:
|
||||
subprocess.run(
|
||||
install_cmd,
|
||||
check=True, timeout=120,
|
||||
capture_output=True,
|
||||
)
|
||||
result = pip_install(missing, quiet=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr)
|
||||
print(f" ✓ Installed {', '.join(missing)}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" ⚠ Failed to install {', '.join(missing)}")
|
||||
stderr = (e.stderr or b"").decode()[:200]
|
||||
stderr = (e.stderr or b"").decode()[:200] if isinstance(e.stderr, bytes) else (e.stderr or "")[:200]
|
||||
if stderr:
|
||||
print(f" {stderr}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Expensive-model confirmation helpers for model selection surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Optional
|
||||
|
||||
from agent.models_dev import ModelInfo
|
||||
|
||||
|
||||
INPUT_COST_WARNING_THRESHOLD = Decimal("20")
|
||||
OUTPUT_COST_WARNING_THRESHOLD = Decimal("100")
|
||||
GPT55_PRO_OPENROUTER_ID = "openai/gpt-5.5-pro"
|
||||
GPT55_SUGGESTION = "did you mean to select openai/gpt-5.5?"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpensiveModelWarning:
|
||||
"""Confirmation payload for models above Hermes' cost guardrail."""
|
||||
|
||||
model: str
|
||||
provider: str
|
||||
input_cost_per_million: Optional[Decimal]
|
||||
output_cost_per_million: Optional[Decimal]
|
||||
source: str
|
||||
message: str
|
||||
|
||||
|
||||
def _to_decimal(value: object) -> Optional[Decimal]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_money(value: Optional[Decimal]) -> str:
|
||||
if value is None:
|
||||
return "unknown"
|
||||
return f"${value:.2f}/M"
|
||||
|
||||
|
||||
def _pricing_from_model_info(
|
||||
model_info: Optional[ModelInfo],
|
||||
) -> tuple[Optional[Decimal], Optional[Decimal], str]:
|
||||
if model_info is None or not model_info.has_cost_data():
|
||||
return None, None, ""
|
||||
return (
|
||||
_to_decimal(model_info.cost_input),
|
||||
_to_decimal(model_info.cost_output),
|
||||
"models.dev",
|
||||
)
|
||||
|
||||
|
||||
def expensive_model_warning(
|
||||
model_name: str,
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
model_info: Optional[ModelInfo] = None,
|
||||
) -> Optional[ExpensiveModelWarning]:
|
||||
"""Return a warning payload when known pricing exceeds safety thresholds.
|
||||
|
||||
The guard only triggers when pricing is known. Callers should use this after
|
||||
model resolution so aliases and provider-specific model IDs have settled.
|
||||
"""
|
||||
model = (model_name or "").strip()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
input_cost, output_cost, source = _pricing_from_model_info(model_info)
|
||||
if input_cost is None and output_cost is None and provider:
|
||||
try:
|
||||
from agent.models_dev import get_model_info
|
||||
|
||||
input_cost, output_cost, source = _pricing_from_model_info(
|
||||
get_model_info(provider, model)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if input_cost is None and output_cost is None:
|
||||
try:
|
||||
from agent.usage_pricing import get_pricing_entry
|
||||
|
||||
entry = get_pricing_entry(
|
||||
model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
except Exception:
|
||||
entry = None
|
||||
if entry is not None:
|
||||
input_cost = entry.input_cost_per_million
|
||||
output_cost = entry.output_cost_per_million
|
||||
source = entry.source
|
||||
|
||||
over_input = (
|
||||
input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD
|
||||
)
|
||||
over_output = (
|
||||
output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD
|
||||
)
|
||||
if not over_input and not over_output:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
"!!! EXPENSIVE MODEL WARNING !!!",
|
||||
"",
|
||||
f"{model} has known pricing above Hermes' safety threshold.",
|
||||
f"Input tokens: {_format_money(input_cost)}",
|
||||
f"Output tokens: {_format_money(output_cost)}",
|
||||
(
|
||||
"Threshold: more than $20/M input tokens or more than "
|
||||
"$100/M output tokens."
|
||||
),
|
||||
]
|
||||
if source:
|
||||
lines.append(f"Pricing source: {source}.")
|
||||
if model.lower() == GPT55_PRO_OPENROUTER_ID:
|
||||
lines.append(GPT55_SUGGESTION)
|
||||
lines.append("Confirm only if you intend to use this model.")
|
||||
|
||||
return ExpensiveModelWarning(
|
||||
model=model,
|
||||
provider=(provider or "").strip(),
|
||||
input_cost_per_million=input_cost,
|
||||
output_cost_per_million=output_cost,
|
||||
source=source or "unknown",
|
||||
message="\n".join(lines),
|
||||
)
|
||||
+131
-43
@@ -25,6 +25,44 @@ import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def _prompt_auth_credentials_choice(title: str) -> str:
|
||||
"""Prompt for reuse / reauthenticate / cancel with the standard radio UI.
|
||||
|
||||
Returns one of ``"use"``, ``"reauth"``, ``"cancel"``. Falls back to a
|
||||
numbered prompt when curses is unavailable (piped stdin, non-TTY).
|
||||
"""
|
||||
choices = [
|
||||
"Use existing credentials",
|
||||
"Reauthenticate (new OAuth login)",
|
||||
"Cancel",
|
||||
]
|
||||
try:
|
||||
from hermes_cli.setup import _curses_prompt_choice
|
||||
|
||||
idx = _curses_prompt_choice(title, choices, 0)
|
||||
if idx >= 0:
|
||||
print()
|
||||
return ("use", "reauth", "cancel")[idx]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(title)
|
||||
for i, label in enumerate(choices, 1):
|
||||
marker = "→" if i == 1 else " "
|
||||
print(f" {marker} {i}. {label}")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
|
||||
if choice == "2":
|
||||
return "reauth"
|
||||
if choice == "3":
|
||||
return "cancel"
|
||||
return "use"
|
||||
|
||||
|
||||
def _model_flow_openrouter(config, current_model=""):
|
||||
"""OpenRouter provider: ensure API key, then pick model."""
|
||||
from hermes_cli.main import _prompt_api_key
|
||||
@@ -64,7 +102,12 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
pricing = get_pricing_for_provider("openrouter", force_refresh=True)
|
||||
|
||||
selected = _prompt_model_selection(
|
||||
openrouter_models, current_model=current_model, pricing=pricing
|
||||
openrouter_models,
|
||||
current_model=current_model,
|
||||
pricing=pricing,
|
||||
confirm_provider="openrouter",
|
||||
confirm_base_url=OPENROUTER_BASE_URL,
|
||||
confirm_api_key=_resolved or existing_key,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
@@ -273,6 +316,9 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_nous_portal_url,
|
||||
unavailable_message=unavailable_message,
|
||||
confirm_provider="nous",
|
||||
confirm_base_url=creds.get("base_url", ""),
|
||||
confirm_api_key=creds.get("api_key", ""),
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
@@ -321,16 +367,9 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
if status.get("logged_in"):
|
||||
print(" OpenAI Codex credentials: ✓")
|
||||
print()
|
||||
print(" 1. Use existing credentials")
|
||||
print(" 2. Reauthenticate (new OAuth login)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
choice = _prompt_auth_credentials_choice("OpenAI Codex credentials:")
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
print("Starting a fresh OpenAI Codex login...")
|
||||
print()
|
||||
try:
|
||||
@@ -350,7 +389,7 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
if not status.get("logged_in"):
|
||||
print("Login failed.")
|
||||
return
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
else:
|
||||
print("Not logged into OpenAI Codex. Starting login...")
|
||||
@@ -385,7 +424,13 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
|
||||
codex_models = get_codex_model_ids(access_token=_codex_token)
|
||||
|
||||
selected = _prompt_model_selection(codex_models, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
codex_models,
|
||||
current_model=current_model,
|
||||
confirm_provider="openai-codex",
|
||||
confirm_base_url=DEFAULT_CODEX_BASE_URL,
|
||||
confirm_api_key=_codex_token or "",
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
||||
@@ -411,16 +456,11 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
if status.get("logged_in"):
|
||||
print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓")
|
||||
print()
|
||||
print(" 1. Use existing credentials")
|
||||
print(" 2. Reauthenticate (new OAuth login)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
choice = _prompt_auth_credentials_choice(
|
||||
"xAI Grok OAuth (SuperGrok / Premium+) credentials:"
|
||||
)
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
print("Starting a fresh xAI OAuth login...")
|
||||
print()
|
||||
try:
|
||||
@@ -444,7 +484,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
except Exception as exc:
|
||||
print(f"Login failed: {exc}")
|
||||
return
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
else:
|
||||
print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...")
|
||||
@@ -520,7 +560,12 @@ def _model_flow_qwen_oauth(_config, current_model=""):
|
||||
models = list(_DEFAULT_QWEN_PORTAL_MODELS)
|
||||
|
||||
default = current_model or (models[0] if models else "qwen3-coder-plus")
|
||||
selected = _prompt_model_selection(models, current_model=default)
|
||||
selected = _prompt_model_selection(
|
||||
models,
|
||||
current_model=default,
|
||||
confirm_provider="qwen-oauth",
|
||||
confirm_base_url=DEFAULT_QWEN_BASE_URL,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL)
|
||||
@@ -569,7 +614,12 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
|
||||
selected = _prompt_model_selection(model_ids, current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_ids,
|
||||
current_model,
|
||||
confirm_provider="minimax-oauth",
|
||||
confirm_base_url=creds["base_url"],
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
_save_model_choice(selected)
|
||||
@@ -638,7 +688,12 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
|
||||
|
||||
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
|
||||
default = current_model or (models[0] if models else "gemini-3-flash-preview")
|
||||
selected = _prompt_model_selection(models, current_model=default)
|
||||
selected = _prompt_model_selection(
|
||||
models,
|
||||
current_model=default,
|
||||
confirm_provider="google-gemini-cli",
|
||||
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider(
|
||||
@@ -1563,7 +1618,11 @@ def _model_flow_copilot(config, current_model=""):
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(
|
||||
model_list, current_model=normalized_current_model
|
||||
model_list,
|
||||
current_model=normalized_current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=api_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -1701,6 +1760,9 @@ def _model_flow_copilot_acp(config, current_model=""):
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=normalized_current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=catalog_api_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -1805,7 +1867,13 @@ def _model_flow_kimi(config, current_model=""):
|
||||
model_list = _PROVIDER_MODELS.get("moonshot", [])
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Enter model name: ").strip()
|
||||
@@ -1913,7 +1981,13 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
)
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name: ").strip()
|
||||
@@ -1989,7 +2063,13 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
|
||||
print(f" Showing {len(model_list)} curated models")
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="custom",
|
||||
confirm_base_url=mantle_base_url,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
@@ -2178,7 +2258,12 @@ def _model_flow_bedrock(config, current_model=""):
|
||||
|
||||
# 4. Model selection
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="bedrock",
|
||||
confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
@@ -2462,7 +2547,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
model_list = list(dict.fromkeys(mid for mid in model_list if mid))
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name: ").strip()
|
||||
@@ -2560,20 +2651,13 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
elif cc_available:
|
||||
print(" Claude Code credentials: ✓ (auto-detected)")
|
||||
print()
|
||||
print(" 1. Use existing credentials")
|
||||
print(" 2. Reauthenticate (new OAuth login)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
choice = _prompt_auth_credentials_choice("Anthropic credentials:")
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
needs_auth = True
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
# choice == "1" or default: use existing, proceed to model selection
|
||||
# choice == "use" or default: use existing, proceed to model selection
|
||||
|
||||
if needs_auth:
|
||||
# Show auth method choice
|
||||
@@ -2619,7 +2703,11 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
# Model selection
|
||||
model_list = _PROVIDER_MODELS.get("anthropic", [])
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="anthropic",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip()
|
||||
|
||||
@@ -151,10 +151,11 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
|
||||
|
||||
def _has_agent_browser() -> bool:
|
||||
import shutil
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
agent_browser_bin = shutil.which("agent-browser")
|
||||
local_bin = (
|
||||
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||
get_hermes_source_root() / "node_modules" / ".bin" / "agent-browser"
|
||||
)
|
||||
return bool(agent_browser_bin or local_bin.exists())
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ def get_bundled_plugins_dir() -> Path:
|
||||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return Path(__file__).resolve().parent.parent / "plugins"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
return get_hermes_source_root() / "plugins"
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
@@ -900,7 +900,8 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
|
||||
"user_modified": [],
|
||||
"skipped_opt_out": True,
|
||||
}
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
|
||||
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
def _print_aiohttp_missing() -> None:
|
||||
print(
|
||||
"hermes proxy requires aiohttp. Install one of:\n"
|
||||
" pip install 'hermes-agent[messaging]'\n"
|
||||
" uv pip install -e '.[messaging]' # from the hermes-agent checkout\n"
|
||||
" pip install aiohttp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"aiohttp is required for `hermes proxy`. Install with: "
|
||||
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
|
||||
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
@@ -253,7 +253,7 @@ async def run_server(
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"aiohttp is required for `hermes proxy`. Install with: "
|
||||
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
|
||||
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
|
||||
)
|
||||
|
||||
app = create_app(adapter)
|
||||
|
||||
+11
-23
@@ -16,11 +16,13 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
from utils import base_url_hostname
|
||||
@@ -28,7 +30,8 @@ from hermes_constants import get_optional_skills_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
_DOCS_BASE = "https://hermes-agent.nousresearch.com/docs"
|
||||
|
||||
@@ -796,10 +799,8 @@ def _install_neutts_deps() -> bool:
|
||||
print_info("This will also download the TTS model (~300MB) on first use.")
|
||||
print()
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "neutts[all]", "--quiet"],
|
||||
check=True, timeout=300,
|
||||
)
|
||||
result = pip_install(["neutts[all]"], quiet=True, upgrade=True, timeout=300)
|
||||
result.check_returncode()
|
||||
print_success("neutts installed successfully")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
@@ -821,10 +822,8 @@ def _install_kittentts_deps() -> bool:
|
||||
print_info("Installing kittentts Python package (~25-80MB model downloaded on first use)...")
|
||||
print()
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"],
|
||||
check=True, timeout=300,
|
||||
)
|
||||
result = pip_install([wheel_url, "soundfile"], quiet=True, upgrade=True, timeout=300)
|
||||
result.check_returncode()
|
||||
print_success("kittentts installed successfully")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
@@ -1285,11 +1284,7 @@ def setup_terminal_backend(config: dict):
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "modal"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = pip_install(["modal"])
|
||||
if result.returncode == 0:
|
||||
print_success("modal SDK installed")
|
||||
else:
|
||||
@@ -1338,11 +1333,7 @@ def setup_terminal_backend(config: dict):
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "daytona"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = pip_install(["daytona"])
|
||||
if result.returncode == 0:
|
||||
print_success("daytona SDK installed")
|
||||
else:
|
||||
@@ -1988,10 +1979,7 @@ def _setup_matrix():
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = pip_install([matrix_pkg])
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
|
||||
@@ -351,13 +351,29 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
"lobehub": 500, "browse-sh": 500,
|
||||
}
|
||||
|
||||
with c.status("[bold]Fetching skills from registries..."):
|
||||
with c.status("[bold]Fetching skills from registries...") as status:
|
||||
# Live progress: tick off each source as it resolves so the wait is
|
||||
# visible instead of a frozen spinner. parallel_search_sources invokes
|
||||
# this callback from the collecting thread as each source completes;
|
||||
# the page itself is still rendered once, after the correctly-merged
|
||||
# and trust-sorted result set is final (browse's ordering contract is
|
||||
# computed over the whole set, so we never render a half-sorted page).
|
||||
_done: List[str] = []
|
||||
|
||||
def _on_source_done(sid: str, count: int) -> None:
|
||||
_done.append(f"{sid} ({count})")
|
||||
status.update(
|
||||
"[bold]Fetching skills from registries...[/] "
|
||||
f"[dim]done: {', '.join(_done)}[/]"
|
||||
)
|
||||
|
||||
all_results, source_counts, timed_out = parallel_search_sources(
|
||||
sources,
|
||||
query="",
|
||||
per_source_limits=_PER_SOURCE_LIMIT,
|
||||
source_filter=source,
|
||||
overall_timeout=30,
|
||||
on_source_done=_on_source_done,
|
||||
)
|
||||
|
||||
if not all_results:
|
||||
|
||||
@@ -9,7 +9,8 @@ import sys
|
||||
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
from hermes_cli.auth import AuthError, resolve_provider
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
+13
-75
@@ -24,6 +24,7 @@ from hermes_cli.config import (
|
||||
load_config, save_config, get_env_value, save_env_value,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_cli.nous_subscription import (
|
||||
apply_nous_managed_defaults,
|
||||
get_nous_subscription_features,
|
||||
@@ -34,7 +35,8 @@ from utils import base_url_hostname, is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
|
||||
# ─── UI Helpers (shared with setup.py) ────────────────────────────────────────
|
||||
@@ -580,73 +582,10 @@ def _cua_driver_cmd() -> str:
|
||||
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
|
||||
|
||||
|
||||
def _pip_install(
|
||||
args: List[str],
|
||||
*,
|
||||
timeout: int = 300,
|
||||
capture_output: bool = True,
|
||||
):
|
||||
"""Install Python packages from a post-setup hook.
|
||||
|
||||
Strategy (in order):
|
||||
1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the venv.
|
||||
2. ``python -m pip install`` — works on stdlib venvs.
|
||||
3. ``python -m ensurepip --upgrade`` then retry pip — covers ``uv venv``
|
||||
which creates a venv WITHOUT pip.
|
||||
|
||||
Why this exists: the Windows installer creates the venv via ``uv venv``,
|
||||
which doesn't seed pip. Post-setup hooks that shelled out to
|
||||
``[sys.executable, '-m', 'pip', 'install', ...]`` failed with
|
||||
``No module named pip`` on every fresh install. uv-first sidesteps that.
|
||||
|
||||
Returns the ``subprocess.CompletedProcess`` from whichever tier succeeded
|
||||
(or the last failure for the caller to inspect).
|
||||
"""
|
||||
venv_root = Path(sys.executable).parent.parent
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", *args],
|
||||
capture_output=capture_output, text=True, timeout=timeout,
|
||||
env=uv_env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
# Fall through to pip — uv may have failed for an unrelated reason
|
||||
# (resolution conflict, network), and pip might handle it.
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
|
||||
probe = subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise FileNotFoundError("pip not in venv")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
capture_output=True, text=True, timeout=120, check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
# Synthesize a result so callers see a clean failure path.
|
||||
return subprocess.CompletedProcess(
|
||||
pip_cmd, returncode=1, stdout="",
|
||||
stderr=f"pip not available and ensurepip failed: {e}",
|
||||
)
|
||||
|
||||
return subprocess.run(
|
||||
pip_cmd + ["install", *args],
|
||||
capture_output=capture_output, text=True, timeout=timeout,
|
||||
)
|
||||
|
||||
def pip_install_deps(packages: list[str], *, timeout: int = 300, quiet: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Thin wrapper around the authoritative managed_uv.pip_install for tools_config."""
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
return pip_install(packages, timeout=timeout, quiet=quiet)
|
||||
|
||||
|
||||
def _check_cua_driver_asset_for_arch() -> bool:
|
||||
@@ -833,6 +772,8 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
|
||||
def _run_post_setup(post_setup_key: str):
|
||||
"""Run post-setup hooks for tools that need extra installation steps."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if post_setup_key in {"agent_browser", "browserbase"}:
|
||||
node_modules = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||
npm_bin = shutil.which("npm")
|
||||
@@ -840,7 +781,6 @@ def _run_post_setup(post_setup_key: str):
|
||||
# Step 1: install the agent-browser npm package into node_modules/
|
||||
if not node_modules.exists() and npm_bin:
|
||||
_print_info(" Installing Node.js dependencies for browser tools...")
|
||||
import subprocess
|
||||
# Use the resolved npm_bin absolute path so subprocess.Popen can
|
||||
# execute npm.cmd on Windows (CreateProcessW otherwise rejects
|
||||
# batch shims). On POSIX npm_bin is the plain path — same
|
||||
@@ -908,7 +848,6 @@ def _run_post_setup(post_setup_key: str):
|
||||
return
|
||||
|
||||
_print_info(" Installing Chromium (~170MB one-time download)...")
|
||||
import subprocess
|
||||
# Prefer the bundled agent-browser install subcommand so the
|
||||
# version of Chromium matches the CLI. Fall back to npx shim on
|
||||
# setups where the local bin stub isn't present.
|
||||
@@ -951,7 +890,6 @@ def _run_post_setup(post_setup_key: str):
|
||||
_npm_bin = shutil.which("npm")
|
||||
if not camofox_dir.exists() and _npm_bin:
|
||||
_print_info(" Installing Camofox browser server...")
|
||||
import subprocess
|
||||
# Absolute npm path so .cmd shim executes on Windows.
|
||||
result = subprocess.run(
|
||||
# --workspaces=false avoids resolving apps/desktop. See #38772.
|
||||
@@ -987,7 +925,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
"0.8.1/kittentts-0.8.1-py3-none-any.whl"
|
||||
)
|
||||
try:
|
||||
result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
|
||||
result = pip_install_deps(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" kittentts installed")
|
||||
_print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo")
|
||||
@@ -1007,7 +945,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
except ImportError:
|
||||
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
|
||||
try:
|
||||
result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300)
|
||||
result = pip_install_deps(["-U", "piper-tts", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" piper-tts installed")
|
||||
else:
|
||||
@@ -1030,7 +968,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
except ImportError:
|
||||
_print_info(" Installing ddgs (DuckDuckGo search package)...")
|
||||
try:
|
||||
result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300)
|
||||
result = pip_install_deps(["-U", "ddgs", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" ddgs installed")
|
||||
else:
|
||||
@@ -1081,7 +1019,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_success(" langfuse SDK already installed")
|
||||
except ImportError:
|
||||
_print_info(" Installing langfuse SDK...")
|
||||
result = _pip_install(["langfuse", "--quiet"], timeout=120)
|
||||
result = pip_install_deps(["langfuse", "--quiet"], timeout=120)
|
||||
if result.returncode == 0:
|
||||
_print_success(" langfuse SDK installed")
|
||||
else:
|
||||
|
||||
@@ -25,9 +25,7 @@ def log_success(msg: str):
|
||||
def log_warn(msg: str):
|
||||
print(f"{color('⚠', Colors.YELLOW)} {msg}")
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project installation directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
|
||||
def find_shell_configs() -> list:
|
||||
@@ -572,7 +570,8 @@ def run_uninstall(args):
|
||||
- Full uninstall: removes code + ~/.hermes/ (configs, data, logs)
|
||||
- Keep data: removes code but keeps ~/.hermes/ for future reinstall
|
||||
"""
|
||||
project_root = get_project_root()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
# Detect named profiles when uninstalling from the default root —
|
||||
|
||||
+103
-20
@@ -37,7 +37,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
@@ -84,7 +85,7 @@ except ImportError:
|
||||
except Exception:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
||||
f"Install with: uv pip install 'fastapi' 'uvicorn[standard]'"
|
||||
)
|
||||
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
@@ -246,7 +247,31 @@ def _has_valid_session_token(request: Request) -> bool:
|
||||
|
||||
|
||||
def _require_token(request: Request) -> None:
|
||||
"""Validate the ephemeral session token. Raises 401 on mismatch."""
|
||||
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
|
||||
|
||||
Two auth schemes protect the dashboard, exactly one active per bind:
|
||||
|
||||
* **Loopback / ``--insecure`` mode** (``auth_required`` False): the
|
||||
ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
|
||||
back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
|
||||
Validate it here.
|
||||
* **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
|
||||
NOT injected (the SPA authenticates with a session cookie), so there is
|
||||
no token to check. The ``gated_auth_middleware`` has already verified the
|
||||
cookie before the request reached this handler — any non-public ``/api/``
|
||||
route it lets through carries a verified ``request.state.session``. The
|
||||
legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
|
||||
the (absent) token here would 401 every cookie-authenticated request,
|
||||
making plugin install/enable/disable and the other ``_require_token``
|
||||
endpoints permanently unreachable behind the gate. Defer to the gate.
|
||||
"""
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
# Gate is authoritative. It attaches ``request.state.session`` on
|
||||
# success and 401s otherwise, so a request that reached us is already
|
||||
# authenticated. Belt-and-braces: confirm the session is present.
|
||||
if getattr(request.state, "session", None) is not None:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not _has_valid_session_token(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
@@ -633,21 +658,6 @@ class AudioTranscriptionRequest(BaseModel):
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
scope="main" → writes model.provider + model.default
|
||||
scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
|
||||
scope="auxiliary" with task="" → applied to every auxiliary.* slot
|
||||
scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
|
||||
"""
|
||||
|
||||
scope: str
|
||||
provider: str
|
||||
model: str
|
||||
task: str = ""
|
||||
|
||||
|
||||
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
@@ -689,6 +699,7 @@ class ModelAssignment(BaseModel):
|
||||
# reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is
|
||||
# the path that actually wires a local endpoint into resolution.
|
||||
base_url: str = ""
|
||||
confirm_expensive_model: bool = False
|
||||
|
||||
|
||||
def _apply_main_model_assignment(
|
||||
@@ -1362,11 +1373,28 @@ def _tail_lines(path: Path, n: int) -> List[str]:
|
||||
return lines[-n:] if n > 0 else lines
|
||||
|
||||
|
||||
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
|
||||
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
|
||||
|
||||
Multiple dashboard paths can request a restart in quick succession
|
||||
(restart button double-click, or a stale cached frontend firing its own
|
||||
restart after the server already auto-restarted post-onboarding). Two
|
||||
concurrent ``hermes gateway restart`` children race each other on the
|
||||
manual kill-and-start path, so reuse the live one instead.
|
||||
|
||||
Returns ``(proc, reused)``.
|
||||
"""
|
||||
existing = _ACTION_PROCS.get("gateway-restart")
|
||||
if existing is not None and existing.poll() is None:
|
||||
return existing, True
|
||||
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
|
||||
|
||||
|
||||
@app.post("/api/gateway/restart")
|
||||
async def restart_gateway():
|
||||
"""Kick off a ``hermes gateway restart`` in the background."""
|
||||
try:
|
||||
proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart")
|
||||
proc, _reused = _spawn_gateway_restart()
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway restart")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
|
||||
@@ -2446,6 +2474,30 @@ async def set_model_assignment(body: ModelAssignment):
|
||||
try:
|
||||
cfg = load_config()
|
||||
|
||||
if model and not body.confirm_expensive_model:
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
# Pricing lookup can hit models.dev / a /models endpoint on a
|
||||
# cache miss — keep it off the event loop.
|
||||
warning = await asyncio.to_thread(
|
||||
expensive_model_warning,
|
||||
model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
if warning is not None:
|
||||
return {
|
||||
"ok": False,
|
||||
"scope": scope,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"confirm_required": True,
|
||||
"confirm_message": warning.message,
|
||||
}
|
||||
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
@@ -3714,6 +3766,34 @@ async def get_telegram_onboarding_status(pairing_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
||||
"""Best-effort gateway restart after saving Telegram QR onboarding.
|
||||
|
||||
The QR flow naturally pulls users into Telegram on another device. If the
|
||||
saved token waits on a separate dashboard restart click, Hermes appears
|
||||
broken from the chat side. Keep the config save authoritative, but report
|
||||
restart failures so the UI can fall back to the existing manual banner.
|
||||
"""
|
||||
try:
|
||||
proc, reused = _spawn_gateway_restart()
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
|
||||
return {
|
||||
"restart_started": False,
|
||||
"restart_error": str(exc),
|
||||
}
|
||||
if reused:
|
||||
_log.info(
|
||||
"Telegram onboarding: reusing in-flight gateway restart (pid %s)",
|
||||
proc.pid,
|
||||
)
|
||||
return {
|
||||
"restart_started": True,
|
||||
"restart_action": "gateway-restart",
|
||||
"restart_pid": proc.pid,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
|
||||
async def apply_telegram_onboarding(
|
||||
pairing_id: str, body: TelegramOnboardingApply
|
||||
@@ -3768,11 +3848,14 @@ async def apply_telegram_onboarding(
|
||||
with _telegram_onboarding_lock:
|
||||
_telegram_onboarding_pairings.pop(pairing_id, None)
|
||||
|
||||
restart_result = _restart_gateway_after_telegram_onboarding()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"platform": "telegram",
|
||||
"bot_username": bot_username,
|
||||
"needs_restart": True,
|
||||
"needs_restart": not restart_result["restart_started"],
|
||||
**restart_result,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ from typing import List, Optional
|
||||
|
||||
from tools import write_approval as wa
|
||||
|
||||
_VALID_MODES = (wa.MODE_ON, wa.MODE_OFF, wa.MODE_APPROVE)
|
||||
|
||||
def _fmt_state(subsystem: str) -> str:
|
||||
on = wa.write_approval_enabled(subsystem)
|
||||
return f"{subsystem}.write_approval = {'on' if on else 'off'}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -63,18 +66,17 @@ def handle_pending_subcommand(
|
||||
memory_store: live MemoryStore for applying approved memory writes
|
||||
(CLI passes ``self.agent._memory_store``; gateway applies against a
|
||||
freshly loaded store).
|
||||
set_mode_fn: optional callable ``(mode: str) -> None`` that persists the
|
||||
new write_mode to config (gateway provides this; CLI uses its own
|
||||
``save_config_value`` and passes a closure).
|
||||
set_mode_fn: optional callable ``(enabled: bool) -> None`` that
|
||||
persists the new write_approval boolean to config (gateway provides
|
||||
this; CLI uses its own ``save_config_value`` and passes a closure).
|
||||
|
||||
Returns a text string to show the user. Returns None when the args are not
|
||||
a write-approval subcommand (caller falls through to its other handling,
|
||||
e.g. /skills search).
|
||||
"""
|
||||
if not args:
|
||||
# Bare /memory or /skills with no sub → show pending + current mode.
|
||||
mode = wa.get_write_mode(subsystem)
|
||||
return f"{subsystem}.write_mode = {mode}\n\n" + _fmt_pending_list(subsystem)
|
||||
# Bare /memory or /skills with no sub → show pending + gate state.
|
||||
return f"{_fmt_state(subsystem)}\n\n" + _fmt_pending_list(subsystem)
|
||||
|
||||
sub = args[0].lower()
|
||||
rest = args[1:]
|
||||
@@ -91,8 +93,8 @@ def handle_pending_subcommand(
|
||||
if sub == "diff" and subsystem == wa.SKILLS:
|
||||
return _diff(rest)
|
||||
|
||||
if sub == "mode":
|
||||
return _set_mode(subsystem, rest, set_mode_fn)
|
||||
if sub in {"approval", "mode"}: # 'mode' kept as a back-compat alias
|
||||
return _set_approval(subsystem, rest, set_mode_fn)
|
||||
|
||||
return None # not ours — caller handles
|
||||
|
||||
@@ -179,19 +181,29 @@ def _diff(rest: List[str]) -> str:
|
||||
return header + "\n" + diff
|
||||
|
||||
|
||||
def _set_mode(subsystem: str, rest: List[str], set_mode_fn) -> str:
|
||||
def _set_approval(subsystem: str, rest: List[str], set_mode_fn) -> str:
|
||||
"""Turn the approval gate on/off for a subsystem.
|
||||
|
||||
``set_mode_fn`` (when provided) persists the new boolean to config.
|
||||
"""
|
||||
if not rest:
|
||||
cur = wa.get_write_mode(subsystem)
|
||||
return (f"{subsystem}.write_mode = {cur}\n"
|
||||
f"Set with: /{subsystem} mode <on|off|approve>")
|
||||
mode = rest[0].lower()
|
||||
if mode not in _VALID_MODES:
|
||||
return f"Invalid mode '{mode}'. Use: on, off, approve."
|
||||
return (f"{_fmt_state(subsystem)}\n"
|
||||
f"Set with: /{subsystem} approval <on|off>")
|
||||
arg = rest[0].strip().lower()
|
||||
truthy = {"on", "true", "yes", "1", "enable", "enabled"}
|
||||
falsey = {"off", "false", "no", "0", "disable", "disabled"}
|
||||
if arg in truthy:
|
||||
enabled = True
|
||||
elif arg in falsey:
|
||||
enabled = False
|
||||
else:
|
||||
return f"Invalid value '{arg}'. Use: on or off."
|
||||
if set_mode_fn is None:
|
||||
return (f"To change {subsystem} write mode, run:\n"
|
||||
f" hermes config set {subsystem}.write_mode {mode}")
|
||||
val = "true" if enabled else "false"
|
||||
return (f"To change the {subsystem} approval gate, run:\n"
|
||||
f" hermes config set {subsystem}.write_approval {val}")
|
||||
try:
|
||||
set_mode_fn(mode)
|
||||
set_mode_fn(enabled)
|
||||
except Exception as e:
|
||||
return f"Failed to set {subsystem}.write_mode: {e}"
|
||||
return f"{subsystem}.write_mode set to '{mode}'."
|
||||
return f"Failed to set {subsystem}.write_approval: {e}"
|
||||
return f"{subsystem}.write_approval set to '{'on' if enabled else 'off'}'."
|
||||
|
||||
@@ -11,6 +11,28 @@ from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-root helper — single source of truth for the repo / install root.
|
||||
# ---------------------------------------------------------------------------
|
||||
# ``hermes_constants.py`` lives at the repo root, so its parent *is* the root.
|
||||
# Every other module that previously did ``Path(__file__).parent.parent`` to
|
||||
# climb out of sub-packages (hermes_cli/, gateway/, tools/, …) should call
|
||||
# this instead. Tests can monkeypatch it directly without touching __file__.
|
||||
|
||||
def get_hermes_source_root() -> Path:
|
||||
"""Return the Hermes Agent source / installation root directory.
|
||||
|
||||
This is the single authoritative way to locate the project root.
|
||||
Previously this was scattered across ~20 files as
|
||||
``Path(__file__).parent.parent.resolve()`` relative to each sub-package.
|
||||
|
||||
Returns the directory that contains ``pyproject.toml``, ``hermes_constants.py``,
|
||||
``hermes_cli/``, ``tools/``, etc.
|
||||
"""
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
|
||||
_profile_fallback_warned: bool = False
|
||||
_UNSET = object()
|
||||
_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar(
|
||||
|
||||
+2
-2
@@ -452,7 +452,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
if not _MCP_SERVER_AVAILABLE:
|
||||
raise ImportError(
|
||||
"MCP server requires the 'mcp' package. "
|
||||
f"Install with: {sys.executable} -m pip install 'mcp'"
|
||||
f"Install with: uv pip install 'mcp'"
|
||||
)
|
||||
|
||||
mcp = FastMCP(
|
||||
@@ -868,7 +868,7 @@ def run_mcp_server(verbose: bool = False) -> None:
|
||||
if not _MCP_SERVER_AVAILABLE:
|
||||
print(
|
||||
"Error: MCP server requires the 'mcp' package.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'mcp'",
|
||||
f"Install with: uv pip install 'mcp'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# ⚠️ DEPRECATED
|
||||
|
||||
This Homebrew formula is **discontinued and no longer receives updates**.
|
||||
Please use a supported installation method (curl installer, Docker, or Nix).
|
||||
See https://hermes-agent.nousresearch.com/ for details.
|
||||
|
||||
---
|
||||
|
||||
Homebrew packaging notes for Hermes Agent.
|
||||
|
||||
Use `packaging/homebrew/hermes-agent.rb` as a tap or `homebrew-core` starting point.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# FROZEN: This formula is deprecated and will not receive further updates.
|
||||
# pip/PyPI and Homebrew installations are discontinued.
|
||||
# See https://hermes-agent.nousresearch.com/ for supported install methods.
|
||||
|
||||
class HermesAgent < Formula
|
||||
include Language::Python::Virtualenv
|
||||
|
||||
desc "Self-improving AI agent that creates skills from experience"
|
||||
homepage "https://hermes-agent.nousresearch.com"
|
||||
deprecate! because: "is discontinued upstream. See https://hermes-agent.nousresearch.com/ for supported install methods."
|
||||
# Stable source should point at the semver-named sdist asset attached by
|
||||
# scripts/release.py, not the CalVer tag tarball.
|
||||
url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz"
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# Platform Support Tiers — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Formalize Hermes Agent's platform support into three tiers, document them in the repo and docs, and deprecate removed platforms (pip/PyPI, Homebrew formula) with a clear migration path.
|
||||
|
||||
---
|
||||
|
||||
## Platform Support Tiers
|
||||
|
||||
### Explicitly supported — guaranteed to work, first-party installers only
|
||||
|
||||
| Platform | Installer |
|
||||
|----------|-----------|
|
||||
| Linux x86_64 / arm64 | `curl \| bash` installer, Docker image |
|
||||
| Latest Debian, Ubuntu, Fedora, Windows WSL | `curl \| bash` installer |
|
||||
| Official Docker image | `docker pull` |
|
||||
| macOS arm64 | Desktop app installer, `curl \| bash` installer |
|
||||
| Windows x86_64 / arm64 | Desktop app installer, PowerShell installer |
|
||||
|
||||
### Best-effort — PRs accepted for fixes, but Nous won't prioritize them, nor will we accept packaging-specific code in the repo
|
||||
|
||||
- Termux / Android
|
||||
- AUR packaging
|
||||
- Homebrew packaging
|
||||
- Nix packaging (flake + NixOS module stay in-tree and maintained; packaging bugs outside core nix support are best-effort)
|
||||
|
||||
### Explicitly unsupported — no PRs for support will be accepted
|
||||
|
||||
- macOS x86_64
|
||||
- Packaging via pip / PyPI
|
||||
- FreeBSD
|
||||
|
||||
---
|
||||
|
||||
## Where the tier information lives
|
||||
|
||||
### In the repo (for agents and contributors)
|
||||
|
||||
| File | What to add |
|
||||
|------|-------------|
|
||||
| `AGENTS.md` | New `## Platform Support` section with the tier table and a link to the docs reference page. This is the canonical in-repo source — agents and contributors read it first. |
|
||||
| `CONTRIBUTING.md` | Update the "Contribution Priorities" section (currently lines 8–17) to reference the tiers. Replace the generic "Cross-platform compatibility" bullet with explicit tier-aware language: PRs for best-effort platforms are welcome but won't block releases; PRs for unsupported platforms will be closed. |
|
||||
|
||||
### In the docs (for users)
|
||||
|
||||
| File | What to add |
|
||||
|------|-------------|
|
||||
| `website/docs/reference/platform-support.md` (new) | The canonical user-facing reference. Full tier breakdown with nuance, migration instructions for each deprecated path, and a support policy summary. Other pages link here. |
|
||||
| `website/docs/getting-started/installation.md` | Add a prominent tier summary (table or callout) near the top, before the install commands. Remove the pip install row from the install layout table. Add a "Migrating from pip" subsection. |
|
||||
| `website/docs/getting-started/updating.md` | Replace the "pip installs" update section with a deprecation notice linking to the platform-support page. |
|
||||
| `website/docs/getting-started/termux.md` | Add a best-effort support banner at the top. |
|
||||
| `website/docs/getting-started/nix-setup.md` | Add a note clarifying that the nix flake + NixOS module are maintained in-tree but nix-specific packaging bugs are best-effort. |
|
||||
|
||||
### What does NOT change
|
||||
|
||||
- `flake.nix`, `nix/`, Dockerfile — these are deployment methods, not just packaging. They stay.
|
||||
- `scripts/install.sh` termux detection — already works, no reason to break it.
|
||||
- `constraints-termux.txt` and `[termux]`/`[termux-all]` extras in pyproject.toml — still needed for best-effort users.
|
||||
|
||||
---
|
||||
|
||||
## Deprecating removed platforms
|
||||
|
||||
### 1. pip / PyPI
|
||||
|
||||
**Step 1: Publish one final version to PyPI**
|
||||
|
||||
- Update the package `description` in `pyproject.toml` to include a deprecation prefix:
|
||||
```
|
||||
⚠️ DEPRECATED: pip/PyPI installs are discontinued. See https://hermes-agent.nousresearch.com/ for supported install methods.
|
||||
```
|
||||
- Bump the version (next semver, e.g. `0.17.0`)
|
||||
- Cut the release through the existing pipeline (`scripts/release.py` → tag push → `upload_to_pypi.yml`)
|
||||
- After that release ships, **disable `upload_to_pypi.yml`**: add `if: false` to the job definitions and a comment explaining why
|
||||
|
||||
**Step 2: Add runtime deprecation notices**
|
||||
|
||||
Every touchpoint where Hermes detects a pip install must surface a clear deprecation warning. The message should be consistent across all surfaces:
|
||||
|
||||
> ⚠️ pip/PyPI installs are discontinued and no longer receive updates. Switch to a supported install method: https://hermes-agent.nousresearch.com/
|
||||
|
||||
In `hermes_cli/`:
|
||||
|
||||
| Location | Change |
|
||||
|----------|--------|
|
||||
| `config.py` — `detect_install_method()` returns `"pip"` | Keep returning `"pip"` (detection still works, needed so existing installs see the deprecation message) |
|
||||
| `config.py` — `cmd_update` pip path | Replace the `uv pip install --upgrade hermes-agent` command with the deprecation message above. Do not attempt the upgrade — just print the message and exit. |
|
||||
| `banner.py` — existing `detect_install_method() == "pip"` check | Add a deprecation line to the startup banner for pip installs |
|
||||
| `main.py` — `hermes doctor` | Print the deprecation warning when `detect_install_method() == "pip"`, with an additional line: "Migrate with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh \| bash" |
|
||||
|
||||
**Step 3: Update the docs**
|
||||
|
||||
- `website/docs/getting-started/installation.md`: remove the pip install row from the install layout table; add a deprecation callout box; add a brief "Migrating from pip" section
|
||||
- `website/docs/getting-started/updating.md`: replace the "pip installs" section with a deprecation notice + link
|
||||
|
||||
**Step 4: Clean up pyproject.toml and enforce build failure (after the final release)**
|
||||
|
||||
- Add a comment at the top of `[project]` noting that PyPI publishing is discontinued
|
||||
- Remove `[project.scripts]` entries — they're only needed for pip's `console_scripts` entry points; git/docker/nix all use their own launchers
|
||||
- Keep `[build-system]`, `[project.optional-dependencies]`, and `[tool.setuptools]` sections — they're used by nix build and local dev setup (like termux), not just pip
|
||||
- Remove `hermes_agent.egg-info/` from tracking
|
||||
- **Enforce wheel build failure**: replace any remaining `setup.py` with a minimal stub that explicitly raises a `RuntimeError("pip/wheel builds are discontinued. Please use curl install, docker, or nix. See https://hermes-agent.nousresearch.com/")`. This prevents accidental silent fallback builds.
|
||||
- **Standardize on uv**: update all remaining local dev/build documentation, scripts, and comments to explicitly use `uv pip` instead of plain `pip`.
|
||||
|
||||
**Step 5: Rip out `ensurepip` and standardize entirely on `uv`**
|
||||
|
||||
Since the `curl | bash` installer and all supported environments guarantee a working `uv` binary, remove all legacy `ensurepip` bootstrapping and plain `pip` fallback logic across the codebase. Any remaining local dependency provisioning (e.g., in dev setups, update recovery, or tool environments like Modal) must strictly use `uv pip`. This eliminates race conditions, partial installs, and state confusion from legacy pip bootstrapping.
|
||||
- Print a hard deprecation message instead of attempting any `pip install` commands in `config.py` / `main.py` update paths.
|
||||
- `is_uv_tool_install()` detection can stay (it's used internally to differentiate from source/nix/docker builds).
|
||||
|
||||
### 2. Homebrew
|
||||
|
||||
**Step 1: Deprecate the formula**
|
||||
|
||||
- In `packaging/homebrew/hermes-agent.rb`, add Homebrew's official deprecation:
|
||||
```ruby
|
||||
deprecate! because: "is discontinued upstream. See https://hermes-agent.nousresearch.com/ for supported install methods."
|
||||
```
|
||||
- Bump the formula `url`/`version`/`sha256` one final time to match the last release
|
||||
|
||||
**Step 2: Add runtime deprecation notices**
|
||||
|
||||
Every touchpoint where Hermes detects a Homebrew install must surface a clear deprecation warning. The message should be consistent across all surfaces:
|
||||
|
||||
> ⚠️ Homebrew installs are discontinued and no longer receive updates. Switch to a supported install method: https://hermes-agent.nousresearch.com/
|
||||
|
||||
In `hermes_cli/`:
|
||||
|
||||
| Location | Change |
|
||||
|----------|--------|
|
||||
| `config.py` — `get_managed_update_command()` | Return the deprecation message above instead of `"brew upgrade hermes-agent"`. Do not suggest running brew upgrade. |
|
||||
| `config.py` — `format_managed_message()` | Prepend the deprecation notice before any Homebrew-specific managed-install messages. |
|
||||
| `banner.py` | Add a deprecation line to the startup banner when `get_managed_system() == "Homebrew"`. |
|
||||
| `main.py` — `hermes doctor` | Print the deprecation warning when `detect_install_method() == "homebrew"`, with an additional line: "Migrate with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh \| bash" |
|
||||
|
||||
**Step 3: Mark the formula as frozen**
|
||||
|
||||
- Add a comment at the top of `hermes-agent.rb` saying it's frozen and won't receive further updates
|
||||
- Update `packaging/homebrew/README.md` to say the formula is deprecated
|
||||
- Keep the directory in-tree as a reference — don't delete it
|
||||
|
||||
### 3. AUR
|
||||
|
||||
- No in-tree code. Purely a social/docs change.
|
||||
- Add a note in `website/docs/reference/platform-support.md` saying AUR packaging is community-maintained and best-effort.
|
||||
- No code changes needed.
|
||||
|
||||
### 4. Nix (stays in-tree, boundary clarified)
|
||||
|
||||
- `flake.nix` and `nix/` stay in-tree and maintained — they're a deployment method, not just packaging
|
||||
- Add a comment in `flake.nix` and `nix/packages.nix` clarifying that nix-specific packaging bugs (e.g. a new dependency that doesn't build under nix) are best-effort: PRs accepted, but won't block releases
|
||||
- The flake's `systems` list already excludes `x86_64-darwin` (only `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`) — that's correct for the new tiers, no change needed
|
||||
|
||||
### 5. Termux
|
||||
|
||||
- Add a best-effort support banner to `website/docs/getting-started/termux.md`
|
||||
- `constraints-termux.txt`, `[termux]`/`[termux-all]` extras, and `scripts/install.sh` termux detection all stay
|
||||
- Termux-specific bugs won't block releases
|
||||
|
||||
### 6. macOS x86_64
|
||||
|
||||
- Add a runtime warning: if `platform.machine() == 'x86_64'` and `sys.platform == 'darwin'`, print a one-time deprecation notice in `hermes doctor` saying the platform is unsupported
|
||||
- Don't actively break anything — just set expectations
|
||||
|
||||
---
|
||||
|
||||
## Execution order
|
||||
|
||||
1. Add `## Platform Support` section to `AGENTS.md`
|
||||
2. Create `website/docs/reference/platform-support.md`
|
||||
3. Update `CONTRIBUTING.md` to reference the tiers
|
||||
4. Update `website/docs/getting-started/installation.md` with tier info and pip deprecation
|
||||
5. Update `website/docs/getting-started/updating.md` with pip deprecation
|
||||
6. Add runtime deprecation notices in `hermes_cli/config.py`, `hermes_cli/banner.py`, `hermes_cli/main.py` for **both pip and Homebrew** installs
|
||||
7. Update `packaging/homebrew/hermes-agent.rb` with `deprecate!`
|
||||
8. Cut the final PyPI release with deprecation description in pyproject.toml
|
||||
9. After the release: disable `upload_to_pypi.yml`, replace pip update command with deprecation message, add deprecation comments to pyproject.toml, remove `[project.scripts]`
|
||||
10. Rip out all `ensurepip` and legacy `pip` fallback logic across `hermes_cli/main.py`, `hermes_cli/tools_config.py`, `tools/lazy_deps.py`, `tools/environments/modal.py`, and install scripts, standardizing exclusively on `uv pip`.
|
||||
11. Add termux best-effort banner to termux docs
|
||||
12. Add macOS x86_64 unsupported warning to `hermes doctor`
|
||||
|
||||
---
|
||||
|
||||
## Files touched (summary)
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `AGENTS.md` | Add platform support section |
|
||||
| `CONTRIBUTING.md` | Update contribution priorities with tier awareness |
|
||||
| `website/docs/reference/platform-support.md` | Create — canonical user-facing tier reference |
|
||||
| `website/docs/getting-started/installation.md` | Add tier summary, pip deprecation, migration section |
|
||||
| `website/docs/getting-started/updating.md` | Replace pip section with deprecation |
|
||||
| `website/docs/getting-started/termux.md` | Add best-effort banner |
|
||||
| `website/docs/getting-started/nix-setup.md` | Add best-effort boundary note |
|
||||
| `hermes_cli/config.py` | Deprecation messages for pip update + Homebrew update command |
|
||||
| `hermes_cli/banner.py` | Deprecation line for pip installs + Homebrew installs |
|
||||
| `hermes_cli/main.py` | `hermes doctor` pip + Homebrew deprecation warnings, macOS x86_64 warning |
|
||||
| `hermes_cli/tools_config.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `tools/lazy_deps.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `tools/environments/modal.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `scripts/install.ps1` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `packaging/homebrew/hermes-agent.rb` | Add `deprecate!`, freeze comment |
|
||||
| `packaging/homebrew/README.md` | Mark as deprecated |
|
||||
| `pyproject.toml` | Deprecation description, later: remove `[project.scripts]`, add comments |
|
||||
| `.github/workflows/upload_to_pypi.yml` | Disable after final release (`if: false`) |
|
||||
|
||||
---
|
||||
|
||||
## Full removal (future, after deprecation period)
|
||||
|
||||
After a suitable deprecation period (suggested: 2–3 minor releases, or ~6 months), fully remove the deprecated code and packaging infrastructure. This is a separate PR to avoid breaking existing installs prematurely.
|
||||
|
||||
### pip / PyPI — full removal
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| `hermes_cli/config.py` — `detect_install_method()` | Remove the `"pip"` return path entirely. If no stamp, no managed marker, and no `.git`, treat it as an unknown install rather than defaulting to pip. |
|
||||
| `hermes_cli/config.py` — `cmd_update` pip path | Remove the pip-specific update branch. |
|
||||
| `hermes_cli/config.py` — `_MANAGED_SYSTEM_NAMES` | Remove `"brew"` and `"homebrew"` entries. |
|
||||
| `hermes_cli/config.py` — `get_managed_update_command()` | Remove the `Homebrew` branch. |
|
||||
| `hermes_cli/config.py` — `format_managed_message()` | Remove the `Homebrew` branch. |
|
||||
| `hermes_cli/banner.py` | Remove pip and Homebrew deprecation lines from the banner. |
|
||||
| `hermes_cli/main.py` — `hermes doctor` | Remove pip and Homebrew deprecation warnings. |
|
||||
| `pyproject.toml` — `[project.optional-dependencies]` | Keep the `termux` and `termux-all` extras (local source builds via uv/nix still use them for best-effort support). Remove the `pty` and `vision` back-compat aliases (they were legacy pip-only install targets). |
|
||||
| `pyproject.toml` — `[project]` | Remove `description` deprecation prefix. |
|
||||
| `setup.py` | Replace with a minimal stub that raises a `RuntimeError` explaining pip/wheel builds are discontinued (prevents silent fallback builds). Move any skills/optional-skills data-file logic into the nix build if nix still needs it. |
|
||||
| `hermes_agent.egg-info/` | Delete entirely. |
|
||||
| `.github/workflows/upload_to_pypi.yml` | Delete the workflow file. |
|
||||
| `constraints-termux.txt` | Remove — termux users build from source and can maintain their own constraints. |
|
||||
| `scripts/install_psutil_android.py` | Remove — termux-specific pip hack. |
|
||||
| `tests/test_packaging_metadata.py` | Remove pip-specific assertions (wheel/sdist packaging tests). |
|
||||
| `tests/test_termux_all_extra_compat.py` | Remove. |
|
||||
| `tests/test_wheel_locales_e2e.py` | Remove — tests pip wheel install behavior. |
|
||||
| `tests/hermes_cli/test_cmd_update.py` — pip regression tests | Remove the `"pip"` parameterized test cases. |
|
||||
| `tests/hermes_cli/test_cmd_update_docker.py` — pip test case | Remove the `test_cmd_update_check_on_pip_install_still_uses_pypi` test. |
|
||||
|
||||
### Homebrew — full removal
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| `packaging/homebrew/` | Delete the entire directory (formula + README). The formula is frozen and will never be updated again — no reason to keep it. |
|
||||
| `hermes_cli/config.py` — `_MANAGED_SYSTEM_NAMES` | Remove `"brew"` and `"homebrew"` entries (listed above, duplicate for clarity). |
|
||||
| `pyproject.toml` — `[project.optional-dependencies]` comments | Remove Homebrew-specific comments (e.g. the `voice` extra comment about "source-build packagers like Homebrew"). |
|
||||
| `pyproject.toml` — `[all]` policy comment | Remove the "packagers (Nix, AUR, Homebrew)" references, update to just "packagers (Nix, AUR)". |
|
||||
|
||||
### macOS x86_64 — full removal
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| `hermes_cli/main.py` — `hermes doctor` | Remove the x86_64 macOS warning (or escalate to a hard error that refuses to start). |
|
||||
| `flake.nix` — `systems` | Already correct (no `x86_64-darwin`). No change needed. |
|
||||
|
||||
### General cleanup
|
||||
|
||||
- **Rip out all `ensurepip` and `pip` fallback logic**: Search the codebase for `ensurepip`, `-m pip`, and `pip install`. Update `hermes_cli/main.py`, `hermes_cli/tools_config.py`, `tools/lazy_deps.py`, `tools/environments/modal.py`, and install scripts to exclusively use `uv pip` for *any* remaining local environment provisioning.
|
||||
- Search the codebase for any remaining references to `"pip"`, `"homebrew"`, `"brew"`, `PyPI`, `pypi.org`, `upload_to_pypi`, `egg-info`, and `setup.py` — remove or update them.
|
||||
- Run the full test suite to confirm nothing is broken by the removals.
|
||||
- Update `AGENTS.md` and `website/docs/reference/platform-support.md` to remove any "deprecated" language and state the removed paths as simply unsupported (no longer "deprecated and still detected" — just gone).
|
||||
@@ -13,10 +13,12 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
from plugins.google_meet import process_manager as pm
|
||||
@@ -249,16 +251,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
|
||||
# 1) pip deps — always safe, venv-scoped.
|
||||
pip_pkgs = ["playwright", "websockets"]
|
||||
print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}")
|
||||
try:
|
||||
res = _sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs],
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
print(" pip install failed")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f" pip install failed: {e}")
|
||||
res = pip_install(pip_pkgs, upgrade=True, capture_output=False)
|
||||
if res.returncode != 0:
|
||||
print(" pip install failed")
|
||||
return 1
|
||||
|
||||
# 2) Playwright browsers — pulls chromium (~300MB first run).
|
||||
|
||||
@@ -7,9 +7,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_constants import get_hermes_home
|
||||
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
|
||||
from hermes_cli.config import cfg_get
|
||||
@@ -410,14 +412,8 @@ def _ensure_sdk_installed() -> bool:
|
||||
print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n")
|
||||
return False
|
||||
|
||||
import subprocess
|
||||
print(" Installing honcho-ai...", flush=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
result = pip_install(["honcho-ai>=2.0.1"])
|
||||
if result.returncode == 0:
|
||||
print(" Installed.\n")
|
||||
return True
|
||||
|
||||
@@ -116,6 +116,8 @@ class OpenRouterProfile(ProviderProfile):
|
||||
the same backend server across turns.
|
||||
"""
|
||||
extra_body: dict[str, Any] = {}
|
||||
top_level: dict[str, Any] = {}
|
||||
extra_headers: dict[str, Any] = {}
|
||||
if supports_reasoning:
|
||||
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
|
||||
# future named models) use *adaptive* thinking: the model decides
|
||||
@@ -132,18 +134,36 @@ class OpenRouterProfile(ProviderProfile):
|
||||
# The only reliable behavior is to omit ``reasoning`` and let the
|
||||
# model default to adaptive. See hermes-agent#42991 (disable case)
|
||||
# and the tool-replay follow-up.
|
||||
#
|
||||
# ``reasoning.effort`` being ignored does NOT mean these models have
|
||||
# no effort lever — OpenRouter honors the requested effort on the
|
||||
# top-level ``verbosity`` field instead (it maps to Anthropic's
|
||||
# ``output_config.effort``; ``reasoning.effort`` is accepted but
|
||||
# ignored — confirmed by OpenRouter's Claude migration docs and a
|
||||
# live token-spend probe in hermes-agent#43432). Route the existing
|
||||
# ``reasoning_config["effort"]`` (sourced from
|
||||
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
|
||||
# already sets keeps working for these models. We still send NO
|
||||
# ``reasoning`` field, preserving the #42991 400 fix.
|
||||
if _anthropic_reasoning_is_mandatory(model):
|
||||
pass # omit reasoning entirely → adaptive default
|
||||
cfg = reasoning_config or {}
|
||||
effort = cfg.get("effort")
|
||||
# Only emit when effort is actually requested and reasoning
|
||||
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
|
||||
# model keeps its own adaptive default (``high``).
|
||||
if cfg.get("enabled", True) is not False and effort and effort != "none":
|
||||
top_level["verbosity"] = effort
|
||||
elif reasoning_config is not None:
|
||||
extra_body["reasoning"] = dict(reasoning_config)
|
||||
else:
|
||||
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
|
||||
|
||||
extra_headers: dict[str, Any] = {}
|
||||
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
|
||||
extra_headers["x-grok-conv-id"] = session_id
|
||||
if extra_headers:
|
||||
top_level["extra_headers"] = extra_headers
|
||||
|
||||
return extra_body, {"extra_headers": extra_headers} if extra_headers else {}
|
||||
return extra_body, top_level
|
||||
|
||||
|
||||
openrouter = OpenRouterProfile(
|
||||
|
||||
@@ -602,6 +602,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
|
||||
self._voice_input_callback: Optional[Callable] = None # set by run.py
|
||||
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
|
||||
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
|
||||
# linked text-channel id; set by run.py. Lets the inactivity timer leave
|
||||
# the bot in the channel when the user deliberately picked text-only
|
||||
# (/voice off) instead of leaving (/voice leave).
|
||||
self._voice_mode_getter: Optional[Callable] = None # set by run.py
|
||||
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
|
||||
# Installed once per guild on join; lets acks / TTS / the "thinking"
|
||||
# loop overlap in one outgoing stream instead of stop-and-swap.
|
||||
@@ -789,6 +794,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# Must run BEFORE the user allowlist check so that bots
|
||||
# permitted by DISCORD_ALLOW_BOTS are not rejected for
|
||||
# not being in DISCORD_ALLOWED_USERS (fixes #4466).
|
||||
_role_authorized = False
|
||||
if getattr(message.author, "bot", False):
|
||||
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
|
||||
if allow_bots == "none":
|
||||
@@ -812,6 +818,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
is_dm=_is_dm,
|
||||
):
|
||||
return
|
||||
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
|
||||
|
||||
# Multi-agent filtering: if the message mentions specific bots
|
||||
# but NOT this bot, the sender is talking to another agent —
|
||||
@@ -853,7 +860,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if "*" not in _free_channels and not (_channel_ids & _free_channels):
|
||||
return
|
||||
|
||||
await self._handle_message(message)
|
||||
await self._handle_message(message, role_authorized=_role_authorized)
|
||||
|
||||
@self._client.event
|
||||
async def on_voice_state_update(member, before, after):
|
||||
@@ -2265,6 +2272,20 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
text_ch_id = self._voice_text_channels.get(guild_id)
|
||||
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
|
||||
# the channel (leaving is ``/voice leave``). The inactivity timer only
|
||||
# counts the bot's OWN audio as activity, so under voice-off mode it
|
||||
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
|
||||
# text channel with "Left voice channel (inactivity timeout)." Honor the
|
||||
# user's choice: skip the auto-disconnect while voice replies are off.
|
||||
# (The timer re-arms when the bot next speaks or hears a user.)
|
||||
_mode_getter = getattr(self, "_voice_mode_getter", None)
|
||||
if text_ch_id is not None and _mode_getter is not None:
|
||||
try:
|
||||
if _mode_getter(str(text_ch_id)) == "off":
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await self.leave_voice_channel(guild_id)
|
||||
# Notify the runner so it can clean up voice_mode state
|
||||
if self._on_voice_disconnect and text_ch_id:
|
||||
@@ -2395,6 +2416,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
is_dm=False,
|
||||
):
|
||||
continue
|
||||
# A user speaking to the bot is activity too — not just the
|
||||
# bot's own playback. Reset the inactivity timer so an active
|
||||
# listener isn't disconnected mid-conversation (this also
|
||||
# covers voice-on text-only sessions that never play audio).
|
||||
self._reset_voice_timeout(guild_id)
|
||||
await self._process_voice_input(guild_id, user_id, pcm_data)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -4702,7 +4728,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
raise Exception(f"HTTP {resp.status}")
|
||||
return await resp.read()
|
||||
|
||||
async def _handle_message(self, message: DiscordMessage) -> None:
|
||||
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
|
||||
"""Handle incoming Discord messages."""
|
||||
# In server channels (not DMs), require the bot to be @mentioned
|
||||
# UNLESS the channel is in the free-response list or the message is
|
||||
@@ -4886,6 +4912,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
guild_id=str(guild.id) if guild else None,
|
||||
parent_chat_id=parent_channel_id,
|
||||
message_id=str(message.id),
|
||||
role_authorized=role_authorized,
|
||||
)
|
||||
|
||||
# Build media URLs -- download image attachments to local cache so the
|
||||
@@ -5611,6 +5638,7 @@ def _define_discord_view_classes() -> None:
|
||||
self.allowed_role_ids = allowed_role_ids or set()
|
||||
self.resolved = False
|
||||
self._selected_provider: str = ""
|
||||
self._pending_expensive_model: str = ""
|
||||
|
||||
self._build_provider_select()
|
||||
|
||||
@@ -5693,6 +5721,41 @@ def _define_discord_view_classes() -> None:
|
||||
cancel_btn.callback = self._on_cancel
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
def _build_expensive_confirm(self, model_id: str):
|
||||
"""Build confirmation buttons for unusually expensive models."""
|
||||
self.clear_items()
|
||||
self._pending_expensive_model = model_id
|
||||
|
||||
confirm_btn = discord.ui.Button(
|
||||
label="Switch anyway",
|
||||
style=discord.ButtonStyle.red,
|
||||
custom_id="model_expensive_confirm",
|
||||
)
|
||||
confirm_btn.callback = self._on_expensive_confirm
|
||||
self.add_item(confirm_btn)
|
||||
|
||||
cancel_btn = discord.ui.Button(
|
||||
label="Cancel",
|
||||
style=discord.ButtonStyle.grey,
|
||||
custom_id="model_expensive_cancel",
|
||||
)
|
||||
cancel_btn.callback = self._on_cancel
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
async def _expensive_warning_for(self, model_id: str):
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
# Pricing lookup can hit models.dev / a /models endpoint on a
|
||||
# cache miss — keep it off the event loop.
|
||||
return await asyncio.to_thread(
|
||||
expensive_model_warning,
|
||||
model_id,
|
||||
provider=self._selected_provider,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _on_provider_selected(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
@@ -5722,7 +5785,11 @@ def _define_discord_view_classes() -> None:
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def _on_model_selected(self, interaction: discord.Interaction):
|
||||
async def _switch_selected_model(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
model_id: str,
|
||||
):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"Already resolved~", ephemeral=True
|
||||
@@ -5735,7 +5802,6 @@ def _define_discord_view_classes() -> None:
|
||||
return
|
||||
|
||||
self.resolved = True
|
||||
model_id = interaction.data["values"][0]
|
||||
self.clear_items()
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
@@ -5764,6 +5830,50 @@ def _define_discord_view_classes() -> None:
|
||||
view=None,
|
||||
)
|
||||
|
||||
async def _on_model_selected(self, interaction: discord.Interaction):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"Already resolved~", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
model_id = interaction.data["values"][0]
|
||||
warning = await self._expensive_warning_for(model_id)
|
||||
if warning is not None:
|
||||
self._build_expensive_confirm(model_id)
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚠ Expensive Model Warning",
|
||||
description=warning.message,
|
||||
color=discord.Color.red(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
return
|
||||
|
||||
await self._switch_selected_model(interaction, model_id)
|
||||
|
||||
async def _on_expensive_confirm(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not self._pending_expensive_model:
|
||||
await interaction.response.send_message(
|
||||
"Model selection expired.", ephemeral=True
|
||||
)
|
||||
return
|
||||
await self._switch_selected_model(
|
||||
interaction,
|
||||
self._pending_expensive_model,
|
||||
)
|
||||
|
||||
async def _on_back(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
@@ -6494,7 +6604,7 @@ def register(ctx) -> None:
|
||||
check_fn=check_discord_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["DISCORD_BOT_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[messaging]'",
|
||||
install_hint="uv pip install -e '.[messaging]' # from the hermes-agent checkout",
|
||||
# Interactive setup wizard — replaces the central
|
||||
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
|
||||
setup_fn=interactive_setup,
|
||||
|
||||
@@ -3299,7 +3299,7 @@ def register(ctx) -> None:
|
||||
"GOOGLE_CHAT_SUBSCRIPTION_NAME",
|
||||
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
|
||||
],
|
||||
install_hint="pip install 'hermes-agent[google_chat]'",
|
||||
install_hint="uv pip install -e '.[google_chat]' # from the hermes-agent checkout",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration — the core env-populator hook calls
|
||||
# this during ``_apply_env_overrides`` and seeds
|
||||
|
||||
@@ -68,7 +68,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
# Pin the legacy logger name so operator-side log filters keep matching
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from utils import atomic_replace
|
||||
# after the in-tree → plugin migration. See adapter.py for context.
|
||||
logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
|
||||
|
||||
@@ -379,16 +380,15 @@ def install_deps() -> bool:
|
||||
|
||||
print("Installing Google Chat OAuth dependencies...")
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
result = pip_install(_REQUIRED_PACKAGES, quiet=True)
|
||||
if result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args)
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"ERROR: Failed to install dependencies: {exc}")
|
||||
print("Or install via the optional extra:")
|
||||
print(" pip install 'hermes-agent[google_chat]'")
|
||||
print(" uv pip install -e '.[google_chat]' # from the hermes-agent checkout")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,7 +47,7 @@ _discovered = False
|
||||
|
||||
# Repo-root ``plugins/model-providers/`` — populated at discovery time.
|
||||
_BUNDLED_PLUGINS_DIR = (
|
||||
Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
|
||||
get_hermes_source_root() / "plugins" / "model-providers"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+82
-16
@@ -50,7 +50,7 @@ dependencies = [
|
||||
"tenacity==9.1.4",
|
||||
"pyyaml==6.0.3",
|
||||
"ruamel.yaml==0.18.17",
|
||||
"requests==2.33.0", # CVE-2026-25645
|
||||
"requests==2.33.0", # CVE-2026-25645
|
||||
"jinja2==3.1.6",
|
||||
# Bumped from 2.12.5 to 2.13.4 to pull in pydantic-core 2.46.4.
|
||||
# pydantic-core 2.41.5 (pulled by 2.12.5) segfaults when the OpenAI SDK's
|
||||
@@ -82,7 +82,7 @@ dependencies = [
|
||||
# it out of the lazy-install path that exists only for the heavy matrix deps.
|
||||
"Markdown==3.10.2",
|
||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
|
||||
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
|
||||
# urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass)
|
||||
# and GHSA-qccp-gfcp-xxvc (header leak across origins).
|
||||
"urllib3>=2.7.0,<3",
|
||||
@@ -117,7 +117,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
# Native Anthropic provider — only needed when provider=anthropic (not via
|
||||
# OpenRouter or other aggregators).
|
||||
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
|
||||
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
|
||||
# Web search backends — each only loaded when the user picks it as their
|
||||
# search provider (configured via `hermes tools` or config.yaml).
|
||||
exa = ["exa-py==2.10.2"]
|
||||
@@ -131,11 +131,34 @@ edge-tts = ["edge-tts==7.2.7"]
|
||||
modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
dev = [
|
||||
"debugpy==1.8.20",
|
||||
"pytest==9.0.2",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"pytest-timeout==2.4.0",
|
||||
"mcp==1.26.0",
|
||||
"starlette==1.0.1",
|
||||
"ty==0.0.21",
|
||||
"ruff==0.15.10",
|
||||
"setuptools==82.0.1",
|
||||
] # starlette: CVE-2026-48710
|
||||
messaging = [
|
||||
"python-telegram-bot[webhooks]==22.6",
|
||||
"discord.py[voice]==2.7.1",
|
||||
"aiohttp==3.13.4",
|
||||
"brotlicffi==1.2.0.1",
|
||||
"slack-bolt==1.27.0",
|
||||
"slack-sdk==3.40.1",
|
||||
"qrcode==7.4.2",
|
||||
] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"]
|
||||
matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
|
||||
matrix = [
|
||||
"mautrix[encryption]==0.21.0",
|
||||
"aiosqlite==0.22.1",
|
||||
"asyncpg==0.31.0",
|
||||
"aiohttp-socks==0.11.0",
|
||||
]
|
||||
# WeCom callback-mode adapter — parses untrusted XML POST bodies from
|
||||
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in
|
||||
# replacement for stdlib xml.etree.ElementTree) to block billion-laughs
|
||||
@@ -171,7 +194,7 @@ vision = []
|
||||
# `request.url` can be bypassed. We pin a patched Starlette directly in every
|
||||
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
|
||||
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
|
||||
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
nemo-relay = ["nemo-relay==0.3"]
|
||||
homeassistant = ["aiohttp==3.13.4"]
|
||||
sms = ["aiohttp==3.13.4"]
|
||||
@@ -179,7 +202,7 @@ sms = ["aiohttp==3.13.4"]
|
||||
# The cua-driver binary itself is installed via `hermes tools` post-setup
|
||||
# (curl install script); this extra just pins the MCP client used to talk
|
||||
# to it, which is already provided by the `mcp` extra.
|
||||
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
acp = ["agent-client-protocol==0.9.0"]
|
||||
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
|
||||
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
|
||||
@@ -213,7 +236,11 @@ termux-all = [
|
||||
"hermes-agent[sms]",
|
||||
"hermes-agent[web]",
|
||||
]
|
||||
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
|
||||
dingtalk = [
|
||||
"dingtalk-stream==0.24.3",
|
||||
"alibabacloud-dingtalk==2.2.42",
|
||||
"qrcode==7.4.2",
|
||||
]
|
||||
feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
|
||||
google = [
|
||||
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
|
||||
@@ -275,7 +302,22 @@ hermes-agent = "run_agent:main"
|
||||
hermes-acp = "acp_adapter.entry:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]
|
||||
py-modules = [
|
||||
"run_agent",
|
||||
"model_tools",
|
||||
"toolsets",
|
||||
"batch_runner",
|
||||
"trajectory_compressor",
|
||||
"toolset_distributions",
|
||||
"cli",
|
||||
"hermes_bootstrap",
|
||||
"hermes_constants",
|
||||
"hermes_state",
|
||||
"hermes_time",
|
||||
"hermes_logging",
|
||||
"utils",
|
||||
"mcp_serve",
|
||||
]
|
||||
|
||||
[tool.setuptools.data-files]
|
||||
# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is
|
||||
@@ -301,7 +343,12 @@ locales = ["locales/*.yaml"]
|
||||
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
|
||||
hermes_cli = [
|
||||
"web_dist/**/*",
|
||||
"tui_dist/**/*",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
]
|
||||
gateway = ["assets/**/*"]
|
||||
plugins = [
|
||||
"*/dashboard/manifest.json",
|
||||
@@ -319,13 +366,30 @@ plugins = [
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
include = [
|
||||
"agent",
|
||||
"agent.*",
|
||||
"tools",
|
||||
"tools.*",
|
||||
"hermes_cli",
|
||||
"hermes_cli.*",
|
||||
"gateway",
|
||||
"gateway.*",
|
||||
"tui_gateway",
|
||||
"tui_gateway.*",
|
||||
"cron",
|
||||
"acp_adapter",
|
||||
"plugins",
|
||||
"plugins.*",
|
||||
"providers",
|
||||
"providers.*",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration: marks tests requiring external services (API keys, Modal, etc.)",
|
||||
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
|
||||
"integration: marks tests requiring external services (API keys, Modal, etc.)",
|
||||
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
|
||||
]
|
||||
# pytest-timeout: per-test 30s hard cap with cross-platform thread method.
|
||||
# This is the fallback inside each per-file pytest subprocess (see
|
||||
@@ -338,11 +402,13 @@ addopts = "-m 'not integration' --timeout=30 --timeout-method=thread"
|
||||
python-version = "3.13"
|
||||
|
||||
[tool.ty.rules]
|
||||
all = "warn"
|
||||
unknown-argument = "warn"
|
||||
redundant-cast = "ignore"
|
||||
unresolved-reference = "error"
|
||||
|
||||
[tool.ruff]
|
||||
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
|
||||
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
|
||||
|
||||
[tool.ruff.lint]
|
||||
# All other lints are intentionally disabled (see comment history on this
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user