Merge origin/main: unify declared-schema and instance-schema provider config

Main's #60569 (dashboard memory provider switching) rewrote the same
GET/PUT /api/memory/providers/{name}/config routes this PR owns — the
dashboard and desktop share one backend. Resolve by dispatching: providers
that declare a config_schema.py get the declared path (host-block storage,
locked honcho writes, profile scoping, actions); everything else keeps
main's instance get_config_schema()/save_config path unchanged, so the
dashboard's PluginsPage behavior is preserved for instance providers.
Setup manifests, the /setup endpoint, provider switching, and name
validation from #60569 apply to both paths. Instance payloads gain the
docs_url/actions keys the desktop panel expects; declared payloads gain
the setup block the dashboard expects. Main's hindsight/honcho instance-
schema tests are updated for the dispatch, and its status test gets the
HOME pin it was missing (it read the developer's real ~/.honcho).
This commit is contained in:
Erosika 2026-07-08 15:03:24 -04:00
commit 1508ece2d9
948 changed files with 85189 additions and 7133 deletions

2
.envrc
View file

@ -1,4 +1,4 @@
watch_file pyproject.toml uv.lock
watch_file pyproject.toml uv.lock hermes
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix

View file

@ -221,10 +221,17 @@ jobs:
- name: Save baseline cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: cp ci-timings.json ci-timings-baseline.json
run: |
# Degraded runs (API rate-limited) produce no ci-timings.json —
# skip rather than fail, and never cache an empty baseline.
if [ -f ci-timings.json ]; then
cp ci-timings.json ci-timings-baseline.json
else
echo "No timings JSON this run — skipping baseline update"
fi
- name: Upload baseline to cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ci-timings-baseline.json

View file

@ -178,6 +178,9 @@ jobs:
- name: Create manifest list and push
working-directory: /tmp/digests
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
args=()
@ -185,9 +188,8 @@ jobs:
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
docker buildx imagetools create \
-t "${IMAGE_NAME}:${TAG}" \
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
"${args[@]}"
else
docker buildx imagetools create \
@ -195,15 +197,14 @@ jobs:
-t "${IMAGE_NAME}:latest" \
"${args[@]}"
fi
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
- name: Inspect image
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
else
docker buildx imagetools inspect "${IMAGE_NAME}:main"
fi
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}

View file

@ -98,6 +98,8 @@ jobs:
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
- name: Generate diff summary
env:
HEAD_REF: ${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}
run: |
python scripts/lint_diff.py \
--base-ruff .lint-reports/base/ruff.json \
@ -105,7 +107,7 @@ jobs:
--base-ty .lint-reports/base/ty.json \
--head-ty .lint-reports/head/ty.json \
--base-ref "${{ steps.base.outputs.ref }}" \
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
--head-ref "$HEAD_REF" \
--output .lint-reports/summary.md
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"

1
.gitignore vendored
View file

@ -8,6 +8,7 @@ __pycache__/
.venv
.vscode/
.env
.op.env
.env.local
.env.development.local
.env.test.local

View file

@ -491,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
### Electron Desktop Chat App (`apps/desktop/`)
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:

View file

@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
| Requisito | Notas |
|-----------|-------|
| **Git** | Con la extensión `git-lfs` instalada |
| **Python 3.11+** | uv lo instalará si falta |
| **Python 3.113.13** | uv lo instalará si falta |
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |

View file

@ -109,7 +109,7 @@ A well-built third-party-product plugin can clear automated review and still be
| Requirement | Notes |
|-------------|-------|
| **Git** | With the `git-lfs` extension installed |
| **Python 3.11+** | uv will install it if missing |
| **Python 3.113.13** | uv will install it if missing |
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |

View file

@ -7,5 +7,7 @@ graft locales
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
recursive-include gateway/assets *
global-exclude __pycache__
global-exclude *.py[cod]

View file

@ -617,7 +617,7 @@ def _format_session_search_result(result: Optional[str]) -> Optional[str]:
return None
mode = data.get("mode") or "search"
query = data.get("query")
lines = ["Recent sessions" if mode == "recent" else f"Session search results" + (f" for `{query}`" if query else "")]
lines = ["Recent sessions" if mode == "recent" else "Session search results" + (f" for `{query}`" if query else "")]
if not results:
lines.append(str(data.get("message") or "No matching sessions found."))
return "\n".join(lines)

View file

@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.18.0",
"version": "0.18.2",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.18.0",
"package": "hermes-agent[acp]==0.18.2",
"args": ["hermes-acp"]
}
}

View file

@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional
import httpx
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
if TYPE_CHECKING:
@ -436,20 +436,78 @@ def _resolve_codex_usage_url(base_url: str) -> str:
return normalized + "/api/codex/usage"
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
token_data = _read_codex_tokens()
tokens = token_data.get("tokens") or {}
account_id = str(tokens.get("account_id", "") or "").strip() or None
def _resolve_codex_usage_credentials(
base_url: Optional[str],
api_key: Optional[str],
) -> tuple[str, str, Optional[str]]:
"""Resolve Codex quota credentials from the native runtime path.
Prefer explicit live-agent credentials, then the legacy singleton OAuth
state, then the credential pool. Hermes's native OAuth setup now stores
device-code logins in the pool, so quota diagnostics must not depend only
on the older singleton store.
"""
explicit_key = str(api_key or "").strip()
if explicit_key:
return explicit_key, str(base_url or "").strip(), None
# Tier 2: the native runtime resolver. It ALREADY falls back to the
# credential pool when the singleton is empty (see
# ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only
# setup this returns a usable ``source="credential_pool"`` token.
#
# Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can
# run: a broad ``except Exception`` would (a) mask a transient refresh /
# network failure and silently hand back a DIFFERENT pool account's usage,
# and (b) hide genuine programming errors. A refresh/network error must
# propagate — the outer ``fetch_account_usage`` guard fails open (shows
# nothing this turn) rather than reporting the wrong account.
#
# The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read
# best-effort: a partial/missing singleton token store must not sink an
# otherwise-usable resolver credential and force a header-less pool fallback.
try:
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
account_id: Optional[str] = None
try:
token_data = _read_codex_tokens()
tokens = token_data.get("tokens") or {}
account_id = str(tokens.get("account_id", "") or "").strip() or None
except AuthError:
# Pool-only creds carry no singleton account_id; header is optional.
logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True)
return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id
except AuthError:
logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True)
# Tier 3: direct pool select. Reached only when the resolver itself raises
# AuthError (e.g. singleton missing AND its own pool read found nothing at
# resolve time, but a pool entry is usable now). Pool credentials have no
# account_id concept, so the ChatGPT-Account-Id header is intentionally
# omitted here.
from agent.credential_pool import load_pool
pool = load_pool("openai-codex")
entry = pool.select()
if entry is None:
raise RuntimeError("No available openai-codex credential in credential pool")
return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None
def _fetch_codex_account_usage(
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Optional[AccountUsageSnapshot]:
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
headers = {
"Authorization": f"Bearer {creds['api_key']}",
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
if account_id:
headers["ChatGPT-Account-Id"] = account_id
with httpx.Client(timeout=15.0) as client:
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers)
response.raise_for_status()
payload = response.json() or {}
rate_limit = payload.get("rate_limit") or {}
@ -628,7 +686,7 @@ def fetch_account_usage(
return None
try:
if normalized == "openai-codex":
return _fetch_codex_account_usage()
return _fetch_codex_account_usage(base_url=base_url, api_key=api_key)
if normalized == "anthropic":
return _fetch_anthropic_account_usage()
if normalized == "openrouter":

View file

@ -68,24 +68,118 @@ def _ra():
return run_agent
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
text is printed inline for CLI users and replayed via ``status_callback``
for gateway users, so it must be self-contained and include the exact
opt-back-out command.
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
The same text is printed inline for CLI users and replayed via
``status_callback`` for gateway users, so it must be self-contained and
include the exact opt-back-out command.
"""
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is
# capped at 272K by the Codex OAuth backend.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
return (
f" Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
f" Codex {model} caps context at {cap}, so auto-compaction was raised "
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
f"summarizing.\n"
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
)
def _resolve_compression_threshold(
global_threshold: float,
model_cthresh: Optional[float],
*,
model: Optional[str] = None,
is_codex_autoraise: bool,
) -> tuple[float, Optional[Dict[str, Any]]]:
"""Combine the user's global compaction threshold with a per-model override.
Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is
``{"model": <slug>, "from": <old>, "to": <new>}`` only when a Codex
autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises
the threshold, otherwise ``None``.
The Codex overrides are *autoraises*: they must never LOWER a higher
user-configured threshold. A user who already set ``compression.threshold``
above the raised value deliberately keeps more raw context, and silently
dropping them would both waste usable window and contradict the feature's
purpose (use more of the window). Other overrides (e.g. Arcee Trinity)
keep their existing unconditional behaviour.
"""
if model_cthresh is None:
return global_threshold, None
if is_codex_autoraise:
if model_cthresh <= global_threshold + 1e-9:
# Autoraise never lowers; keep the user's higher/equal threshold.
return global_threshold, None
return model_cthresh, {
"model": model,
"from": global_threshold,
"to": model_cthresh,
}
return model_cthresh, None
def _codex_gpt55_autoraise_notice_marker():
"""Path to the per-profile marker recording that the autoraise notice ran.
Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other
internal markers like ``.container-mode`` so it is not a user-facing config
key, and every profile tracks its own notice state independently.
"""
return get_hermes_home() / ".codex_gpt55_autoraise_notice"
def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:
"""Stable identity for one autoraise notice, keyed on what it displays.
Uses the model slug plus the same fromto percentages the notice text
shows, so an unchanged threshold stays silent across restarts while a
later change (the user edits their global ``threshold``, or switches to a
different autoraised Codex model) re-notifies once.
"""
model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1]
from_pct = int(round(float(autoraise["from"]) * 100))
to_pct = int(round(float(autoraise["to"]) * 100))
return f"{model}:{from_pct}:{to_pct}"
def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:
"""True if this exact autoraise notice was already shown for this profile.
A missing/unreadable marker (or one recording a different threshold) reads
as unseen, so the notice shows.
"""
try:
current = _codex_gpt55_autoraise_notice_state(autoraise)
return _codex_gpt55_autoraise_notice_marker().read_text(
encoding="utf-8"
).strip() == current
except (OSError, KeyError, TypeError, ValueError):
return False
def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None:
"""Persist that the autoraise notice was shown for this profile/config state.
Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice
may show again next init, which is preferable to breaking agent init.
"""
try:
marker = _codex_gpt55_autoraise_notice_marker()
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(
_codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8"
)
except (OSError, KeyError, TypeError, ValueError):
pass
def _normalized_custom_base_url(value: Any) -> str:
if not isinstance(value, str):
return ""
@ -1363,6 +1457,17 @@ def init_agent(
# line). Useful for users on exotic setups where the probe heuristics
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
# Warm the probe off-thread: it shells out to python3/pip (~0.5s of
# subprocess round-trips) and its result lands in the FIRST system
# prompt build, which sits on the time-to-first-token critical path.
# The warm runs during agent init (network/credential setup dominates),
# so by the time the first prompt is built the line is already cached.
if agent._environment_probe:
try:
from tools.env_probe import warm_environment_probe_async
warm_environment_probe_async()
except Exception:
pass
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
# Lets an enterprise admin append to or replace Hermes' built-in
@ -1398,41 +1503,48 @@ def init_agent(
if not isinstance(_compression_cfg, dict):
_compression_cfg = {}
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
# 85% (the Codex backend caps the window at 272K, so the default 50% would
# compact at ~136K — half the usable context). Gated by an opt-out config
# flag so the user can fall back to the global threshold; when the override
# fires we stash a one-time notification (replayed on the first turn) that
# tells the user what changed and how to revert.
# Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5
# raise to 85% (the Codex backend caps both families at 272K, so the
# default 50% would compact at ~136K — half the usable context). Gated by
# an opt-out config flag so the user can fall back to the global threshold;
# when the override fires we stash a one-time notification (replayed on the
# first turn) that tells the user what changed and how to revert. The
# notice has its own display gate so users can keep the threshold
# autoraise without getting the banner on gateway turns.
_codex_gpt55_autoraise = str(
_compression_cfg.get("codex_gpt55_autoraise", True)
).lower() in {"true", "1", "yes"}
_codex_gpt55_autoraise_notice = str(
_compression_cfg.get("codex_gpt55_autoraise_notice", True)
).lower() in {"true", "1", "yes"}
agent._compression_threshold_autoraised = None
try:
from agent.auxiliary_client import (
_compression_threshold_for_model as _cthresh_fn,
_is_codex_gpt55 as _is_codex_gpt55_fn,
_is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn,
_is_codex_spark as _is_codex_spark_fn,
)
_model_cthresh = _cthresh_fn(
agent.model,
agent.provider,
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
)
if _model_cthresh is not None:
_prev_threshold = compression_threshold
compression_threshold = _model_cthresh
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
# override is a long-standing silent default). Skip the notice when
# the user's global threshold already meets/exceeds the raised
# value, since nothing actually changed for them.
if (
_is_codex_gpt55_fn(agent.model, agent.provider)
and _model_cthresh > _prev_threshold + 1e-9
):
agent._compression_threshold_autoraised = {
"from": _prev_threshold,
"to": _model_cthresh,
}
# The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark)
# apply only when they RAISE (never lower a user's higher global
# threshold). The notice is populated only when it actually fires, and
# carries the model slug so the banner names the right family. Arcee
# Trinity keeps its long-standing unconditional behaviour.
compression_threshold, agent._compression_threshold_autoraised = (
_resolve_compression_threshold(
compression_threshold,
_model_cthresh,
model=agent.model,
is_codex_autoraise=(
_is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider)
or _is_codex_spark_fn(agent.model, agent.provider)
),
)
)
except Exception:
pass
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
@ -1458,6 +1570,16 @@ def init_agent(
compression_in_place = is_truthy_value(
_compression_cfg.get("in_place"), default=False
)
codex_app_server_auto_compaction = str(
_compression_cfg.get("codex_app_server_auto", "native") or "native"
).lower()
if codex_app_server_auto_compaction not in {"native", "hermes", "off"}:
_ra().logger.warning(
"Invalid compression.codex_app_server_auto=%r; using 'native'. "
"Valid values are: native, hermes, off.",
codex_app_server_auto_compaction,
)
codex_app_server_auto_compaction = "native"
# Read optional explicit context_length override for the auxiliary
# compression model. Custom endpoints often cannot report this via
@ -1661,6 +1783,12 @@ def init_agent(
if _selected_engine is not None:
agent.context_compressor = _selected_engine
# External engines own compaction policy: the host compression
# threshold (including the Codex gpt-5.5 autoraise above) only
# configures the built-in ContextCompressor and never reaches the
# plugin, so the autoraise notice would announce a change that does
# not apply. Drop it. (#44439)
agent._compression_threshold_autoraised = None
# Resolve context_length for plugin engines — mirrors switch_model() path
from agent.model_metadata import get_model_context_length
_plugin_ctx_len = get_model_context_length(
@ -1706,6 +1834,7 @@ def init_agent(
pass
agent.compression_enabled = compression_enabled
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@ -1721,6 +1850,33 @@ def init_agent(
f"(this must be at least {MINIMUM_CONTEXT_LENGTH // 1000}K)."
)
# Nous Hermes 3/4 are chat models, not tool-call-tuned. The interactive
# CLI already warns via cli.py show_banner() (richer output + /model hint),
# so skip platform=="cli" here to avoid emitting the warning twice per
# startup. (Gateway/TUI/cron construct with quiet_mode=True and are already
# gated off by the `not agent.quiet_mode` check above; this guard's active
# job is the CLI dedup, and it leaves the door open for any non-quiet
# non-CLI surface to still surface the warning.)
if not agent.quiet_mode and (agent.platform or "cli") != "cli":
try:
from hermes_cli.model_switch import _check_hermes_model_warning
_hermes_warn = _check_hermes_model_warning(agent.model or "")
if _hermes_warn:
_user_msg = (
"⚠ Nous Research Hermes 3 & 4 models are NOT agentic — they "
"lack reliable tool-calling for agent workflows (delegation, "
"cron, proactive tools). Consider an agentic model instead "
"(Claude, GPT, Gemini, Qwen-Coder, etc.)."
)
if hasattr(agent, "_emit_warning"):
agent._emit_warning(_user_msg)
else:
print(f"\n{_user_msg}\n", file=sys.stderr)
_ra().logger.warning(_hermes_warn)
except Exception:
pass
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand).
# Skip names that are already present — the _ra().get_tool_definitions()
# quiet_mode cache returned a shared list pre-#17335, so a stray
@ -1790,6 +1946,8 @@ def init_agent(
working_dir=os.getenv("TERMINAL_CWD") or None,
)
agent._user_turn_count = 0
# Copilot x-initiator flag: first API call of a user turn sends "user" (#3040).
agent._is_user_initiated_turn = False
# Cumulative token usage for the session
agent.session_prompt_tokens = 0
@ -1854,29 +2012,53 @@ def init_agent(
agent._ollama_num_ctx,
)
# Codex gpt-5.x autoraise notice: show at most once per profile/config
# state. Without the persisted marker the notice re-fires on every agent
# init — and the gateway rebuilds the agent per inbound message, so Discord
# etc. saw it repeatedly (#54432). A change in the raised threshold (or the
# autoraised model) updates the marker state and re-notifies once. The
# config display gate (compression.codex_gpt55_autoraise_notice) still
# suppresses the banner entirely without disabling the threshold autoraise.
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
_show_autoraise_notice = (
bool(_autoraise)
and compression_enabled
and _codex_gpt55_autoraise_notice
and not _codex_gpt55_autoraise_notice_seen(_autoraise)
)
if not agent.quiet_mode:
if compression_enabled:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
# Report the active engine's own threshold — for a plugin engine
# the host compression_threshold is not in effect, and mixing the
# two printed a percent that contradicted the token count. (#44439)
_active_threshold_pct = getattr(
agent.context_compressor, "threshold_percent", compression_threshold
)
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
# exact opt-back-out command. Printed inline at startup for CLI users;
# gateway users get the same text replayed via _compression_warning on
# turn 1 (set below, after the warning slot is initialized).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
print(_build_codex_gpt55_autoraise_notice(_autoraise))
# Notice with the exact opt-back-out command. Printed inline at startup
# for CLI users; gateway users get the same text replayed via
# _compression_warning on turn 1 (set below).
if _show_autoraise_notice:
print(_build_codex_gpt5_autoraise_notice(_autoraise))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
# in _compression_warning and replayed in the first run_conversation().
agent._compression_warning = None
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
# Gateway parity for the Codex gpt-5.x autoraise notice: the startup print
# above only reaches the CLI, so stash the same text here to be replayed
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
if _show_autoraise_notice:
agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
# Mark shown so repeated inits in this profile (e.g. every gateway message)
# stay silent. Recorded once, whether the notice went to the CLI print or
# the gateway replay slot.
if _show_autoraise_notice:
_record_codex_gpt55_autoraise_notice(_autoraise)
# Lazy feasibility check: deferred to the first turn that approaches the
# compression threshold. Running it eagerly here costs ~400ms cold (network
# probe of the auxiliary provider chain + /models lookup) on every agent

View file

@ -306,7 +306,13 @@ def sanitize_tool_call_arguments(
try:
json.loads(arguments)
except json.JSONDecodeError:
tool_call_id = tool_call.get("id")
# Use the canonical ``call_id || id`` precedence so both the
# scan for an existing tool result and any inserted stub key
# on the same id the rest of the pipeline uses. Keying on bare
# ``id`` here would fail to find a result built with ``call_id``
# (Codex Responses format) and insert a duplicate stub that
# itself becomes an orphan (#58168).
tool_call_id = _ra().AIAgent._get_tool_call_id_static(tool_call) or None
function_name = function.get("name", "?")
preview = arguments[:80]
log.warning(
@ -464,6 +470,21 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
# Pass 1: drop stray tool messages that don't follow a known
# assistant tool_call_id. Uses a rolling set of known ids refreshed
# on each assistant message.
#
# Both ``id`` AND ``call_id`` are registered for every assistant
# tool_call. In the Codex Responses API format the two differ
# (``id`` = ``fc_...`` response-item id, ``call_id`` = ``call_...``
# the function-call id), and a tool result's ``tool_call_id`` may be
# matched against *either* depending on which code path built it
# (the OpenAI-compatible path stores ``tc.id``; codex paths store
# ``call_id``). Registering only ``id`` — as this pass did before —
# made a valid tool result look orphaned whenever the assistant
# tool_call carried a distinct ``call_id`` (or only ``call_id``); the
# pass then dropped it, leaving the assistant tool_call unanswered and
# producing an HTTP 400 on strict providers (DeepSeek, Kimi). Matching
# on the *superset* of both keys achieves the same tolerance as
# ``_get_tool_call_id_static``'s ``call_id || id`` — a match set must
# accept every legitimate reference, not just the canonical one (#58168).
known_tool_ids: set = set()
filtered: List[Dict] = []
for msg in collapsed:
@ -474,14 +495,23 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
if role == "assistant":
known_tool_ids = set()
for tc in (msg.get("tool_calls") or []):
tc_id = tc.get("id") if isinstance(tc, dict) else None
if tc_id:
known_tool_ids.add(tc_id)
if not isinstance(tc, dict):
continue
for key in ("id", "call_id"):
tc_id = tc.get(key)
if tc_id:
known_tool_ids.add(tc_id)
filtered.append(msg)
elif role == "tool":
tc_id = msg.get("tool_call_id")
if tc_id and tc_id in known_tool_ids:
filtered.append(msg)
# Consume the id so a SECOND tool result carrying the same
# tool_call_id (duplicate from a retry/crash/session-resume
# glitch) falls into the drop branch below instead of being
# replayed — strict providers (DeepSeek) reject a duplicate
# tool_call_id with HTTP 400 (#58327). Credit: #55436.
known_tool_ids.discard(tc_id)
else:
repairs += 1
else:
@ -699,7 +729,14 @@ def recover_with_credential_pool(
# that seeded the pool.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
if current_provider and pool_provider and current_provider != pool_provider:
# Guard: skip credential pool recovery when the pool is scoped to a
# different provider than the agent. Only guard when the pool has a
# known provider — an empty pool provider means "unscoped" (applies to
# any provider). An empty agent provider is treated as a mismatch
# because swapping the pool's credentials would set base_url/api_key
# without fixing the empty provider field, leaving the agent in a
# corrupted state (provider="" model="").
if pool_provider and current_provider != pool_provider:
# Custom endpoints use two naming conventions for the SAME provider:
# the agent carries the generic ``custom`` label while the pool is
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
@ -1238,6 +1275,12 @@ def restore_primary_runtime(agent) -> bool:
agent._fallback_activated = False
agent._fallback_index = 0
# Reset the stale-call circuit breaker (#58962): the streak measured
# the FALLBACK provider we're leaving; the restored primary deserves
# a fresh stream attempt before the breaker can trip again.
from agent.chat_completion_helpers import _reset_stale_streak
_reset_stale_streak(agent)
# Undo the fallback's identity rewrite so the prompt is
# byte-identical to the stored copy again (prefix cache match).
from agent.chat_completion_helpers import rewrite_prompt_model_identity
@ -1478,6 +1521,46 @@ def anthropic_prompt_cache_policy(
eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "")
eff_model = (model if model is not None else agent.model) or ""
# MoA virtual provider: the agent's model/provider are the preset name and
# "moa" — neither matches any caching branch, so the ACTING AGGREGATOR
# (often Claude on OpenRouter) silently lost prompt caching entirely
# (measured: 85% cache share solo vs 2% on the identical model via MoA —
# tens of millions of re-billed input tokens per benchmark run). Resolve
# the policy from the preset's real aggregator slot instead.
if eff_provider.strip().lower() == "moa":
try:
from hermes_cli.config import load_config as _load_moa_cfg
from hermes_cli.moa_config import resolve_moa_preset
from hermes_cli.runtime_provider import resolve_runtime_provider
_preset = resolve_moa_preset(
_load_moa_cfg().get("moa") or {}, eff_model or None
)
_agg = _preset.get("aggregator") or {}
_agg_provider = str(_agg.get("provider") or "").strip()
_agg_model = str(_agg.get("model") or "").strip()
if _agg_provider and _agg_model:
_agg_base_url = ""
_agg_api_mode = ""
try:
_rt = resolve_runtime_provider(
requested=_agg_provider, target_model=_agg_model
)
_agg_base_url = _rt.get("base_url") or ""
_agg_api_mode = _rt.get("api_mode") or ""
except Exception:
pass
return anthropic_prompt_cache_policy(
agent,
provider=_agg_provider,
base_url=_agg_base_url,
api_mode=_agg_api_mode,
model=_agg_model,
)
except Exception as _moa_exc: # pragma: no cover - defensive
logger.debug("MoA aggregator cache-policy resolution failed: %s", _moa_exc)
return False, False
model_lower = eff_model.lower()
provider_lower = eff_provider.lower()
is_claude = "claude" in model_lower
@ -1915,6 +1998,14 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Invalidate cached system prompt so it rebuilds next turn ──
agent._cached_system_prompt = None
# ── Reset the cross-turn stale-call circuit breaker (#58962) ──
# The breaker's error text tells the user to "switch models ... then
# retry"; without this reset the streak stays latched and the freshly
# selected (healthy) provider would keep short-circuiting before any
# stream is even attempted.
from agent.chat_completion_helpers import _reset_stale_streak
_reset_stale_streak(agent)
# ── Update _primary_runtime so the change persists across turns ──
_cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None
agent._primary_runtime = {
@ -2024,12 +2115,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
except Exception as _mw_err:
logger.debug("tool_request middleware error: %s", _mw_err)
# Check plugin hooks for a block directive before executing anything.
# Check plugin hooks for a block or approval directive before executing.
block_message: Optional[str] = None
if not pre_tool_block_checked:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
@ -2040,7 +2131,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
middleware_trace=list(_tool_middleware_trace),
)
except Exception:
pass
block_message = None
if block_message is not None:
result = json.dumps({"error": block_message}, ensure_ascii=False)
try:
@ -2318,6 +2409,41 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
filtered.append(msg)
messages = filtered
# --- Drop empty / malformed tool_calls arrays on assistant messages ---
# An assistant message carrying ``tool_calls: []`` (an empty array) — or a
# non-list value under the key — is semantically identical to an assistant
# message with no tool calls, but strict OpenAI-compatible providers reject
# the empty array outright: DeepSeek v4 returns HTTP 400 "Invalid
# 'messages[N].tool_calls': empty array. Expected an array with minimum
# length 1, but got an empty array instead." (#58755, follow-up to #56980).
# Empty arrays reach here from session resume, host-fed histories, or the
# consecutive-assistant merge in ``repair_message_sequence`` (which
# preserves a pre-existing ``[]`` on the surviving turn). This is the final
# pre-API chokepoint, so normalize defensively — and, per the #56980
# review, do it HERE on the per-call copy rather than in
# ``repair_message_sequence``, which would destructively rewrite the
# persisted trajectory. Shallow-copy the message before dropping the key so
# stored history (and prompt caching) stays byte-stable.
normalized: List[Dict[str, Any]] = []
dropped_empty_tool_calls = 0
for msg in messages:
if (
isinstance(msg, dict)
and msg.get("role") == "assistant"
and "tool_calls" in msg
and not (isinstance(msg["tool_calls"], list) and msg["tool_calls"])
):
msg = {k: v for k, v in msg.items() if k != "tool_calls"}
dropped_empty_tool_calls += 1
normalized.append(msg)
if dropped_empty_tool_calls:
messages = normalized
_ra().logger.debug(
"Pre-call sanitizer: dropped empty/invalid tool_calls on %d "
"assistant message(s)",
dropped_empty_tool_calls,
)
# --- Repair tool_calls whose function.name is empty/missing ---
# Some providers (and partially-streamed responses) emit a tool_call with
# id="call_xxx" but function.name="". Downstream Responses-API adapters
@ -2414,6 +2540,50 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
"Pre-call sanitizer: added %d stub tool result(s)",
len(missing_results),
)
# 3. Deduplicate tool_call_ids. Strict providers (DeepSeek) reject a
# payload where the same tool_call_id appears more than once with HTTP 400
# "Duplicate value for 'tool_call_id'" (#58327). Duplicates can arise from
# retries, crash/resume glitches, or a compression window that re-emits a
# tool result. This is the final pre-API chokepoint, so dedup defensively
# here even though repair_message_sequence also consumes matched ids.
# (a) collapse duplicate tool_calls WITHIN an assistant message
# (b) drop later tool result messages reusing an already-seen id
seen_assistant_call_ids: set = set()
seen_result_call_ids: set = set()
deduped: List[Dict[str, Any]] = []
removed_dupes = 0
for msg in messages:
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
kept_tcs = []
for tc in msg.get("tool_calls") or []:
cid = _ra().AIAgent._get_tool_call_id_static(tc)
if cid and cid in seen_assistant_call_ids:
removed_dupes += 1
continue
if cid:
seen_assistant_call_ids.add(cid)
kept_tcs.append(tc)
if len(kept_tcs) != len(msg.get("tool_calls") or []):
msg = {**msg, "tool_calls": kept_tcs}
deduped.append(msg)
elif role == "tool":
cid = (msg.get("tool_call_id") or "").strip()
if cid and cid in seen_result_call_ids:
removed_dupes += 1
continue
if cid:
seen_result_call_ids.add(cid)
deduped.append(msg)
else:
deduped.append(msg)
if removed_dupes:
messages = deduped
_ra().logger.debug(
"Pre-call sanitizer: removed %d duplicate tool_call_id reference(s)",
removed_dupes,
)
return messages

View file

@ -1045,7 +1045,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
data=data,
headers={
"Content-Type": content_type,
"User-Agent": f"claude-code/{_get_claude_code_version()} (external, cli)",
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
},
method="POST",
)
@ -1378,9 +1378,20 @@ _OAUTH_TOKEN_URLS = [
"https://console.anthropic.com/v1/oauth/token",
]
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
# User-Agent sent on the OAuth *token endpoint* (login exchange + refresh).
# Anthropic rate-limits (HTTP 429) any token-endpoint request whose UA starts
# with ``claude-code/`` — verified empirically against platform.claude.com:
# ``claude-code/2.1.200`` and ``Mozilla/5.0`` -> 429; ``axios/*``, ``node``,
# and SDK-style UAs -> 400 (reached code validation). The real Claude Code CLI
# exchanges the auth code with a bare axios client (``axios/<ver>``), NOT its
# ``claude-code/`` inference UA. We mirror that here. NOTE: the *inference* path
# (build_anthropic_kwargs) still uses the ``claude-code/`` UA + ``x-app: cli`` —
# that fingerprint is required there and is NOT throttled on the messages API.
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
def _get_hermes_oauth_file() -> Path:
return get_hermes_home() / ".anthropic_oauth.json"
def _generate_pkce() -> tuple:
@ -1478,8 +1489,9 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
# console.anthropic.com now 404s. Try the new host first, then fall
# back to console for older deployments (mirrors the refresh path).
# Use the claude-code/ UA prefix: Anthropic blocks claude-cli/ on the
# OAuth token endpoint (returns 404 for all versions).
# UA is _OAUTH_TOKEN_USER_AGENT (a non-claude-code UA) — see the
# constant's definition for why the token endpoint must not send
# claude-code/ (429 UA-prefix block).
result = None
last_error = None
for endpoint in _OAUTH_TOKEN_URLS:
@ -1488,7 +1500,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
data=exchange_data,
headers={
"Content-Type": "application/json",
"User-Agent": f"claude-code/{_get_claude_code_version()} (external, cli)",
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
},
method="POST",
)
@ -1527,9 +1539,10 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
if _HERMES_OAUTH_FILE.exists():
oauth_file = _get_hermes_oauth_file()
if oauth_file.exists():
try:
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
data = json.loads(oauth_file.read_text(encoding="utf-8"))
if data.get("accessToken"):
return data
except (json.JSONDecodeError, OSError, IOError) as e:

View file

@ -314,33 +314,64 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
return bare == "trinity-large-thinking"
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5.
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
# chatgpt.com/backend-api/codex/responses is rejected with
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
# default 50% compaction trigger fires at ~136K — wasteful, since the model
# can hold far more raw context before summarization actually buys anything.
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
# sessions use the window they actually have.
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/
# gpt-5.5 sessions use the window they actually have.
_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
# native 128K context window. The default 50% compaction trigger fires at
# ~64K — wasting half the usable window, often before the session has enough
# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so
# spark sessions use more of the window before summarization, while still
# leaving ~38K headroom for the summary and continued conversation before
# the 128K hard limit.
_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend.
Matches only the Codex OAuth route (provider ``openai-codex``), not the
direct OpenAI API, OpenRouter, or GitHub Copilot paths those expose a
larger context window for the same slug and must keep the user's default
compaction threshold. ``gpt-5.5-pro`` and dated snapshots
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
family without re-listing every variant.
compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots
are matched via prefix so the override tracks both 272K-capped families
without re-listing every variant.
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
return False
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
return (
bare == "gpt-5.4"
or bare.startswith("gpt-5.4-")
or bare.startswith("gpt-5.4.")
or bare == "gpt-5.5"
or bare.startswith("gpt-5.5-")
or bare.startswith("gpt-5.5.")
)
def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend.
The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native
128K context window. Only the Codex OAuth route (provider
``openai-codex``) is matched the slug is not available on other
routes.
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
return False
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
return bare == "gpt-5.3-codex-spark"
def _fixed_temperature_for_model(
@ -379,18 +410,26 @@ def _compression_threshold_for_model(
Per-model/route overrides:
- Arcee Trinity Large Thinking 0.75 (preserve reasoning context).
- gpt-5.5 on the Codex OAuth route 0.85, because Codex caps the window
at 272K and the default 50% trigger would compact at ~136K. Gated by
``allow_codex_gpt55_autoraise`` so the user can opt back down to the
global default (the caller passes the config flag through here).
- gpt-5.4 / gpt-5.5 on the Codex OAuth route 0.85, because Codex caps
both families at 272K and the default 50% trigger would compact at
~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key
name kept for backward compatibility) so the user can opt back down to
the global default (the caller passes the config flag through here).
- gpt-5.3-codex-spark on the Codex OAuth route 0.70, because the model
has a native 128K window and the default 50% trigger would compact at
~64K wasting half the usable context. Not gated by the gpt-5.5
opt-out flag: 128K is the model's native window, so the raise is
unambiguously correct.
Returns a float in (0, 1] to override the global ``compression.threshold``
config value, or ``None`` to leave the user's config value unchanged.
"""
if _is_arcee_trinity_thinking(model):
return 0.75
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
return _CODEX_GPT55_COMPACTION_THRESHOLD
if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider):
return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD
if _is_codex_spark(model, provider):
return _CODEX_SPARK_COMPACTION_THRESHOLD
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
@ -1317,6 +1356,96 @@ class AsyncAnthropicAuxiliaryClient:
self._real_client = sync_wrapper._real_client
class _BedrockCompletionsAdapter:
"""Translates ``chat.completions.create(**kwargs)`` into Bedrock Converse."""
def __init__(self, region: str, model: str):
self._region = region
self._model = model
def create(self, **kwargs) -> Any:
from agent.bedrock_adapter import call_converse
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
# OpenAI accepts ``stop`` as str or list; Converse requires a list.
stop = kwargs.get("stop")
if isinstance(stop, str):
stop = [stop]
if kwargs.get("tool_choice") is not None:
# Converse's toolChoice isn't wired through call_converse();
# no in-tree auxiliary caller passes tool_choice today. Surface
# the drop instead of silently ignoring it.
logger.debug(
"BedrockAuxiliaryClient: tool_choice=%r not supported by the "
"Converse shim — ignored.", kwargs.get("tool_choice"),
)
if kwargs.get("stream"):
# Converse streaming isn't wired through this shim. Return a
# complete response instead — call_llm's streaming consumer
# detects a final object and downgrades to non-live output.
logger.debug(
"BedrockAuxiliaryClient: stream=True requested for %s"
"returning a complete response (Converse shim does not "
"stream); caller downgrades to non-streaming.",
model,
)
return call_converse(
region=self._region,
model=model,
messages=messages,
tools=kwargs.get("tools"),
max_tokens=int(max_tokens) if max_tokens else 4096,
temperature=kwargs.get("temperature"),
top_p=kwargs.get("top_p"),
stop_sequences=stop,
)
class _BedrockChatShim:
def __init__(self, adapter: "_BedrockCompletionsAdapter"):
self.completions = adapter
class BedrockAuxiliaryClient:
"""OpenAI-client-compatible wrapper over AWS Bedrock Converse API."""
def __init__(self, region: str, model: str):
self._region = region
self._model = model
adapter = _BedrockCompletionsAdapter(region, model)
self.chat = _BedrockChatShim(adapter)
self.api_key = "aws-sdk"
self.base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
def close(self):
pass
class _AsyncBedrockCompletionsAdapter:
def __init__(self, sync_adapter: _BedrockCompletionsAdapter):
self._sync = sync_adapter
async def create(self, **kwargs) -> Any:
import asyncio
return await asyncio.to_thread(self._sync.create, **kwargs)
class _AsyncBedrockChatShim:
def __init__(self, adapter: _AsyncBedrockCompletionsAdapter):
self.completions = adapter
class AsyncBedrockAuxiliaryClient:
def __init__(self, sync_wrapper: "BedrockAuxiliaryClient"):
sync_adapter = sync_wrapper.chat.completions
async_adapter = _AsyncBedrockCompletionsAdapter(sync_adapter)
self.chat = _AsyncBedrockChatShim(async_adapter)
self.api_key = sync_wrapper.api_key
self.base_url = sync_wrapper.base_url
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
protocol instead of OpenAI chat.completions.
@ -1372,6 +1501,8 @@ def _maybe_wrap_anthropic(
# Already wrapped — don't double-wrap.
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
return client_obj
# Other specialized adapters we should never re-dispatch.
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
@ -2003,6 +2134,76 @@ def _read_main_provider() -> str:
return ""
def _read_main_api_key() -> str:
"""Read the user's main model API key from the runtime override or config.
Mirrors ``_read_main_model`` / ``_read_main_provider``: checks the
process-local ``_RUNTIME_MAIN_API_KEY`` override first (set by
``set_runtime_main`` when an AIAgent is active), then falls back to
``model.api_key`` in config.yaml.
Used by the ``custom`` provider fallback chain so that auxiliary tasks
configured with an explicit ``base_url`` but empty ``api_key`` inherit
the main model's credentials instead of falling to ``no-key-required``
(issue #9318).
"""
override = _RUNTIME_MAIN_API_KEY
if isinstance(override, str) and override.strip():
return override.strip()
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
key = model_cfg.get("api_key", "")
if isinstance(key, str) and key.strip():
return key.strip()
except Exception:
pass
return ""
def _read_main_base_url() -> str:
"""Read the main model's base_url from the runtime override or config.
Same override-then-config pattern as ``_read_main_api_key``.
"""
override = _RUNTIME_MAIN_BASE_URL
if isinstance(override, str) and override.strip():
return override.strip()
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
base = model_cfg.get("base_url", "")
if isinstance(base, str) and base.strip():
return base.strip()
except Exception:
pass
return ""
def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
"""Return the main api_key only when *aux_base_url* points at the same
host as the main model's base_url.
The #9318 use case is an auxiliary task sharing the main model's
self-hosted gateway (same host, different model) with an empty per-task
api_key. Inheriting unconditionally would send the main credential to
ANY host a misconfigured aux base_url names a cross-host credential
leak. A host mismatch keeps the previous fail-safe behavior
(``no-key-required`` 401).
"""
aux_host = base_url_hostname(aux_base_url)
if not aux_host:
return ""
main_host = base_url_hostname(_read_main_base_url())
if not main_host or aux_host != main_host:
return ""
return _read_main_api_key()
# Process-local override set by AIAgent at session/turn start. Single-threaded
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
_RUNTIME_MAIN_PROVIDER: str = ""
@ -2392,11 +2593,19 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
return None, None
pool_present, entry = _select_pool_entry("anthropic")
if pool_present:
if entry is None:
return None, None
if pool_present and entry is not None:
token = explicit_api_key or _pool_runtime_api_key(entry)
else:
# Pool absent, OR pool present but no usable entry (expired token +
# stale refresh_token, all entries exhausted, etc). Fall through to the
# legacy resolver instead of hard-failing: a temporarily dead pool
# entry must not wedge auxiliary tasks when a valid standalone
# credential (ANTHROPIC_TOKEN, credentials file, API key) exists. This
# matches the openrouter and codex paths, which already fall back to
# their env/auth-store credential on (True, None). Without this, the
# goal judge and every other Anthropic-routed side channel died with
# "no auxiliary client configured" while the main session stayed
# healthy (it resolves the env token directly).
entry = None
token = explicit_api_key or resolve_anthropic_token()
if not token:
@ -3271,6 +3480,21 @@ def _refresh_provider_credentials(provider: str) -> bool:
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
normalized = _normalize_aux_provider(provider)
try:
if normalized == "copilot":
from hermes_cli.copilot_auth import (
_jwt_cache,
_token_fingerprint,
exchange_copilot_token,
resolve_copilot_token,
)
raw_token, _source = resolve_copilot_token()
if not str(raw_token or "").strip():
return False
_jwt_cache.pop(_token_fingerprint(raw_token), None)
exchange_copilot_token(raw_token)
_evict_cached_clients(normalized)
return True
if normalized == "openai-codex":
from hermes_cli.auth import resolve_codex_runtime_credentials
@ -3325,6 +3549,152 @@ def _refresh_provider_credentials(provider: str) -> bool:
return False
def _auth_refresh_provider_for_route(
resolved_provider: Optional[str],
client_base_url: str,
) -> str:
"""Return the provider whose short-lived credentials should be refreshed.
Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even
after _get_cached_client() selects a concrete backend. Infer the backend
from the selected client's base URL so auth refresh works for auto →
Copilot/Codex/Anthropic/Nous routes too. (#20832)
"""
normalized = _normalize_aux_provider(resolved_provider)
if normalized and normalized != "auto":
return normalized
if base_url_host_matches(client_base_url, "api.githubcopilot.com"):
return "copilot"
if base_url_host_matches(client_base_url, "chatgpt.com"):
return "openai-codex"
if base_url_host_matches(client_base_url, "api.anthropic.com"):
return "anthropic"
if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"):
return "nous"
return normalized
def _call_fallback_candidate_sync(
fb_client: Any,
fb_model: Optional[str],
fb_label: str,
*,
task: Optional[str],
messages: list,
temperature: Optional[float],
max_tokens: Optional[int],
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
) -> Optional[Any]:
"""Call one fallback candidate with stale-credential recovery.
A fallback candidate can itself carry a stale credential (e.g. an expired
``ANTHROPIC_TOKEN`` picked up by ``_try_anthropic``). Before this helper,
such a 401 propagated out of the fallback site and aborted the auxiliary
task (for compression: a 60s cooldown + context marker) even though other
healthy candidates remained. Live case: a Codex-timeout Anthropic
fallback 401-looped five times in one session (mattalachia debug dump,
Jul 2026).
On an auth error: refresh the candidate's provider credentials and retry
once with a rebuilt client; if the retry also auth-fails (non-refreshable
expired token), mark the provider unhealthy and return ``None`` so the
caller can continue to the next fallback layer. Non-auth errors raise.
"""
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
retry_client, retry_model = _get_cached_client(fb_provider, fb_model)
if retry_client is not None:
retry_kwargs = _build_call_kwargs(
fb_provider, retry_model or fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
# Refresh unavailable or the refreshed credential still 401s —
# the token is dead (expired setup token with no refresh token).
# Quarantine the candidate so subsequent chain walks skip it, and
# let the caller move on instead of aborting the whole task.
_mark_provider_unhealthy(fb_provider or fb_label)
logger.warning(
"Auxiliary %s: fallback candidate %s has a stale/unrefreshable "
"credential (%s) — skipping to next fallback",
task or "call", fb_label, fb_err,
)
return None
async def _call_fallback_candidate_async(
fb_client: Any,
fb_model: Optional[str],
fb_label: str,
*,
task: Optional[str],
messages: list,
temperature: Optional[float],
max_tokens: Optional[int],
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
) -> Optional[Any]:
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
retry_client, retry_model = _get_cached_client(
fb_provider, fb_model, async_mode=True)
if retry_client is not None:
retry_kwargs = _build_call_kwargs(
fb_provider, retry_model or fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
_mark_provider_unhealthy(fb_provider or fb_label)
logger.warning(
"Auxiliary %s (async): fallback candidate %s has a stale/unrefreshable "
"credential (%s) — skipping to next fallback",
task or "call", fb_label, fb_err,
)
return None
def _try_payment_fallback(
failed_provider: str,
task: str = None,
@ -3926,6 +4296,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
if isinstance(sync_client, BedrockAuxiliaryClient):
return AsyncBedrockAuxiliaryClient(sync_client), model
try:
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
@ -4073,7 +4445,15 @@ def resolve_provider_client(
# main_model also empty), the branches still hit their own
# missing-credentials returns and ``_resolve_auto`` falls through to
# the Step-2 chain as before.
if not model:
#
# Prefer explicit caller model, then provider-scoped aux model, then main model.
# Do NOT pre-fill a blank ``auto`` request from the config/main default here.
# ``auto`` has its own main-runtime resolver below; pre-filling first can pair
# a stale configured model with a live fallback provider (e.g. Claude model
# sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto()
# return the actual current runtime model when the caller did not explicitly
# request one. (# compression-current-model)
if not model and provider != "auto":
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
@ -4229,11 +4609,14 @@ def resolve_provider_client(
# ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
if provider == "custom":
custom_base = ""
custom_key = ""
if explicit_base_url:
custom_base = _to_openai_base_url(explicit_base_url).strip()
custom_key = (
(explicit_api_key or "").strip()
or os.getenv("OPENAI_API_KEY", "").strip()
or _read_main_api_key_if_same_host(custom_base)
or "no-key-required" # local servers don't need auth
)
if not custom_base:
@ -4242,6 +4625,19 @@ def resolve_provider_client(
"but base_url is empty"
)
return None, None
elif main_runtime:
# When main_runtime carries a concrete base_url + api_key for a
# named custom provider (custom:<name>), use it directly instead
# of re-resolving from the bare "custom" provider name.
# Re-resolution loses the provider name and falls back to
# OpenRouter or a wrong API-key provider — the main agent already
# solved this, we just need to reuse its answer. (#45472)
_main_base = str(main_runtime.get("base_url") or "").strip().rstrip("/")
_main_key = str(main_runtime.get("api_key") or "").strip()
if _main_base and _main_key:
custom_base = _main_base
custom_key = _main_key
if custom_base and custom_key:
final_model = _normalize_resolved_model(
model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini",
provider,
@ -4641,10 +5037,14 @@ def resolve_provider_client(
else (client, final_model))
elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
# SDK (prompt caching, thinking); non-Claude models use Converse API.
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
from agent.bedrock_adapter import (
has_aws_credentials,
is_anthropic_bedrock_model,
resolve_bedrock_region,
)
from agent.anthropic_adapter import build_anthropic_bedrock_client
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
@ -4659,17 +5059,26 @@ def resolve_provider_client(
region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = _normalize_resolved_model(model or default_model, provider)
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
)
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
if is_anthropic_bedrock_model(final_model):
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=base_url,
)
logger.debug("resolve_provider_client: bedrock anthropic (%s, %s)",
final_model, region)
else:
client = BedrockAuxiliaryClient(region, final_model)
logger.debug("resolve_provider_client: bedrock converse (%s, %s)",
final_model, region)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
@ -4940,9 +5349,35 @@ def resolve_vision_provider_client(
main_provider,
)
else:
# Custom endpoints (``custom`` / ``custom:<name>``) carry no
# built-in base_url/api_key — resolve_provider_client("custom")
# would return None ("no endpoint credentials found") and the
# whole chain would fall through to the aggregators, breaking
# vision for every user on a custom provider that has no
# separate ``auxiliary.vision`` block. Recover the live main
# endpoint that ``set_runtime_main()`` recorded for this turn so
# Step 1 can build a working client.
rpc_base_url = None
rpc_api_key = None
rpc_api_mode = resolved_api_mode
if main_provider == "custom" or main_provider.startswith("custom:"):
if _RUNTIME_MAIN_BASE_URL:
rpc_base_url = _RUNTIME_MAIN_BASE_URL
rpc_api_key = _RUNTIME_MAIN_API_KEY or None
rpc_api_mode = resolved_api_mode or _RUNTIME_MAIN_API_MODE or None
else:
# No live runtime recorded (non-gateway caller): fall
# back to resolving the configured custom endpoint.
custom_base, custom_key, custom_mode = _resolve_custom_runtime()
if custom_base:
rpc_base_url = custom_base
rpc_api_key = custom_key
rpc_api_mode = resolved_api_mode or custom_mode or None
rpc_client, rpc_model = resolve_provider_client(
main_provider, vision_model,
api_mode=resolved_api_mode,
api_mode=rpc_api_mode,
explicit_base_url=rpc_base_url,
explicit_api_key=rpc_api_key,
is_vision=True)
if rpc_client is not None:
logger.info(
@ -5498,6 +5933,18 @@ def _resolve_task_provider_model(
if cfg_provider:
cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url)
# An explicit provider arg without an explicit base_url must not bypass
# the task's configured endpoint: adopt auxiliary.<task>.base_url/api_key
# when the config targets the same provider (or names none), so the
# early `if provider:` return below carries the configured endpoint
# instead of falling through to main-runtime resolution (#58515).
# An explicit "auto" is excluded — it means "inherit / auto-detect" and
# must keep flowing through the existing auto-resolution chain.
if provider and provider != "auto" and not base_url and cfg_base_url and cfg_provider in (None, provider):
base_url = cfg_base_url
if not api_key:
api_key = cfg_api_key
if base_url and _preserve_provider_with_base_url(provider):
return provider, resolved_model, base_url, api_key, resolved_api_mode
if base_url:
@ -5525,6 +5972,17 @@ def _resolve_task_provider_model(
_DEFAULT_AUX_TIMEOUT = 30.0
# Compression summarises large conversation histories; a reasoning auxiliary
# model (e.g. Codex / GPT-5.5) can legitimately take longer than the default
# ``auxiliary.compression.timeout`` (120 s), causing the stream to time out and
# the compressor to fall back to the deterministic context marker (#54915).
# This is a bounded *floor* applied only to config-derived compression timeouts
# — it does not affect other auxiliary tasks and does not override an explicit
# per-call ``timeout=``. A floor is harmless for fast compression models
# (they finish before the deadline) and is a minimum, so a higher config value
# is kept unchanged.
_COMPRESSION_TIMEOUT_FLOOR_SECONDS = 300.0
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
"""Return the config dict for auxiliary.<task>, or {} when unavailable.
@ -5584,6 +6042,23 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
return default
def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
"""Resolve the effective timeout for an auxiliary LLM call.
Uses the caller-provided ``timeout`` when given; otherwise reads
``auxiliary.{task}.timeout`` from config via :func:`_get_task_timeout`.
For the ``compression`` task only, applies a bounded floor so a reasoning
model summarising a large context is not cut off by the default timeout
(#54915). The floor is intentionally skipped when the caller passes an
explicit ``timeout=`` explicit per-call deadlines are always honoured
and it is a minimum (``max``), so a config value already above it is kept.
"""
effective = timeout if timeout is not None else _get_task_timeout(task)
if timeout is None and task == "compression":
effective = max(effective, _COMPRESSION_TIMEOUT_FLOOR_SECONDS)
return effective
def _get_task_extra_body(task: str) -> Dict[str, Any]:
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
task_config = _get_auxiliary_task_config(task)
@ -5902,7 +6377,7 @@ def call_llm(
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: float = None,
temperature: Optional[float] = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
@ -6018,7 +6493,7 @@ def call_llm(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
f"Run: hermes setup")
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
effective_timeout = _effective_aux_timeout(task, timeout)
# Log what we're about to do — makes auxiliary operations visible
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
@ -6257,18 +6732,24 @@ def call_llm(
refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry ───────────────────────────────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider, _base_info)
if (_is_auth_error(first_err)
and resolved_provider not in {"auto", "", None}
and auth_refresh_provider not in {"auto", "", None}
and not client_is_nous):
if _refresh_provider_credentials(resolved_provider):
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
# The stale client is cached under the route label
# (e.g. "auto"), not the concrete backend we refreshed.
_evict_cached_clients(resolved_provider)
logger.info(
"Auxiliary %s: refreshed %s credentials after auth error, retrying",
task or "call", resolved_provider,
task or "call", auth_refresh_provider,
)
return _retry_same_provider_sync(
task=task,
resolved_provider=resolved_provider,
resolved_model=resolved_model,
resolved_provider=auth_refresh_provider,
resolved_model=resolved_model or final_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,
@ -6438,14 +6919,28 @@ def call_llm(
resolved_provider, task, reason=reason)
if fb_client is not None:
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
fb_resp = _call_fallback_candidate_sync(
fb_client, fb_model, fb_label,
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(fb_client, "base_url", "") or ""))
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
# The candidate had a stale/unrefreshable credential and was
# quarantined — walk the discovery chain once more; unhealthy
# entries are skipped so the next viable candidate serves.
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason="stale fallback credential")
if fb_client is not None:
fb_resp = _call_fallback_candidate_sync(
fb_client, fb_model, fb_label,
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
# All fallback layers exhausted — emit a single user-visible
# warning so the operator knows aux task is about to fail.
# (#26882) The error itself is re-raised below.
@ -6533,7 +7028,7 @@ async def async_call_llm(
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: float = None,
temperature: Optional[float] = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
@ -6607,7 +7102,7 @@ async def async_call_llm(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
f"Run: hermes setup")
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
effective_timeout = _effective_aux_timeout(task, timeout)
# Pass the client's actual base_url (not just resolved_base_url) so
# endpoint-specific temperature overrides can distinguish
@ -6785,18 +7280,24 @@ async def async_call_llm(
await refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider, _client_base)
if (_is_auth_error(first_err)
and resolved_provider not in {"auto", "", None}
and auth_refresh_provider not in {"auto", "", None}
and not client_is_nous):
if _refresh_provider_credentials(resolved_provider):
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
# The stale client is cached under the route label
# (e.g. "auto"), not the concrete backend we refreshed.
_evict_cached_clients(resolved_provider)
logger.info(
"Auxiliary %s (async): refreshed %s credentials after auth error, retrying",
task or "call", resolved_provider,
task or "call", auth_refresh_provider,
)
return await _retry_same_provider_async(
task=task,
resolved_provider=resolved_provider,
resolved_model=resolved_model,
resolved_provider=auth_refresh_provider,
resolved_model=resolved_model or final_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,
@ -6923,20 +7424,34 @@ async def async_call_llm(
resolved_provider, task, reason=reason)
if fb_client is not None:
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(fb_client, "base_url", "") or ""))
# Convert sync fallback client to async
async_fb, async_fb_model = _to_async_client(
fb_client, fb_model or "", is_vision=(task == "vision")
)
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
fb_kwargs["model"] = async_fb_model
return _validate_llm_response(
await async_fb.chat.completions.create(**fb_kwargs), task)
fb_resp = await _call_fallback_candidate_async(
async_fb, async_fb_model or fb_model, fb_label,
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
# Stale/unrefreshable candidate credential — quarantined; walk
# the discovery chain once more (unhealthy entries skipped).
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason="stale fallback credential")
if fb_client is not None:
async_fb, async_fb_model = _to_async_client(
fb_client, fb_model or "", is_vision=(task == "vision")
)
fb_resp = await _call_fallback_candidate_async(
async_fb, async_fb_model or fb_model, fb_label,
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
# All fallback layers exhausted — warn before re-raising. (#26882)
logger.warning(
"Auxiliary %s (async): %s on %s and all fallbacks exhausted "

View file

@ -449,10 +449,21 @@ def summarize_background_review_actions(
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
continue
# ``data`` may not be a dict — some memory/skill tool responses in
# older codepaths or wrapper MCP servers return a top-level JSON
# list (e.g. ``[{"success": true, ...}]``) or a scalar. The original
# isinstance check below silently skips non-dict payloads, which
# is correct, but ``data.get("_change")`` further down can still
# hand back a list and break ``change.get("description", "")``.
# Defensively normalize everything through a dict-typed alias so
# the rest of the function can stay terse without per-call
# ``isinstance`` guards (#59437).
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
detail = call_details.get(tcid, {})
detail = call_details.get(tcid) or {}
if not isinstance(detail, dict):
detail = {}
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
@ -480,12 +491,30 @@ def summarize_background_review_actions(
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
operations = detail.get("operations") or []
# ``operations`` may be anything callable put into the JSON
# arguments. Anything non-iterable that isn't a list[str]
# of dicts becomes unusable here, so coerce defensively.
ops_raw = detail.get("operations")
operations: list = (
ops_raw if isinstance(ops_raw, list) else []
)
max_preview = 120
if is_skill:
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
# ``_change`` is a free-form dict the skill tool leaves in
# the response. Older / wrapper MCP backends return it
# as a list, an int, or a JSON-shaped scalar — normalize
# to a dict so the .get() calls downstream don't
# AttributeError (#59437).
change_raw = data.get("_change")
change: dict = (
change_raw if isinstance(change_raw, dict) else {}
)
old_string = (
change.get("old", "") or detail.get("old_string", "")
)
new_string = (
change.get("new", "") or detail.get("new_string", "")
)
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
@ -506,7 +535,13 @@ def summarize_background_review_actions(
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
op = op or {}
# Each element must be a dict-of-fields; some
# legacy codepaths serialize the entry as a bare
# string and the message dict doesn't exist. Skip
# non-dict items defensively — they have no
# actionable fields anyway (#59437).
if not isinstance(op, dict):
continue
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
@ -819,11 +854,29 @@ def _run_review_in_thread(
# the review agent inherits that history and would otherwise
# re-surface stale "created"/"updated" messages from the prior
# conversation as if they just happened (issue #14944).
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
#
# Wrapped in try/except: a buggy/legacy tool response shape
# (e.g. ``_change`` returned as a list instead of a dict, #59437)
# must NOT take down the whole review with an AttributeError,
# since the caller's outer except logs only "Background
# memory/skill review failed" and discards every successful
# action the fork DID complete before the crash. Coerce an
# exception into an empty actions list so the partial valid
# actions from earlier in the messages are returned instead.
try:
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
except Exception as e:
logger.warning(
"summarize_background_review_actions returned partial results "
"after exception (treating as empty); suppressing AttributeError "
"that previously aborted the entire review (#59437): %s",
e,
)
actions = []
if actions:
summary = " · ".join(dict.fromkeys(actions))

148
agent/bounded_response.py Normal file
View file

@ -0,0 +1,148 @@
"""Bounded reads of HTTP error response bodies.
When a provider returns a non-OK status on a *streaming* request, Hermes reads
the response body to build a useful diagnostic error. A bare ``response.read()``
on a streaming httpx response is unbounded in two dangerous ways:
1. A server can declare (or stream) an arbitrarily large body, so the read can
balloon memory.
2. A server can open the body and then stall forever (no ``Content-Length``,
no further bytes), so the read hangs the agent indefinitely.
Both are realistic against a misbehaving proxy, a hijacked endpoint, or a
provider having a bad day. The diagnostic body is only ever shown to the user
truncated to a few hundred characters, so reading megabytes or blocking
forever buys nothing.
``read_streaming_error_body`` bounds the read to a byte cap and enforces a
hard wall-clock deadline, returning the decoded text snippet. Callers pass the
returned text into their existing error builders instead of touching
``response.text`` (which would be unbounded / would raise after a partial
stream read).
A subtlety the implementation must respect: ``httpx``'s ``iter_bytes()`` blocks
*inside* the C/socket read while waiting for the next chunk. A wall-clock check
placed only between yielded chunks cannot interrupt a server that opens the
body and then stalls mid-chunk control never returns to Python until httpx's
own (often 30s+) read timeout fires. To guarantee a bounded stop regardless of
socket behavior, the read runs on a daemon worker thread and the caller waits
on it with a hard deadline; on timeout we close the response (which unblocks /
cancels the read) and return whatever partial bytes were collected.
Ported and adapted from openclaw/openclaw#95108 ("bound Anthropic error
streams"), generalized to cover Hermes's three streaming error-body sites
(native Gemini, Gemini Cloud Code, Antigravity Cloud Code).
"""
from __future__ import annotations
import logging
import threading
from typing import List, Optional
import httpx
logger = logging.getLogger(__name__)
# Defaults chosen to comfortably hold any real provider error envelope (Google
# RPC error JSON, Anthropic error JSON) while rejecting pathological bodies.
DEFAULT_ERROR_BODY_MAX_BYTES = 64 * 1024
# Hard wall-clock deadline for the whole bounded read. A streaming error body
# that does not finish within this window is abandoned and the connection is
# closed; we keep whatever partial bytes arrived.
DEFAULT_ERROR_BODY_TIMEOUT_S = 10.0
def read_streaming_error_body(
response: httpx.Response,
*,
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
) -> str:
"""Read a non-OK streaming response body with a byte cap and a hard deadline.
Returns the decoded body text (UTF-8, errors replaced), truncated to
``max_bytes``. Never raises: any transport error, stall, or oversize
condition is swallowed and the best-effort partial text (or an empty
string) is returned, because this runs on the error path and must not
mask the original HTTP failure with a read error.
The byte cap protects against huge bodies; the wall-clock deadline (enforced
via a worker thread so it can interrupt a socket read that stalls mid-chunk)
protects against bodies that open and then hang.
"""
chunks: List[bytes] = []
state = {"truncated": False}
done = threading.Event()
def _drain() -> None:
total = 0
try:
for chunk in response.iter_bytes():
if not chunk:
continue
remaining = max_bytes - total
if remaining <= 0:
state["truncated"] = True
break
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
total += remaining
state["truncated"] = True
break
chunks.append(chunk)
total += len(chunk)
except Exception as exc: # noqa: BLE001 - error path must not raise
logger.debug("bounded error-body read failed: %s", exc)
finally:
done.set()
worker = threading.Thread(
target=_drain, name="bounded-error-body-read", daemon=True
)
worker.start()
finished = done.wait(timeout=timeout_s)
if not finished:
logger.debug(
"bounded error-body read: hard timeout after %.1fs (%d bytes so far)",
timeout_s,
sum(len(c) for c in chunks),
)
# Closing the response cancels the in-flight socket read, letting the
# worker thread unwind. We do not join (it is a daemon and may be
# blocked in C); the partial `chunks` collected so far are returned.
_safe_close(response)
else:
_safe_close(response)
if state["truncated"]:
logger.debug(
"bounded error-body read: capped at %d bytes (max=%d)",
sum(len(c) for c in chunks),
max_bytes,
)
return b"".join(chunks).decode("utf-8", errors="replace")
def _safe_close(response: httpx.Response) -> None:
try:
response.close()
except Exception: # noqa: BLE001
pass
def read_error_body_or_default(
response: httpx.Response,
*,
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
) -> Optional[str]:
"""Like ``read_streaming_error_body`` but returns ``None`` on empty body.
Convenience for callers that distinguish "no body" from "empty string".
"""
text = read_streaming_error_body(
response, max_bytes=max_bytes, timeout_s=timeout_s
)
return text or None

View file

@ -171,6 +171,52 @@ def _env_float(name: str, default: float) -> float:
return default
# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
# A session wedged against an unresponsive provider hits the stale detector
# on every call and loops forever (observed: 494 consecutive failures over
# 3+ days, each burning the full stale timeout × retries with no response).
# The agent carries ``_consecutive_stale_streams``: incremented on every
# stale kill, reset only when a call actually completes (or when the
# provider is swapped — switch_model / try_activate_fallback /
# restore_primary_runtime — since the streak measured the OLD provider).
# Past the give-up threshold, calls abort immediately with an actionable
# error instead of re-waiting out the stale timeout.
def _stale_streak(agent) -> int:
try:
return int(getattr(agent, "_consecutive_stale_streams", 0) or 0)
except Exception:
return 0
def _bump_stale_streak(agent) -> None:
try:
agent._consecutive_stale_streams = _stale_streak(agent) + 1
except Exception:
pass
def _reset_stale_streak(agent) -> None:
try:
agent._consecutive_stale_streams = 0
except Exception:
pass
def _check_stale_giveup(agent) -> None:
"""Raise immediately when the consecutive-stale streak is past the
give-up threshold no network attempt, no stale-timeout wait."""
_giveup = env_int("HERMES_STREAM_STALE_GIVEUP", 5)
_streak = _stale_streak(agent)
if _giveup > 0 and _streak >= _giveup:
raise RuntimeError(
"Provider has been unresponsive (no response received) for "
f"{_streak} consecutive stale attempts — aborting this call to "
"avoid an indefinite stall. Switch models or start a new "
"session, then retry."
)
def interruptible_api_call(agent, api_kwargs: dict):
"""
Run the API call in a background thread so the main conversation loop
@ -186,6 +232,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
provider fallback.
"""
result = {"response": None, "error": None}
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
# of the guard in interruptible_streaming_api_call. Quiet-mode /
# subagent / no-stream-consumer sessions take THIS path, and a wedged
# unattended session here has the same infinite stale-retry class.
_check_stale_giveup(agent)
request_client_holder = {"client": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
@ -557,6 +610,9 @@ def interruptible_api_call(agent, api_kwargs: dict):
_close_request_client_once("stale_call_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
agent._touch_activity(
f"stale non-streaming call killed after {int(_elapsed)}s"
)
@ -599,6 +655,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
raise InterruptedError("Agent interrupted during API call")
if result["error"] is not None:
raise result["error"]
# Success — clear the circuit breaker (#58962): the provider proved
# responsive. See the canonical comment block above ``_stale_streak()``.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
@ -1488,6 +1548,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
"Fallback activated: %s%s (%s)",
old_model, fb_model, fb_provider,
)
# Reset the stale-call circuit breaker (#58962): the streak measured
# the OLD provider's unresponsiveness. Carrying it over would
# short-circuit the freshly activated fallback before it gets a
# single stream attempt.
_reset_stale_streak(agent)
return True
except Exception as e:
if fb_provider == "nous":
@ -1525,8 +1590,10 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
# hand-builds messages and calls chat.completions.create() directly,
# bypassing the transport — so mirror that sanitization here:
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
# timestamp (preserved on gateway user replay entries for the
# stale-confirmation expiry check — #47868 rejection class),
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
api_msg.pop(schema_foreign, None)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
@ -1879,11 +1946,27 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
t.join(timeout=0.3)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call")
# Worker exited before the poll loop observed the interrupt flag. The
# Bedrock stream callback breaks out and returns a PARTIAL response
# without raising on interrupt (see bedrock_adapter.py
# stream_converse_with_callbacks / on_interrupt_check), so result[
# "response"] is populated with error=None and the in-loop raise above
# never fires. Re-check here so /stop is not silently swallowed on the
# Bedrock path — mirrors the post-worker guard on the main streaming
# loop. (#59999 area)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
if result["error"] is not None:
raise result["error"]
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
# Cross-turn stale-stream circuit breaker (#58962) — see the canonical
# comment block above ``_stale_streak()``. Raises past the give-up
# threshold instead of burning another stale-timeout×retries cycle.
_check_stale_giveup(agent)
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
@ -2194,15 +2277,23 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
idx = _active_slot_by_idx[raw_idx]
if idx not in tool_calls_acc:
# Poolside may send integer id instead of string
_tc_id = tc_delta.id
if isinstance(_tc_id, int):
_tc_id = str(_tc_id)
tool_calls_acc[idx] = {
"id": tc_delta.id or "",
"id": _tc_id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
"extra_content": None,
}
entry = tool_calls_acc[idx]
if tc_delta.id:
entry["id"] = tc_delta.id
if tc_delta.id is not None:
_new_id = tc_delta.id
if isinstance(_new_id, int):
_new_id = str(_new_id)
if _new_id:
entry["id"] = _new_id
if tc_delta.function:
if tc_delta.function.name:
# Use assignment, not +=. Function names are
@ -2853,6 +2944,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_close_request_client_once("stale_stream_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
@ -2892,6 +2986,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")
# Worker thread exited before the main thread's poll loop could check
# the interrupt flag. If the worker returned early due to an interrupt
# (e.g. _call_anthropic() detected _interrupt_requested and returned
# None), the InterruptedError above was never raised. Re-check the
# flag here so /stop is not silently swallowed. (#59999 area)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during streaming API call (post-worker)")
if result["error"] is not None:
if deltas_were_sent["yes"]:
# Streaming failed AFTER some tokens were already delivered to
@ -2975,8 +3076,16 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
)
if _content_filter_terminated:
_stub._content_filter_terminated = True
# Partial-stream stub: chunks WERE received (deltas fired), so
# the provider is demonstrably responsive — clear the circuit
# breaker (#58962) just like the full-success return below.
_reset_stale_streak(agent)
return _stub
raise result["error"]
# Success — clear the circuit breaker (#58962): the provider proved
# responsive. See the canonical comment block above ``_stale_streak()``.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
# ── Provider fallback ──────────────────────────────────────────────────

View file

@ -1166,15 +1166,28 @@ def _normalize_codex_response(
if item_type == "message":
item_phase = getattr(item, "phase", None)
normalized_phase = None
is_commentary_phase = False
if isinstance(item_phase, str):
normalized_phase = item_phase.strip().lower()
if normalized_phase in {"commentary", "analysis"}:
saw_commentary_phase = True
is_commentary_phase = True
elif normalized_phase in {"final_answer", "final"}:
saw_final_answer_phase = True
message_text = _extract_responses_message_text(item)
if message_text:
content_parts.append(message_text)
# Responses ``commentary``/``analysis`` phase text is mid-turn
# preamble/progress narration, never the turn's final answer
# (Codex CLI excludes it from last-message extraction; issues
# #24933 / #41293). Keep it out of assistant content so it
# can't be concatenated into — or leak as — the final response,
# but surface it through the reasoning channel so the CLI/
# gateway display it like thinking text. The exact message
# item is still preserved below for replay/cache continuity.
if is_commentary_phase:
reasoning_parts.append(message_text)
else:
content_parts.append(message_text)
raw_message_item: Dict[str, Any] = {
"type": "message",
"role": "assistant",
@ -1269,7 +1282,11 @@ def _normalize_codex_response(
))
final_text = "\n".join([p for p in content_parts if p]).strip()
if not final_text and hasattr(response, "output_text"):
if (
not final_text
and hasattr(response, "output_text")
and not (saw_commentary_phase and not saw_final_answer_phase)
):
out_text = getattr(response, "output_text", "")
if isinstance(out_text, str):
final_text = out_text.strip()

View file

@ -228,6 +228,76 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
}
def _record_codex_app_server_compaction(
agent,
turn,
*,
approx_tokens: int | None = None,
force: bool = False,
) -> bool:
"""Record a Codex-native context compaction boundary in Hermes state.
The app-server owns the compacted thread context, so Hermes should not
rewrite local transcript rows here; state.db records the boundary via the
session event/usage counters while preserving the visible transcript.
"""
if not force and not getattr(turn, "compacted", False):
return False
thread_id = getattr(turn, "thread_id", None) or ""
turn_id = getattr(turn, "turn_id", None) or ""
logger.info(
"codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
getattr(agent, "session_id", None) or "none",
thread_id,
turn_id,
force,
)
if not force:
try:
from agent.conversation_compression import COMPACTION_STATUS
agent._emit_status(COMPACTION_STATUS)
except Exception:
pass
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
compressor.compression_count = getattr(
compressor, "compression_count", 0
) + 1
compressor.last_compression_rough_tokens = approx_tokens or 0
if not getattr(turn, "token_usage_last", None):
compressor.last_prompt_tokens = -1
compressor.last_completion_tokens = 0
compressor.awaiting_real_usage_after_compression = True
agent._last_compaction_in_place = False
try:
if getattr(agent, "event_callback", None):
agent.event_callback(
"session:compress",
{
"platform": getattr(agent, "platform", None) or "",
"session_id": getattr(agent, "session_id", None) or "",
"old_session_id": "",
"in_place": False,
"compression_count": getattr(
compressor, "compression_count", 0
)
if compressor is not None
else 0,
"runtime": "codex_app_server",
"thread_id": thread_id,
"turn_id": turn_id,
},
)
except Exception:
logger.debug("event_callback error on codex session:compress", exc_info=True)
return True
def run_codex_app_server_turn(
agent,
*,
@ -393,6 +463,7 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
_record_codex_app_server_compaction(agent, turn)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
@ -500,6 +571,14 @@ def _event_field(event: Any, name: str, default: Any = None) -> Any:
return value if value is not None else default
def _item_field(item: Any, name: str, default: Any = None) -> Any:
"""Field access for nested Response items (attr-style SDK object or dict)."""
value = getattr(item, name, None)
if value is None and isinstance(item, dict):
value = item.get(name, default)
return value if value is not None else default
def _raise_stream_error(event: Any) -> None:
"""Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame.
@ -562,6 +641,7 @@ def _consume_codex_event_stream(
collected_text_deltas: List[str] = []
has_tool_calls = False
first_delta_fired = False
active_message_phase: str | None = None
terminal_status: str = "completed"
terminal_usage: Any = None
terminal_response_id: str = None
@ -595,9 +675,35 @@ def _consume_codex_event_stream(
if event_type == "error":
_raise_stream_error(event)
# Track the phase of the active streamed message item. Codex/Harmony
# ``commentary``/``analysis`` text is mid-turn preamble/progress
# narration, never the final answer. We still collect completed output
# items for replay, but route those deltas to the reasoning callback so
# they display like thinking text instead of assistant content.
if event_type == "response.output_item.added":
item = _event_field(event, "item")
item_type = _item_field(item, "type", "")
if item_type == "message":
phase = _item_field(item, "phase", None)
active_message_phase = phase.strip().lower() if isinstance(phase, str) else None
else:
active_message_phase = None
if "function_call" in str(item_type):
has_tool_calls = True
continue
if "output_text.delta" in event_type or event_type == "response.output_text.delta":
delta_text = _event_field(event, "delta", "")
if delta_text:
is_commentary_delta = active_message_phase in {"commentary", "analysis"}
if delta_text and is_commentary_delta:
# Commentary streams through the reasoning channel, not the
# visible answer stream (and stays out of output_text).
if on_reasoning_delta is not None:
try:
on_reasoning_delta(delta_text)
except Exception:
logger.debug("Codex stream on_reasoning_delta raised", exc_info=True)
elif delta_text:
collected_text_deltas.append(delta_text)
if not has_tool_calls:
if not first_delta_fired:

View file

@ -84,6 +84,46 @@ LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# poisoning every subsequent request in the session — a bare key like
# "is_compressed_summary" would reach the wire and trip exactly that.
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
_DB_PERSISTED_MARKER = "_db_persisted"
def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]:
"""Copy a message for compaction assembly without persistence markers.
Live cached-gateway transcripts stamp ``_db_persisted`` during incremental
flushes. Shallow ``.copy()`` propagates that marker into the post-rotation
compressed list, so ``_flush_messages_to_session_db`` skips every row when
writing to the new child session (#57491).
This strips at the copy site (clearest intent, and cheap), but the
authoritative guarantee is the single terminal sweep in ``compress()``
(``_strip_persistence_markers``): no message may leave ``compress()``
carrying ``_db_persisted`` regardless of how many intermediate copy sites
a future refactor adds.
"""
fresh = msg.copy()
fresh.pop(_DB_PERSISTED_MARKER, None)
return fresh
def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
"""Enforce the compaction invariant: no assembled message carries a
session-store persistence marker.
``compress()`` copies protected head/tail messages out of the live
cached-gateway transcript, which stamps ``_db_persisted`` on every message
over the life of the session. If any copied dict keeps that marker, the
rotation flush to the child session skips it and the compacted transcript is
lost from ``state.db`` (#57491). Stripping at each copy site is necessary
but *positional* a copy site added after the assembly loops would re-leak.
This single terminal sweep makes the guarantee structural instead: run it
once on the fully-assembled list so the invariant holds no matter where the
copies happened. Mutates in place (the dicts are compaction-local copies).
"""
for msg in messages:
if isinstance(msg, dict):
msg.pop(_DB_PERSISTED_MARKER, None)
# Appended to every standalone summary message (and to the merged-into-tail
# prefix) so the model has an unambiguous "summary ends here" boundary.
@ -258,6 +298,32 @@ def _content_length_for_budget(raw_content: Any) -> int:
return total
def _serialized_length_for_budget(value: Any) -> int:
"""Return a stable char-length for non-content replay/metadata fields."""
if value is None or value == "":
return 0
if isinstance(value, str):
return len(value)
try:
return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str))
except (TypeError, ValueError):
return len(str(value))
# Provider replay/metadata fields that ride the wire on every request but are
# invisible to ``msg["content"]``/``msg["tool_calls"]`` accounting. Codex
# Responses sessions in particular carry ``codex_reasoning_items`` blobs of
# ``encrypted_content`` that can dominate the serialized session (a measured
# 214-turn session held ~115K tokens / 27% of its payload there — #55572).
_REPLAY_BUDGET_KEYS = (
"reasoning",
"reasoning_content",
"reasoning_details",
"codex_reasoning_items",
"codex_message_items",
)
def _estimate_msg_budget_tokens(msg: dict) -> int:
"""Token estimate for one message in the tail-protection budget walks.
@ -268,12 +334,23 @@ def _estimate_msg_budget_tokens(msg: dict) -> int:
4-tool-call turn measures ~73 vs ~1,090 real tokens), so the protected
tail overshot ``tail_token_budget`` and compression became ineffective.
See issue #28053.
Also counts provider replay fields (``codex_reasoning_items`` etc.
see ``_REPLAY_BUDGET_KEYS``). The preflight "should I compress?"
estimator sees the full message shape, so the tail walk must use the
same size class; otherwise an assistant message with tiny visible
content but large hidden replay blobs is protected as if it were small,
the post-compression session stays near the context limit, and
compaction re-fires continuously (#55572). Accounting-only: replay
fields are never mutated or pruned here.
"""
content_len = _content_length_for_budget(msg.get("content") or "")
tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
tokens += len(str(tc)) // _CHARS_PER_TOKEN
for key in _REPLAY_BUDGET_KEYS:
tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN
return tokens
@ -2834,7 +2911,7 @@ This compaction should PRIORITISE preserving all information related to the focu
# Phase 4: Assemble compressed message list
compressed = []
for i in range(compress_start):
msg = messages[i].copy()
msg = _fresh_compaction_message_copy(messages[i])
if i == 0 and msg.get("role") == "system":
existing = msg.get("content")
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]"
@ -2870,6 +2947,33 @@ This compaction should PRIORITISE preserving all information related to the focu
# is not role=user, so we must pin the summary to "user" and
# prevent the flip logic below from reverting it (#52160).
_force_user_leading = last_head_role == "system"
# Zero-user-turn guard (#58753). The #52160 guard above only fires
# when the system prompt sits *inside* ``messages`` (the gateway
# ``/compress`` path). The main auto-compression path passes the
# transcript WITHOUT the system prompt (it is prepended at
# request-build time), so ``last_head_role`` defaults to "user" and
# the summary is emitted as role="assistant". On a session whose only
# genuine user turn falls into the compressed middle — e.g. a
# ``hermes kanban`` worker seeded with a single short
# ``"work kanban task <id>"`` prompt followed by nothing but
# assistant/tool turns — that leaves the compressed transcript with
# ZERO user-role messages. OpenAI-compatible backends (vLLM/Qwen)
# reject such a request with a non-retryable
# ``400 No user query found in messages``, crashing the worker with no
# possible recovery (every resume replays the same poisoned history).
# If no user-role message survives in either the protected head or the
# preserved tail, the summary MUST carry role="user" so the request
# always has at least one user turn.
if not _force_user_leading:
_user_survives = any(
messages[i].get("role") == "user"
for i in range(0, compress_start)
) or any(
messages[i].get("role") == "user"
for i in range(compress_end, n_messages)
)
if not _user_survives:
_force_user_leading = True
# Pick a role that avoids consecutive same-role with both neighbors.
# Priority: avoid colliding with head (already committed), then tail.
if last_head_role in {"assistant", "tool"} or _force_user_leading:
@ -2907,7 +3011,7 @@ This compaction should PRIORITISE preserving all information related to the focu
})
for i in range(compress_end, n_messages):
msg = messages[i].copy()
msg = _fresh_compaction_message_copy(messages[i])
if _merge_summary_into_tail and i == compress_end:
# Merge the summary into the first tail message, but place
# the END MARKER at the very end so the model sees an
@ -2969,4 +3073,10 @@ This compaction should PRIORITISE preserving all information related to the focu
)
logger.info("Compression #%d complete", self.compression_count)
# Enforced invariant (#57491): no compacted message may leave compress()
# carrying a session-store persistence marker. The per-site strips above
# are positional; this single terminal sweep makes it structural so a
# future copy site cannot re-leak the marker into the child-session flush.
_strip_persistence_markers(compressed)
return compressed

View file

@ -391,6 +391,47 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option
return None
def _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None:
"""Preserve a real user turn when a compressor returns assistant/tool-only context.
On repeated compaction the protected head decays to the system prompt only,
the middle summary can land as ``role="assistant"``, and a tool-heavy tail
can be all assistant/tool so the compacted transcript can legitimately
contain zero user messages. Strict chat templates (LM Studio / llama.cpp
Jinja) then fail with "No user query found in messages" (#55677).
The restored turn is appended at the END: the guard only runs when
``compressed`` currently ends with an assistant/tool message (any existing
user turn including a todo-snapshot append short-circuits the
``any()`` check), so appending a user message never creates consecutive
same-role messages. ``_fresh_compaction_message_copy`` copies the message
and strips the ``_db_persisted`` marker so the rotation/in-place flush
still persists the restored row to the new session (#57491).
If the pre-compression transcript itself carried no user turn at all
(near-impossible every real conversation opens with a user request
but kept as a defensive backstop), a minimal continuation marker is
appended instead so strict templates still see a user message.
"""
if any(isinstance(msg, dict) and msg.get("role") == "user" for msg in compressed):
return
from agent.context_compressor import _fresh_compaction_message_copy
for msg in reversed(original_messages):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
compressed.append(_fresh_compaction_message_copy(msg))
return
compressed.append({
"role": "user",
"content": (
"Continue from the compressed conversation context above. "
"This marker exists because the compacted transcript contained "
"no preserved user turn."
),
})
def compress_context(
agent: Any,
messages: list,
@ -424,6 +465,21 @@ def compress_context(
prompt the session is NOT rotated. Callers should detect the
no-op via ``len(returned) == len(input)`` and stop the retry loop.
"""
# Codex app-server sessions: the codex agent owns the real thread context;
# Hermes' summarizer would only rewrite a local mirror without shrinking
# the actual thread (#36801). Route compaction to the app server's own
# thread/compact mechanism. Behavior is controlled by
# ``compression.codex_app_server_auto`` (native|hermes|off).
if getattr(agent, "api_mode", None) == "codex_app_server":
return _compress_context_via_codex_app_server(
agent,
messages,
system_message,
approx_tokens=approx_tokens,
task_id=task_id,
force=force,
)
# Lazy feasibility check — run the auxiliary-provider probe + context
# length lookup just-in-time on the first compression attempt instead of
# at AIAgent.__init__. Saves ~400ms cold off every short session that
@ -647,6 +703,7 @@ def compress_context(
todo_snapshot = agent._todo_store.format_for_injection()
if todo_snapshot:
compressed.append({"role": "user", "content": todo_snapshot})
_ensure_compressed_has_user_turn(messages, compressed)
agent._invalidate_system_prompt()
new_system_prompt = agent._build_system_prompt(system_message)
@ -929,6 +986,122 @@ def compress_context(
_release_lock()
def _compress_context_via_codex_app_server(
agent: Any,
messages: list,
system_message: Optional[str],
*,
approx_tokens: Optional[int] = None,
task_id: str = "default",
force: bool = False,
) -> Tuple[list, str]:
"""Route compaction to Codex app-server for Codex-owned threads.
Hermes' normal compressor rewrites the local OpenAI-style transcript.
That does not shrink the actual Codex app-server thread context. For this
runtime, ask Codex to compact its own thread and keep Hermes' transcript
unchanged.
"""
auto_mode = str(
getattr(agent, "codex_app_server_auto_compaction", "native") or "native"
).lower()
if auto_mode not in {"native", "hermes", "off"}:
auto_mode = "native"
if not force and auto_mode != "hermes":
logger.info(
"codex app-server compaction skipped: mode=%s force=false "
"(session=%s messages=%d tokens=~%s)",
auto_mode,
getattr(agent, "session_id", None) or "none",
len(messages),
f"{approx_tokens:,}" if approx_tokens else "unknown",
)
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
codex_session = getattr(agent, "_codex_session", None)
if codex_session is None:
logger.info(
"codex app-server compaction skipped: no active codex thread "
"(session=%s messages=%d tokens=~%s)",
getattr(agent, "session_id", None) or "none",
len(messages),
f"{approx_tokens:,}" if approx_tokens else "unknown",
)
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
logger.info(
"codex app-server compaction started: session=%s messages=%d tokens=~%s",
getattr(agent, "session_id", None) or "none",
len(messages),
f"{approx_tokens:,}" if approx_tokens else "unknown",
)
try:
agent._emit_status(COMPACTION_STATUS)
except Exception:
pass
result = codex_session.compact_thread()
if getattr(result, "should_retire", False):
try:
codex_session.close()
except Exception:
pass
agent._codex_session = None
if getattr(result, "error", None):
try:
agent._emit_warning(
f"⚠ Codex app-server compaction failed: {result.error}"
)
except Exception:
pass
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
try:
from agent.codex_runtime import (
_record_codex_app_server_compaction,
_record_codex_app_server_usage,
)
_record_codex_app_server_compaction(
agent,
result,
approx_tokens=approx_tokens,
force=True,
)
if getattr(result, "token_usage_last", None):
_record_codex_app_server_usage(agent, result)
except Exception:
logger.debug("codex compaction bookkeeping failed", exc_info=True)
try:
from tools.file_tools import reset_file_dedup
reset_file_dedup(task_id)
except Exception:
pass
logger.info(
"codex app-server compaction done: session=%s thread=%s turn=%s",
getattr(agent, "session_id", None) or "none",
getattr(result, "thread_id", None) or "",
getattr(result, "turn_id", None) or "",
)
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
def try_shrink_image_parts_in_messages(
api_messages: list,
*,

View file

@ -58,7 +58,12 @@ from agent.model_metadata import (
)
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import apply_anthropic_cache_control
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
from agent.retry_utils import (
adaptive_rate_limit_backoff,
is_zai_coding_overload_error,
jittered_backoff,
zai_coding_overload_retry_ceiling,
)
from agent.trajectory import has_incomplete_scratchpad
from agent.usage_pricing import estimate_usage_cost, normalize_usage
from hermes_constants import PARTIAL_STREAM_STUB_ID
@ -847,15 +852,15 @@ def run_conversation(
if moa_config:
try:
from agent.moa_loop import aggregate_moa_context
from agent.moa_loop import _preset_temperature, aggregate_moa_context
_moa_context = aggregate_moa_context(
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
api_messages=api_messages,
reference_models=moa_config.get("reference_models") or [],
aggregator=moa_config.get("aggregator") or {},
temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6),
aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4),
temperature=_preset_temperature(moa_config, "reference_temperature"),
aggregator_temperature=_preset_temperature(moa_config, "aggregator_temperature"),
max_tokens=moa_config.get("reference_max_tokens"),
)
if _moa_context:
@ -946,15 +951,20 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
# Calculate approximate request size for logging
# Calculate approximate request size for logging and pressure checks.
# estimate_messages_tokens_rough(api_messages) includes the system
# prompt copy but not the tool schema payload, which is sent as a
# separate field. Add tools back for compression decisions so long
# tool-heavy turns do not creep up to the context ceiling and leave
# no room for the model's final answer.
total_chars = sum(len(str(msg)) for msg in api_messages)
approx_tokens = estimate_messages_tokens_rough(api_messages)
approx_request_tokens = estimate_request_tokens_rough(
request_pressure_tokens = estimate_request_tokens_rough(
api_messages, tools=agent.tools or None
)
_runtime_context_error = _ollama_context_limit_error(
agent, approx_request_tokens
agent, request_pressure_tokens
)
if _runtime_context_error:
final_response = _runtime_context_error
@ -969,6 +979,83 @@ def run_conversation(
except Exception:
pass
break
# Pre-API pressure check. The turn-prologue preflight only saw the
# incoming user message; a single turn can then grow by many large
# tool results and leave no output budget before the NEXT call (the
# live 271k/272k Codex failure). The post-response should_compress
# gate at the tool-loop tail uses API-reported last_prompt_tokens,
# which LAGS a just-appended huge tool result — so it misses this
# case. Re-check here against the current request estimate.
#
# Mirror the turn-prologue preflight's guard chain exactly (see
# turn_context.py): (1) defer when the rough estimate is known-noisy
# relative to a recent real provider prompt that fit under threshold
# (schema overhead / post-compaction over-count, #36718); (2) skip
# while a same-session compression-failure cooldown is active; (3) then
# should_compress() — reusing the canonical threshold_tokens (output
# room already reserved by _compute_threshold_tokens) and its summary-
# LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
# hard per-turn backstop shared with the overflow error handlers.
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor, "should_defer_preflight_to_real_usage", lambda _t: False
)
_compression_cooldown = getattr(
_compressor, "get_active_compression_failure_cooldown", lambda: None
)()
if (
agent.compression_enabled
and len(messages) > 1
and compression_attempts < 3
and not _defer_preflight(request_pressure_tokens)
and not _compression_cooldown
and _compressor.should_compress(request_pressure_tokens)
):
compression_attempts += 1
logger.info(
"Pre-API compression: ~%s request tokens >= %s threshold "
"(context=%s, attempt=%s/3)",
f"{request_pressure_tokens:,}",
f"{int(getattr(_compressor, 'threshold_tokens', 0) or 0):,}",
f"{int(getattr(_compressor, 'context_length', 0) or 0):,}"
if getattr(_compressor, "context_length", 0) else "unknown",
compression_attempts,
)
agent._emit_status(
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
f"near the context/output limit. Compacting before the next model call."
)
messages, active_system_prompt = agent._compress_context(
messages,
system_message,
approx_tokens=request_pressure_tokens,
task_id=effective_task_id,
)
# Reset retry/empty-response state so the compacted request
# gets a fresh chance instead of inheriting stale recovery
# counters from the pre-compaction history.
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
# Re-baseline the flush cursor for the compaction mode that just
# ran. Legacy session-rotation returns None (the child session has
# not seen the compacted transcript, so the next flush writes it
# whole); in-place compaction returns list(messages) because the
# compacted rows are already persisted under the same session id —
# leaving None there would re-append them, doubling the active
# context and retriggering compression. Mirrors the post-response
# and preflight compaction sites; see
# conversation_history_after_compression().
conversation_history = conversation_history_after_compression(
agent, messages
)
api_call_count -= 1
agent._api_call_count = api_call_count
agent.iteration_budget.refund()
continue
# Thinking spinner for quiet mode (animated during API call)
thinking_spinner = None
@ -1076,6 +1163,14 @@ def run_conversation(
_sanitize_structure_non_ascii(api_kwargs)
if agent.api_mode == "codex_responses":
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
# Copilot x-initiator: the first API call of a user turn is
# marked "user" so Copilot bills a premium request; tool-loop
# follow-ups keep the default "agent" header (#3040).
if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url():
_xh = dict(api_kwargs.get("extra_headers") or {})
_xh["x-initiator"] = "user"
api_kwargs["extra_headers"] = _xh
agent._is_user_initiated_turn = False
try:
from hermes_cli.middleware import apply_llm_request_middleware
@ -1419,7 +1514,7 @@ def run_conversation(
elif _resp_error_code == 504:
_failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)"
elif _resp_error_code == 429:
_failure_hint = f"rate limited by upstream provider (429)"
_failure_hint = "rate limited by upstream provider (429)"
elif _resp_error_code in {500, 502}:
_failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)"
elif _resp_error_code in {503, 529}:
@ -1508,7 +1603,14 @@ def run_conversation(
else:
incomplete_reason = getattr(incomplete_details, "reason", None)
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
finish_reason = "length"
# Responses API max-output exhaustion is a normal
# Codex incomplete turn. Let the Codex-specific
# continuation path below append the incomplete
# assistant state and retry, instead of routing to
# the generic chat-completions length rollback that
# emits "Response truncated due to output length
# limit" and stops gateway turns.
finish_reason = "incomplete"
else:
finish_reason = "stop"
elif agent.api_mode == "anthropic_messages":
@ -2253,11 +2355,11 @@ def run_conversation(
agent._unicode_sanitization_passes += 1
if _surrogates_found:
agent._buffer_vprint(
f"⚠️ Stripped invalid surrogate characters from messages. Retrying..."
"⚠️ Stripped invalid surrogate characters from messages. Retrying..."
)
else:
agent._buffer_vprint(
f"⚠️ Surrogate encoding error — retrying after full-payload sanitization..."
"⚠️ Surrogate encoding error — retrying after full-payload sanitization..."
)
continue
if _is_ascii_codec:
@ -2664,7 +2766,7 @@ def run_conversation(
):
_retry.copilot_auth_retry_attempted = True
if agent._try_refresh_copilot_client_credentials():
agent._buffer_vprint(f"🔐 Copilot credentials refreshed after 401. Retrying request...")
agent._buffer_vprint("🔐 Copilot credentials refreshed after 401. Retrying request...")
continue
if (
agent.api_mode == "anthropic_messages"
@ -2887,10 +2989,10 @@ def run_conversation(
)
if agent.providers_allowed:
agent._buffer_vprint(
f" Your provider_routing.only restriction is filtering out tool-capable providers."
" Your provider_routing.only restriction is filtering out tool-capable providers."
)
agent._buffer_vprint(
f" Try removing the restriction or adding providers that support tools for this model."
" Try removing the restriction or adding providers that support tools for this model."
)
agent._buffer_vprint(
f" Check which providers support tools: https://openrouter.ai/models/{_model}"
@ -3045,6 +3147,18 @@ def run_conversation(
FailoverReason.timeout,
FailoverReason.overloaded,
}
# Z.AI Coding Plan GLM-5.2 overload 429s classify as
# `overloaded` (to spare the credential pool), but `overloaded`
# is excluded from `is_rate_limited` — the gate for the adaptive
# Z.AI backoff below. Detect the overload directly so its
# long-backoff schedule runs, and raise the retry ceiling so the
# long tier (30/60/90/120s) is reachable. See
# zai_coding_overload_retry_ceiling() for the ceiling rationale.
_is_zai_coding_overload = is_zai_coding_overload_error(
base_url=str(_base), model=_model, error=api_error
)
if _is_zai_coding_overload:
max_retries = max(max_retries, zai_coding_overload_retry_ceiling())
_should_fallback = (
is_rate_limited
or (_is_transport_failure and retry_count >= 2)
@ -3052,8 +3166,7 @@ def run_conversation(
if _should_fallback and agent._fallback_index < len(agent._fallback_chain):
# Don't eagerly fallback if credential pool rotation may
# still recover. See _pool_may_recover_from_rate_limit
# for the single-credential-pool and CloudCode-quota
# exceptions. Fixes #11314 and #13636.
# for the single-credential-pool exception. Fixes #11314.
#
# Exception: an upstream-aggregator 429 — the credential
# pool can't help when the *upstream* model (DeepSeek,
@ -3064,8 +3177,6 @@ def run_conversation(
False if _is_upstream
else _ra()._pool_may_recover_from_rate_limit(
agent._credential_pool,
provider=agent.provider,
base_url=getattr(agent, "base_url", None),
)
)
if not pool_may_recover:
@ -3603,6 +3714,8 @@ def run_conversation(
if agent._has_pending_fallback():
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
elif classified.reason == FailoverReason.ssl_cert_verification:
agent._buffer_status("⚠️ TLS certificate verification failed — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
if agent._try_activate_fallback():
@ -3631,6 +3744,11 @@ def run_conversation(
f"❌ Provider safety filter blocked this request: "
f"{_nonretryable_summary}"
)
elif classified.reason == FailoverReason.ssl_cert_verification:
agent._emit_status(
f"❌ TLS certificate verification failed: "
f"{_nonretryable_summary}"
)
else:
agent._emit_status(
f"❌ Non-retryable error (HTTP {status_code}): "
@ -3704,6 +3822,43 @@ def run_conversation(
f"{agent.log_prefix} hermes fallback add (interactive picker — same as `hermes model`)",
force=True,
)
# TLS certificate failures are environment problems, not
# provider/prompt problems — tell the user exactly which
# knobs fix each common cause. Inspired by Claude Code
# v2.1.199's immediate SSL fix hints.
if classified.reason == FailoverReason.ssl_cert_verification:
agent._vprint(
f"{agent.log_prefix} 💡 The TLS certificate chain could not be verified. This fails the same",
force=True,
)
agent._vprint(
f"{agent.log_prefix} way on every retry — fix the environment, then try again:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} • Corporate TLS-inspecting proxy? Point Python at its CA bundle:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} export SSL_CERT_FILE=/path/to/corp-ca.pem (also REQUESTS_CA_BUNDLE)",
force=True,
)
agent._vprint(
f"{agent.log_prefix} • Missing/stale system CA store? Install/refresh it:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} pip install --upgrade certifi (macOS: run 'Install Certificates.command')",
force=True,
)
agent._vprint(
f"{agent.log_prefix} • Self-signed local endpoint (llama.cpp, LM Studio, vLLM)? Use http://",
force=True,
)
agent._vprint(
f"{agent.log_prefix} for localhost, or add the server's cert to your trust store.",
force=True,
)
logger.error(f"{agent.log_prefix}Non-retryable client error: {api_error}")
# Skip session persistence when the error is likely
# context-overflow related (status 400 + large session).
@ -3954,7 +4109,7 @@ def run_conversation(
pass
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
_backoff_policy = None
if is_rate_limited and not _retry_after:
if (is_rate_limited or _is_zai_coding_overload) and not _retry_after:
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
retry_count,
base_url=str(_base),
@ -3962,13 +4117,14 @@ def run_conversation(
error=api_error,
default_wait=wait_time,
)
if is_rate_limited:
if is_rate_limited or _is_zai_coding_overload:
_policy_note = ""
if _backoff_policy == "zai_coding_overload_long":
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
elif _backoff_policy == "zai_coding_overload_short":
_policy_note = " (Z.AI Coding overload short retry)"
_rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
_wait_reason = "Provider overloaded" if _is_zai_coding_overload and not is_rate_limited else "Rate limited"
_rate_limit_status = f"⏱️ {_wait_reason}. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
# Normal retries are buffered to avoid noisy transient chatter. Long
# Z.AI Coding waits are different: they can last minutes, so surface
# progress immediately instead of making the TUI look frozen.
@ -4173,7 +4329,7 @@ def run_conversation(
if has_incomplete_scratchpad(assistant_message.content or ""):
agent._incomplete_scratchpad_retries += 1
agent._buffer_vprint(f"⚠️ Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)")
agent._buffer_vprint("⚠️ Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)")
if agent._incomplete_scratchpad_retries <= 2:
agent._buffer_vprint(f"🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...")
@ -4408,7 +4564,7 @@ def run_conversation(
else:
# Instead of returning partial, inject tool error results so the model can recover.
# Using tool results (not user messages) preserves role alternation.
agent._buffer_vprint(f"⚠️ Injecting recovery tool results for invalid JSON...")
agent._buffer_vprint("⚠️ Injecting recovery tool results for invalid JSON...")
agent._invalid_json_retries = 0 # Reset for next attempt
# Append the assistant message with its (broken) tool_calls

View file

@ -2105,8 +2105,20 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# changes to the .env file.
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
val = env_file.get(key) or _get_secret(key, "") or ""
return val.strip()
raw = env_file.get(key, "").strip()
env_val = os.environ.get(key, "").strip()
# If .env contains an unresolved op:// reference, prefer the
# already-resolved value from os.environ (set by
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
# "op://Vault/Item/field" string would otherwise win and every
# provider auth attempt would receive a URL instead of a key. This
# happens during a partial migration, or when the user wrote op://
# references straight into .env rather than the secrets.onepassword
# config block. For every non-op:// value the original
# .env-takes-precedence behaviour is preserved unchanged.
if raw.startswith("op://") and env_val:
return env_val
return raw or _get_secret(key, "") or env_val
# Honour user suppression — `hermes auth remove <provider> <N>` for an
# env-seeded credential marks the env:<VAR> source as suppressed so it

View file

@ -556,7 +556,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
if target is None:
return (
False,
f"no matching backup found"
"no matching backup found"
+ (f" for id '{backup_id}'" if backup_id else "")
+ " (use `hermes curator rollback --list` to see available snapshots)",
None,

View file

@ -515,6 +515,16 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
msg = msg[:17] + "..."
return f"to {target}: \"{msg}\""
if tool_name == "skill_view":
name = _oneline(str(args.get("name") or ""))
file_path = args.get("file_path")
if file_path:
file_path = _oneline(str(file_path))
preview = f"{name}{file_path}" if name else file_path
else:
preview = name
return _truncate_preview(preview, max_len) if preview else None
key = primary_args.get(tool_name)
if not key:
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
@ -1384,7 +1394,11 @@ def get_cute_tool_message(
if tool_name == "skills_list":
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
if tool_name == "skill_view":
return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}")
label = args.get("name", "")
file_path = args.get("file_path")
if file_path:
label = f"{label}{file_path}" if label else str(file_path)
return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}")
if tool_name == "image_generate":
return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}")
if tool_name == "text_to_speech":

View file

@ -41,6 +41,11 @@ class FailoverReason(enum.Enum):
# Transport
timeout = "timeout" # Connection/read timeout — rebuild client + retry
# TLS certificate verification failure — deterministic for the host
# (TLS-inspecting proxy, missing/expired CA bundle, self-signed cert).
# Retrying reproduces the identical handshake failure, so fail fast
# with actionable guidance instead of burning retries.
ssl_cert_verification = "ssl_cert_verification"
# Context / payload
context_overflow = "context_overflow" # Context too large — compress, not failover
@ -279,6 +284,15 @@ _MODEL_NOT_FOUND_PATTERNS = [
"no such model",
"unknown model",
"unsupported model",
# OpenRouter returns 404 with this message when none of the candidate
# endpoints for the selected model support tool/function calling.
# Classifying this as model_not_found triggers fallback to a different
# model or provider that does support tools. Without this entry the
# pattern falls through to ``unknown`` with ``retryable=True``, the
# retry loop burns all attempts on the same deterministic rejection,
# and the error surfaces as a confusing "model not found" message
# instead of automatically failing over. See PR #58446.
"no endpoints found that support tool use",
]
# Request-validation patterns — the request is malformed and will fail
@ -438,6 +452,29 @@ _SERVER_DISCONNECT_PATTERNS = [
"incomplete chunked read",
]
# SSL certificate verification failures — deterministic, NOT transient.
#
# A failed certificate chain (TLS-inspecting corporate proxy, missing
# custom CA in the trust store, expired certificate, self-signed cert)
# fails identically on every retry. Burning the retry budget before
# surfacing the error hides the actionable fix from the user for minutes.
# Inspired by Claude Code v2.1.199 (July 2026), which made SSL certificate
# errors fail immediately with a fix hint instead of retrying.
#
# Must be checked BEFORE _SSL_TRANSIENT_PATTERNS — "certificate verify
# failed" messages usually also contain "[SSL:" which would otherwise
# match the transient list and retry forever.
_SSL_CERT_VERIFY_PATTERNS = [
"certificate verify failed", # Python ssl module canonical text
"certificate_verify_failed", # OpenSSL error token
"unable to get local issuer certificate",
"self-signed certificate",
"self signed certificate",
"certificate has expired",
"hostname mismatch, certificate is not valid",
"unable to verify the first certificate", # Node/undici phrasing (MCP bridges)
]
# SSL/TLS transient failure patterns — intentionally distinct from
# _SERVER_DISCONNECT_PATTERNS above.
#
@ -735,7 +772,22 @@ def classify_api_error(
if classified is not None:
return classified
# ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
# ── 5. SSL certificate verification failures → fail fast ────────
# A broken certificate chain (TLS-inspecting proxy, missing custom CA,
# expired/self-signed cert) is deterministic for the host — every retry
# reproduces the identical handshake failure. Fail immediately with
# actionable guidance instead of burning the retry budget first.
# Checked BEFORE the transient-SSL patterns: cert-verify messages also
# contain "[ssl:" which would otherwise match the transient list.
# Inspired by Claude Code v2.1.199 (July 2026).
if any(p in error_msg for p in _SSL_CERT_VERIFY_PATTERNS):
return _result(
FailoverReason.ssl_cert_verification,
retryable=False,
should_fallback=False,
)
# ── 5b. SSL/TLS transient errors → retry as timeout (not compression) ──
# SSL alerts mid-stream are transport hiccups, not server-side context
# overflow signals. Classify before the disconnect check so a large
# session doesn't incorrectly trigger context compression when the real
@ -991,6 +1043,17 @@ def _classify_by_status(
)
return result_fn(FailoverReason.overloaded, retryable=True)
# 408 Request Timeout — a transient timing failure the server itself flags
# as safe to retry (RFC 9110 §15.5.9), not a malformed request. Commonly
# emitted by reverse proxies sitting in front of self-hosted backends
# (llama.cpp / Ollama / vLLM) when a long generation outruns the proxy's
# request-read window. Route to the dedicated ``timeout`` reason (rebuild
# client + retry) instead of falling through to the generic 4xx bucket
# below, which would abort the turn on a retry-safe error the same way it
# aborts a 400 Bad Request.
if status_code == 408:
return result_fn(FailoverReason.timeout, retryable=True)
# Other 4xx — non-retryable
if 400 <= status_code < 500:
return result_fn(

View file

@ -304,6 +304,30 @@ def get_read_block_error(path: str) -> Optional[str]:
return None
def raise_if_read_blocked(path: str) -> None:
"""Raise ``ValueError`` if ``path`` is a denied Hermes read (see
:func:`get_read_block_error`), else return.
Shared chokepoint for provider input-loading sites that read a local
file the model/tool supplied (e.g. image-gen ``image_url`` /
``reference_image_urls`` paths). Centralizes the guard so every provider
enforces the same read boundary with identical semantics instead of each
open-coding the try/except block (#57698).
Best-effort by design: if ``agent.file_safety`` machinery is somehow
unavailable at the call site the guard no-ops rather than breaking local
image loading consistent with the defense-in-depth (not security
boundary) framing of the denylist itself. The blocking ``ValueError`` from
a real hit still propagates; only unexpected internal errors are swallowed.
"""
try:
blocked = get_read_block_error(path)
except Exception: # noqa: BLE001 - guard must never break local-file loading
return
if blocked:
raise ValueError(blocked)
# ---------------------------------------------------------------------------
# Cross-profile write guard (#TBD)
#

View file

@ -27,6 +27,7 @@ from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent.bounded_response import read_streaming_error_body
from agent.gemini_schema import sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
@ -742,14 +743,17 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices:
return chunks
def gemini_http_error(response: httpx.Response) -> GeminiAPIError:
def gemini_http_error(
response: httpx.Response, *, body_text: Optional[str] = None
) -> GeminiAPIError:
status = response.status_code
body_text = ""
body_json: Dict[str, Any] = {}
try:
body_text = response.text
except Exception:
body_text = ""
if body_text is None:
try:
body_text = response.text
except Exception:
body_text = ""
body_text = body_text or ""
if body_text:
try:
parsed = json.loads(body_text)
@ -968,8 +972,8 @@ class GeminiNativeClient:
try:
with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response:
if response.status_code != 200:
response.read()
raise gemini_http_error(response)
body_text = read_streaming_error_body(response)
raise gemini_http_error(response, body_text=body_text)
tool_call_indices: Dict[str, Dict[str, Any]] = {}
for event in _iter_sse_events(response):
for chunk in translate_stream_event(event, model, tool_call_indices):

View file

@ -17,13 +17,17 @@ It reads ``agent.image_input_mode`` from config.yaml (``auto`` | ``native``
| ``text``, default ``auto``) and the active model's capability metadata.
In ``auto`` mode:
- If the user has explicitly configured ``auxiliary.vision.provider``
(i.e. not ``auto`` and not empty), we assume they want the text pipeline
regardless of the main model they've opted in to a specific vision
backend for a reason (cost, quality, local-only, etc.).
- Otherwise, if the active model reports ``supports_vision=True`` in its
models.dev metadata, we attach natively.
- Otherwise (non-vision model, no explicit override), we fall back to text.
- If the active model reports ``supports_vision=True`` (via config
override or models.dev metadata), we attach natively vision-capable
main models should always see the original pixels, even when an
auxiliary vision backend is configured. That auxiliary backend then
acts as a *fallback* for sessions whose main model can't take images.
- Otherwise, if the user has explicitly configured ``auxiliary.vision``
(provider/model/base_url not ``auto``/empty), we route through the
text pipeline so the auxiliary vision backend can describe the image
for the text-only main model.
- Otherwise (non-vision model, no explicit override), we fall back to
text via the default vision_analyze flow.
This keeps ``vision_analyze`` surfaced as a tool in every session skills
and agent flows that chain it (browser screenshots, deeper inspection of
@ -185,7 +189,8 @@ def _supports_vision_override(
2. ``providers.<provider>.models.<model>.supports_vision``
(named custom providers ``provider`` may be the runtime-resolved
value ``"custom"`` and/or the user-declared name under
``model.provider``; both are tried)
``model.provider``; both are tried. For ``custom:<name>`` syntax,
the stripped ``<name>`` is also tried as a provider key.)
Returns None when no override is set, so the caller falls through to
models.dev. Returns False explicitly only when the user wrote a
@ -205,11 +210,16 @@ def _supports_vision_override(
# get rewritten to provider="custom" at runtime
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
# config still holds the user-declared name under model.provider. Try
# both as candidate provider keys.
# both as candidate provider keys, plus the stripped suffix from
# "custom:<name>" (where <name> is the key under providers:).
config_provider = str(model_cfg.get("provider") or "").strip()
# Extract the stripped name from "custom:<name>" if present
stripped_suffix = ""
if config_provider.startswith("custom:"):
stripped_suffix = config_provider[len("custom:"):]
providers_raw = cfg.get("providers")
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
for p in dict.fromkeys(filter(None, (provider, config_provider))):
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
entry_raw = providers_cfg.get(p)
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
models_raw = entry.get("models")
@ -336,8 +346,10 @@ def _coerce_mode(raw: Any) -> str:
def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
"""True when the user configured a specific auxiliary vision backend.
An explicit override means the user *wants* the text pipeline (they're
paying for a dedicated vision model), so we don't silently bypass it.
An explicit override means the user has a dedicated vision backend
available; it's used as a *fallback* when the main model can't take
images natively. In ``auto`` mode, native vision on a vision-capable
main model still wins over this fallback see issue #29135.
"""
if not isinstance(cfg, dict):
return False
@ -426,13 +438,15 @@ def decide_image_input_mode(
if mode_cfg == "text":
return "text"
# auto
if _explicit_aux_vision_override(cfg):
return "text"
# auto: prefer native vision when the main model supports it. An
# explicit auxiliary.vision config acts as a *fallback* for text-only
# main models — it should not preempt native vision on a model that
# can natively inspect the pixels (issue #29135).
supports = _lookup_supports_vision(provider, model, cfg)
if supports is True:
return "native"
if _explicit_aux_vision_override(cfg):
return "text"
return "text"
@ -618,6 +632,17 @@ def _file_to_data_url(path: Path) -> Optional[str]:
caller reports those paths in ``skipped`` and the rest of the turn
proceeds.
"""
try:
from agent.file_safety import raise_if_read_blocked
raise_if_read_blocked(str(path))
except ValueError as exc:
logger.warning("image_routing: blocked local image attachment %s -- %s", path, exc)
return None
except Exception:
# Keep attachment routing best-effort if the guard itself is unavailable.
pass
try:
raw = path.read_bytes()
except Exception as exc:

View file

@ -73,7 +73,8 @@ def format_date(ts: Optional[float]) -> str:
if not ts:
return "unknown"
try:
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%-d %b %Y")
dt = datetime.fromtimestamp(float(ts), tz=timezone.utc)
return f"{dt.day} {dt.strftime('%b %Y')}"
except (ValueError, OSError, OverflowError):
return "unknown"
@ -255,7 +256,7 @@ def _period_key(ts: float, granularity: str) -> tuple[int, ...]:
def _period_label(ts: float, granularity: str) -> str:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
if granularity == "day":
return dt.strftime("%-d %b")
return f"{dt.day} {dt.strftime('%b')}"
if granularity == "month":
return dt.strftime("%b %Y")
return dt.strftime("%Y")

View file

@ -348,17 +348,15 @@ 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)
proc = subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
check=False,
capture_output=True,
text=True,
from hermes_cli.tools_config import _pip_install
proc = _pip_install(
["--target", str(pip_target), "--quiet", pkg],
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
)
return None
except (subprocess.TimeoutExpired, OSError) as e:

View file

@ -91,7 +91,7 @@ async def read_message(reader: asyncio.StreamReader) -> Optional[dict]:
header_bytes += len(line)
if header_bytes > 8192:
raise LSPProtocolError(
f"LSP header block exceeded 8 KiB without terminator"
"LSP header block exceeded 8 KiB without terminator"
)
line = line[:-2] # strip CRLF
if not line:

View file

@ -173,6 +173,50 @@ def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
return out
def _maybe_apply_moa_cache_control(
messages: list[dict[str, Any]],
runtime: dict[str, Any],
) -> list[dict[str, Any]]:
"""Decorate an advisor or aggregator request with cache_control when its
route honors it.
Reuses the SAME policy function as the main agent loop
(``anthropic_prompt_cache_policy``) resolved against the slot's own
provider/base_url/api_mode/model, and the SAME breakpoint layout
(``apply_anthropic_cache_control``, system_and_3). This keeps advisor and
aggregator calls decorated exactly like an acting agent on that provider
would be no MoA-specific caching logic to drift.
Returns the messages unchanged on any resolution error or when the
policy says the route doesn't honor markers.
"""
try:
from types import SimpleNamespace
from agent.agent_runtime_helpers import anthropic_prompt_cache_policy
from agent.prompt_caching import apply_anthropic_cache_control
# The policy function reads agent.* only as fallbacks for kwargs we
# don't pass; provide a stub so the slot is judged purely on its own
# resolved runtime.
stub = SimpleNamespace(provider="", base_url="", api_mode="", model="")
should_cache, native_layout = anthropic_prompt_cache_policy(
stub,
provider=runtime.get("provider") or "",
base_url=runtime.get("base_url") or "",
api_mode=runtime.get("api_mode") or "",
model=runtime.get("model") or "",
)
if not should_cache:
return messages
return apply_anthropic_cache_control(
messages, native_anthropic=native_layout
)
except Exception as exc: # pragma: no cover - decoration must never break a call
logger.debug("MoA cache_control decoration skipped: %s", exc)
return messages
def _run_reference(
slot: dict[str, str],
ref_messages: list[dict[str, Any]],
@ -214,6 +258,18 @@ def _run_reference(
# trimmed view (_reference_messages) already strips the agent's own
# system prompt, so this is the only system message the reference sees.
messages = [{"role": "system", "content": _REFERENCE_SYSTEM_PROMPT}, *ref_messages]
# Apply the same Anthropic-style prompt-caching decoration the main
# agent loop applies (system_and_3 breakpoints). The advisory view is
# append-only across iterations (new turns append before the trailing
# synthetic marker), so on cache-honoring routes (Claude via
# OpenRouter/native, MiniMax, Qwen/DashScope) iteration N+1's prefix
# replays iteration N's cached prefix. Without this, Claude advisors
# served ZERO cache reads across an entire benchmark run (measured:
# 0/1227 calls, 11.5M re-billed input tokens) because Anthropic
# caching is opt-in per request. OpenAI-family advisors are untouched
# (their caching is automatic; markers are ignored harmlessly, but we
# only decorate when the policy says the route honors them).
messages = _maybe_apply_moa_cache_control(messages, runtime)
response = call_llm(
task="moa_reference",
messages=messages,
@ -370,6 +426,14 @@ def _render_tool_calls(tool_calls: Any) -> str:
return "\n".join(lines)
_ADVISORY_INSTRUCTION = (
"[The conversation above is the current state of the task. Give your "
"most intelligent judgement: what is going on, what should happen next, "
"what risks or mistakes you see, and how the acting agent should "
"proceed.]"
)
def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Build an advisory view of the conversation for reference models.
@ -401,13 +465,6 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
The acting aggregator always receives the full, untrimmed transcript; this
function only shapes the disposable advisory copy.
"""
advisory_instruction = (
"[The conversation above is the current state of the task. Give your "
"most intelligent judgement: what is going on, what should happen next, "
"what risks or mistakes you see, and how the acting agent should "
"proceed.]"
)
rendered: list[dict[str, Any]] = []
last_user_content: str | None = None
for msg in messages:
@ -449,7 +506,7 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
# deleting the agent's latest assistant context. This satisfies Anthropic's
# no-trailing-assistant-prefill rule while preserving full state.
if rendered and rendered[-1].get("role") == "assistant":
rendered.append({"role": "user", "content": advisory_instruction})
rendered.append({"role": "user", "content": _ADVISORY_INSTRUCTION})
elif rendered and rendered[-1].get("role") == "user":
# Already ends on a user turn (fresh user prompt, no agent action yet).
# Leave it — the reference answers that prompt directly.
@ -490,14 +547,35 @@ def _extract_text(response: Any) -> str:
return ""
def _preset_temperature(preset: dict[str, Any], key: str) -> float | None:
"""Read an optional temperature from a preset.
Returns None when the key is absent, empty, or explicitly null meaning
"don't send temperature; let the provider default apply", exactly like a
single-model Hermes agent (which never sends temperature unless
configured). The old coercion ``float(preset.get(key, 0.6) or 0.6)``
made unset impossible: absent, null, and even 0 all collapsed to the
hardcoded default, so MoA advisors/aggregator always ran at 0.6/0.4
while the same model running solo used the provider default.
"""
value = preset.get(key)
if value is None or (isinstance(value, str) and not value.strip()):
return None
try:
return float(value)
except (TypeError, ValueError):
logger.warning("ignoring non-numeric %s=%r in MoA preset", key, value)
return None
def aggregate_moa_context(
*,
user_prompt: str,
api_messages: list[dict[str, Any]],
reference_models: list[dict[str, str]],
aggregator: dict[str, str],
temperature: float = 0.6,
aggregator_temperature: float = 0.4,
temperature: float | None = None,
aggregator_temperature: float | None = None,
max_tokens: int | None = None,
) -> str:
"""Run configured reference models and synthesize their advice.
@ -510,6 +588,11 @@ def aggregate_moa_context(
the parameter entirely when it is ``None`` (see its docstring), which also
sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap
here previously truncated long aggregator syntheses.
``temperature`` / ``aggregator_temperature`` are ``None`` by default:
like max_tokens, ``call_llm`` omits temperature when None so the
provider default applies matching single-model agent behavior. Presets
may still pin explicit values.
"""
reference_outputs: list[tuple[str, str, Any]] = []
ref_messages = _reference_messages(api_messages)
@ -535,13 +618,27 @@ def aggregate_moa_context(
)
agg_label = _slot_label(aggregator)
agg_runtime = _slot_runtime(aggregator)
try:
# Same cache_control decoration as _run_reference's advisor calls
# (see _maybe_apply_moa_cache_control) — this synthesis call is a
# third, independent MoA call path that 22c5048d9 did not cover (it
# only restored caching for the acting-aggregator turn in the
# persistent `provider: moa` model and for advisor fan-out). Without
# it, the one-shot `/moa <prompt>` command's synthesis call re-bills
# its full input (system-less prompt containing every joined
# reference output) on every invocation with zero cache_control
# breakpoints, even when the resolved aggregator slot is a
# cache-honoring route (e.g. Claude on OpenRouter/native Anthropic).
agg_messages = _maybe_apply_moa_cache_control(
[{"role": "user", "content": synth_prompt}], agg_runtime
)
response = call_llm(
task="moa_aggregator",
messages=[{"role": "user", "content": synth_prompt}],
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
**_slot_runtime(aggregator),
**agg_runtime,
)
synthesis = _extract_text(response)
except Exception as exc:
@ -726,8 +823,15 @@ class MoAChatCompletions:
# The acting aggregator is never capped here (its output is the
# user-visible answer).
reference_max_tokens = preset.get("reference_max_tokens")
temperature = float(preset.get("reference_temperature", 0.6) or 0.6)
aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4)
# None (the default) = don't send temperature; provider default
# applies, matching single-model agent behavior. Presets may pin
# explicit values. See _preset_temperature.
temperature = _preset_temperature(preset, "reference_temperature")
aggregator_temperature = _preset_temperature(preset, "aggregator_temperature")
if aggregator_temperature is None and api_kwargs.get("temperature") is not None:
# The acting agent's own configured temperature (if any) still
# applies to the aggregator, which IS the acting model.
aggregator_temperature = api_kwargs.get("temperature")
# When the preset is disabled, skip the reference fan-out and let the
# configured aggregator act alone — it is the preset's acting model, so
@ -740,13 +844,41 @@ class MoAChatCompletions:
reference_outputs: list[tuple[str, str, Any]] = []
ref_messages = _reference_messages(messages)
# Fan-out cadence. "per_iteration" (default): advisors re-run whenever
# the advisory view changes — i.e. every tool iteration, since the
# view grows with each tool result. "user_turn": advisors run ONCE per
# user turn; subsequent tool iterations reuse that turn's advice and
# the aggregator acts alone (the original MoA shape: synthesize at the
# start, then let the acting model work). Implemented by hashing only
# the prefix up to the LAST USER message so mid-turn growth doesn't
# change the signature — iteration 2+ becomes a cache HIT.
fanout_mode = str(preset.get("fanout") or "per_iteration").strip().lower()
sig_messages = ref_messages
if fanout_mode == "user_turn":
# Find the last REAL user message. The advisory view appends a
# synthetic user marker (_ADVISORY_INSTRUCTION) when it ends on an
# assistant turn — i.e. on every tool iteration after the first —
# so that marker must not count as a user turn or the prefix
# would include the grown mid-turn context and the signature
# would change every iteration (defeating the once-per-turn
# cadence entirely).
last_user_idx = None
for _i in range(len(ref_messages) - 1, -1, -1):
_m = ref_messages[_i]
if _m.get("role") == "user" and _m.get("content") != _ADVISORY_INSTRUCTION:
last_user_idx = _i
break
if last_user_idx is not None:
sig_messages = ref_messages[: last_user_idx + 1]
# Turn-scoped cache: only run + display references when the advisory
# view changed (i.e. a new user turn). Within one turn the agent loop
# calls create() once per tool iteration with the same advisory view;
# reuse the cached outputs and skip both the re-run and the re-emit.
# calls create() once per tool iteration; in user_turn mode the
# signature is stable across those iterations (prefix hash above), so
# the fan-out runs once per user turn and iterations reuse the advice.
_sig = hashlib.sha256(
"\u0000".join(
f"{m.get('role')}:{m.get('content')}" for m in ref_messages
f"{m.get('role')}:{m.get('content')}" for m in sig_messages
).encode("utf-8", "replace")
).hexdigest()
_cache_key = (self.preset_name, _sig, tuple(_slot_label(s) for s in reference_models))

View file

@ -184,6 +184,15 @@ DEFAULT_FALLBACK_CONTEXT = CONTEXT_PROBE_TIERS[0]
# Sessions, model switches, and cron jobs should reject models below this.
MINIMUM_CONTEXT_LENGTH = 64_000
# Short-lived in-process cache for local-server context probes. Bounds the
# probe rate when the new local-endpoint live-probe paths (reconcile-on-hit +
# pre-defaults step 7) resolve the same model several times during one startup
# (banner, /model switch, compressor update_model). Keyed by (model, base_url);
# values are (result, monotonic_timestamp). Not persisted to disk — cross-
# restart freshness is handled by the reconcile logic re-probing after expiry.
_LOCAL_CTX_PROBE_TTL_SECONDS = 30.0
_LOCAL_CTX_PROBE_CACHE: Dict[tuple, tuple] = {}
# Thin fallback defaults — only broad model family patterns.
# These fire only when provider is unknown AND models.dev/OpenRouter/Anthropic
# all miss. Replaced the previous 80+ entry dict.
@ -296,6 +305,8 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter live metadata reports 262144 (256 × 1024); align the
# static fallback so cache and offline both agree (issue #22268).
"hy3-preview": 262144,
# Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window.
"hy3": 262144,
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
"nemotron": 131072,
# Arcee
@ -496,6 +507,68 @@ def _is_known_provider_base_url(base_url: str) -> bool:
return _infer_provider_from_url(base_url) is not None
def _skip_persistent_context_cache(base_url: str, provider: str) -> bool:
"""Return True when the on-disk context cache must not short-circuit probing.
LM Studio excludes caching because loaded context is transient the user
can reload the model with a different context_length at any time.
"""
return provider == "lmstudio"
def _maybe_cache_local_context_length(
model: str,
base_url: str,
length: int,
) -> None:
"""Persist a locally probed context length only when it meets Hermes minimum.
Sub-minimum live windows (e.g. vLLM ``--max-model-len 32768``) are still
returned to callers so ``agent_init`` can fail with the existing
minimum-context guidance they must not be normalized into the on-disk cache
as if they were valid operating limits.
"""
if length >= MINIMUM_CONTEXT_LENGTH:
save_context_length(model, base_url, length)
def _reconcile_local_cached_context_length(
model: str,
base_url: str,
cached: int,
api_key: str = "",
) -> int:
"""Return *cached* unless a live local probe reports a different limit.
vLLM/Ollama operators can restart with a new ``--max-model-len`` / ``num_ctx``
without changing the model id. When the server is reachable, prefer its
reported window over a stale disk entry; when the probe fails (offline tests,
network blip), keep the cached value.
Live probes below :data:`MINIMUM_CONTEXT_LENGTH` invalidate stale cache
entries but are not persisted startup should reject them, not bless a
sub-64K window as config.
"""
live_ctx = _query_local_context_length(model, base_url, api_key=api_key)
if live_ctx and live_ctx > 0 and live_ctx != cached:
if live_ctx < MINIMUM_CONTEXT_LENGTH:
logger.info(
"Live local probe for %s@%s reports %s (< minimum %s); "
"invalidating stale cache — agent init should reject",
model, base_url, f"{live_ctx:,}", f"{MINIMUM_CONTEXT_LENGTH:,}",
)
_invalidate_cached_context_length(model, base_url)
return live_ctx
logger.info(
"Reconciling stale local cache entry %s@%s: %s -> %s (live probe)",
model, base_url, f"{cached:,}", f"{live_ctx:,}",
)
_invalidate_cached_context_length(model, base_url)
_maybe_cache_local_context_length(model, base_url, live_ctx)
return live_ctx
return cached
def is_local_endpoint(base_url: str) -> bool:
"""Return True if base_url points to a local machine.
@ -1006,6 +1079,8 @@ def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
error_lower = error_msg.lower()
# Pattern: look for numbers near context-related keywords
patterns = [
r'max_model_len\s*(?:is\s*)?[:=(]?\s*(\d{4,})', # vLLM: "max_model_len 32768", "=32768", ": 32768", "(32768)", "is 32768"
r'maximum model length\s*(?:is\s*)?[:=(]?\s*(\d{4,})', # vLLM alt: "maximum model length 131072", "... is 131072"
r'(?:max(?:imum)?|limit)\s*(?:context\s*)?(?:length|size|window)?\s*(?:is|of|:)?\s*(\d{4,})',
r'context\s*(?:length|size|window)\s*(?:is|of|:)?\s*(\d{4,})',
r'(\d{4,})\s*(?:token)?\s*(?:context|limit)',
@ -1451,6 +1526,40 @@ def _model_name_suggests_grok_4_3(model: str) -> bool:
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length (short-TTL cached).
The live-probe paths added for local endpoints (reconcile-on-hit and the
pre-defaults step-7 probe) can fire this function several times in quick
succession during one startup banner display, ``/model`` switch,
compressor ``update_model`` all resolve the same model. Each raw probe
issues synchronous ``detect_local_server_type`` + query HTTP calls (bounded
by the 3s httpx timeout), so an unreachable/slow local server would pay
that cost repeatedly. A tiny in-process TTL cache collapses back-to-back
probes for the same (model, base_url) into one network round-trip without
persisting anything to disk (freshness across restarts is still handled by
the reconcile logic, which probes again once the TTL expires).
"""
import time as _time
cache_key = (_strip_provider_prefix(model), base_url.rstrip("/"))
now = _time.monotonic()
cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key)
if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS:
return cached[0]
result = _query_local_context_length_uncached(model, base_url, api_key=api_key)
# Cache only positive results. A None/failure (server not up yet,
# connection refused, timeout) must NOT be memoized — otherwise a probe
# that fails during a startup race would suppress a legit retry seconds
# later once the server is reachable. Positive-only caching still fully
# bounds the hot-path probe rate (a reachable server returns a value and
# gets cached); an unreachable one simply re-probes on the next call.
if result:
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
return result
def _query_local_context_length_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
@ -1805,8 +1914,8 @@ def get_model_context_length(
e. Ollama native /api/show probe (any base_url, provider-agnostic)
f. models.dev registry lookup (with :cloud/-cloud suffix fallback)
6. OpenRouter live API metadata (Kimi-family 32k guard)
7. Hardcoded defaults (broad family patterns, longest-key-first)
8. Local server query (last resort)
7. Local server query (before hardcoded defaults for local endpoints)
8. Hardcoded defaults (broad family patterns, longest-key-first)
9. Default fallback (256K)"""
# 0. Explicit config override — user knows best
if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
@ -1866,7 +1975,7 @@ def get_model_context_length(
# LM Studio is excluded — its loaded context length is transient (the
# user can reload the model with a different context_length at any time
# via /api/v1/models/load), so a stale cached value would mask reloads.
if base_url and provider != "lmstudio":
if base_url and not _skip_persistent_context_cache(base_url, provider):
cached = get_cached_context_length(model, base_url)
if cached is not None:
# Invalidate stale Codex OAuth cache entries: pre-PR #14935 builds
@ -1931,6 +2040,10 @@ def get_model_context_length(
)
# Fall through; step 5b reconciles and overwrites if portal responds.
else:
if is_local_endpoint(base_url):
return _reconcile_local_cached_context_length(
model, base_url, cached, api_key=api_key,
)
return cached
# 1b. AWS Bedrock — use static context length table.
@ -1975,14 +2088,15 @@ def get_model_context_length(
# 404/405 quickly. Fall through on failure.
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
save_context_length(model, base_url, ctx)
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
# 3. Try querying local server directly
if is_local_endpoint(base_url):
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
if local_ctx and local_ctx > 0:
if provider != "lmstudio":
save_context_length(model, base_url, local_ctx)
if not _skip_persistent_context_cache(base_url, provider):
_maybe_cache_local_context_length(model, base_url, local_ctx)
return local_ctx
logger.info(
"Could not detect context length for model %r at %s"
@ -2076,20 +2190,29 @@ def get_model_context_length(
ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
if ctx is not None:
return ctx
# 5e. Ollama native /api/show probe — runs for ANY provider with a
# base_url, not just ollama-cloud. Ollama-compatible servers expose
# 5e. Ollama native /api/show probe — runs for providers whose base_url
# is NOT a known non-Ollama provider. Ollama-compatible servers expose
# this endpoint regardless of hostname (local Ollama, Ollama Cloud,
# custom Ollama hosting). The OpenAI-compat /v1/models endpoint
# correctly omits context_length per the OpenAI schema, but /api/show
# returns the authoritative GGUF model_info.context_length.
# For non-Ollama servers (OpenAI, Anthropic, etc.), the POST returns
# 404/405 quickly. Results are cached, so the hit is per-model+URL,
# once per hour.
# Known hosted providers (OpenRouter, Anthropic, OpenAI, …) are skipped:
# they are definitively not Ollama, the POST always 404s, and the result
# is never cached for them — so every fresh process used to pay a
# ~300ms blocking HTTP round-trip on the first-turn critical path
# (measured against openrouter.ai; worse on slow DNS).
if base_url:
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
save_context_length(model, base_url, ctx)
return ctx
_inferred_for_probe = _infer_provider_from_url(base_url)
_skip_ollama_probe = (
_inferred_for_probe is not None
and "ollama" not in _inferred_for_probe
)
if not _skip_ollama_probe:
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
# models. OpenRouter's catalog carries per-model context_length (e.g.
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
@ -2147,7 +2270,15 @@ def get_model_context_length(
else:
return or_ctx
# 7. (reserved)
# 7. Query local server before hardcoded defaults — model names like
# ``Hermes-3-Llama-3.1-70B`` substring-match ``llama`` (131072) even when
# vLLM is running at a lower ``--max-model-len`` (e.g. 32768 on limited VRAM).
if base_url and is_local_endpoint(base_url):
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
if local_ctx and local_ctx > 0:
if not _skip_persistent_context_cache(base_url, provider):
_maybe_cache_local_context_length(model, base_url, local_ctx)
return local_ctx
# 8. Hardcoded defaults (fuzzy match — longest key first for specificity)
# Only check `default_model in model` (is the key a substring of the input).
@ -2160,15 +2291,7 @@ def get_model_context_length(
if default_model in model_lower:
return length
# 9. Query local server as last resort
if base_url and is_local_endpoint(base_url):
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
if local_ctx and local_ctx > 0:
if provider != "lmstudio":
save_context_length(model, base_url, local_ctx)
return local_ctx
# 10. Default fallback — 256K
# 9. Default fallback — 256K
return DEFAULT_FALLBACK_CONTEXT

View file

@ -209,7 +209,7 @@ def mark_seen(config_path: Path, flag: str) -> bool:
"""
try:
import yaml
from utils import atomic_yaml_write
from hermes_cli.config import atomic_config_write
except Exception as e: # pragma: no cover — dependency issue
logger.debug("onboarding: failed to import yaml/utils: %s", e)
return False
@ -228,7 +228,7 @@ def mark_seen(config_path: Path, flag: str) -> bool:
if seen.get(flag) is True:
return True # already marked — nothing to do
seen[flag] = True
atomic_yaml_write(config_path, cfg)
atomic_config_write(config_path, cfg)
return True
except Exception as e:
logger.debug("onboarding: failed to mark flag %s: %s", flag, e)

View file

@ -423,7 +423,7 @@ def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
size = len(payload)
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
args = ["inline=1", f"size={size}", "preserveAspectRatio=1"]
if cell_cols:
args.append(f"width={cell_cols}")
if cell_rows:

View file

@ -151,42 +151,51 @@ def build_keepalive_http_client(
"""Build an httpx client for OpenAI SDK calls with env-only proxy policy.
Uses explicit ``HTTPS_PROXY`` / ``NO_PROXY`` env vars via
``_get_proxy_for_base_url``. A custom transport disables httpx's default
``trust_env`` path, so macOS system proxy settings from
``_get_proxy_for_base_url``. Plain no-proxy mounts disable httpx's default
``trust_env`` proxy path, so macOS system proxy settings from
``urllib.request.getproxies()`` (which omit the ExceptionsList) are not
applied. Mirrors ``AIAgent._build_keepalive_http_client``.
Connection lifecycle is managed at the HTTP pool layer
(``keepalive_expiry=20.0`` reaps idle connections before reverse proxies'
typical 30-60 s timeouts) instead of the former custom
``socket_options`` transport, which broke streaming behind reverse
proxies (#54049, #12952) and stalled TLS handshakes by stripping
``TCP_NODELAY``.
``verify`` is forwarded to httpx so auxiliary-client calls (compression,
vision, web_extract, title generation, etc.) honor the same per-provider
``ssl_ca_cert`` / ``ssl_verify`` and ``HERMES_CA_BUNDLE`` settings the main
client uses. It is passed on the ``HTTPTransport`` (which owns the SSL
context when a custom transport is supplied) and, for the copilot branch
that has no custom transport, on the client itself.
client uses. It is passed on the client AND on the plain no-proxy mounts
(a mounted transport owns the SSL context for its scheme).
"""
try:
import httpx
import socket
if "api.githubcopilot.com" in str(base_url or "").lower():
client_cls = httpx.AsyncClient if async_mode else httpx.Client
return client_cls(verify=verify)
sock_opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
if hasattr(socket, "TCP_KEEPIDLE"):
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30))
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10))
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3))
elif hasattr(socket, "TCP_KEEPALIVE"):
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 30))
proxy = _get_proxy_for_base_url(base_url)
limits = httpx.Limits(
max_keepalive_connections=20,
max_connections=100,
keepalive_expiry=20.0,
)
# Generous read=None for SSE streaming endpoints.
timeout = httpx.Timeout(connect=15.0, read=None, write=15.0, pool=10.0)
transport_cls = httpx.AsyncHTTPTransport if async_mode else httpx.HTTPTransport
client_cls = httpx.AsyncClient if async_mode else httpx.Client
# verify lives on the transport: httpx ignores the client-level
# ``verify`` when a custom ``transport=`` is supplied.
mounts = {}
if proxy is None:
mounts = {
"http://": transport_cls(verify=verify),
"https://": transport_cls(verify=verify),
}
return client_cls(
transport=transport_cls(socket_options=sock_opts, verify=verify),
limits=limits,
timeout=timeout,
proxy=proxy,
mounts=mounts or None,
verify=verify,
)
except Exception:
return None

View file

@ -743,6 +743,17 @@ PLATFORM_HINTS = {
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"desktop": (
"You are chatting inside the Hermes desktop app — a graphical chat "
"surface, not a terminal. Use markdown freely: it renders with full "
"GitHub flavor (tables, code blocks with syntax highlighting, math "
"via $...$, task lists, blockquote callouts). "
"You can deliver files natively — include MEDIA:/absolute/path/to/file "
"in your response. Images (.png, .jpg, .webp) appear inline, audio and "
"video play inline, and other files arrive as download links. You can "
"also include image URLs in markdown format ![alt](url) and they "
"render inline as photos."
),
"sms": (
"You are communicating via SMS. Keep responses concise and use plain text "
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
@ -1127,22 +1138,6 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)
# Hermes desktop GUI — any agent running under the desktop app should know
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
_truthy = ("1", "true", "yes")
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
if _in_desktop or _in_desktop_term:
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
if _in_desktop_term:
_desktop_hint += (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
"⌘/Ctrl+L to send it to the chat composer."
)
hints.append(_desktop_hint)
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)

View file

@ -17,12 +17,23 @@ def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool =
role = msg.get("role", "")
content = msg.get("content")
if role == "tool":
if native_anthropic:
msg["cache_control"] = cache_marker
if role == "tool" and native_anthropic:
# Native Anthropic layout: top-level marker; the adapter moves it
# inside the tool_result block.
msg["cache_control"] = cache_marker
return
if content is None or content == "":
if role == "tool" and not native_anthropic:
# OpenRouter rejects top-level cache_control on role:tool (silent
# hang) and an empty message has no content part to carry the
# marker — skip. Non-empty tool content falls through below and
# gets the marker on a content part, which OpenRouter honors.
return
if role == "assistant" and not native_anthropic:
# Empty assistant turns are pure tool_calls. A top-level marker
# here is ignored on the envelope layout, so skip.
return
msg["cache_control"] = cache_marker
return
@ -38,6 +49,30 @@ def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool =
last["cache_control"] = cache_marker
def _can_carry_marker(msg: dict, native_anthropic: bool) -> bool:
"""True if a marker on this message is actually honored by the provider.
On the native Anthropic layout every message works (top-level markers are
relocated by the adapter). On the envelope layout (OpenRouter et al.) only
markers inside content parts are honored: empty-content messages (e.g.
assistant turns that are pure tool_calls) and empty tool messages would
receive a top-level marker the provider ignores wasting one of the four
breakpoints. Skip those so the breakpoints land on messages that count.
"""
if native_anthropic:
return True
content = msg.get("content")
if content is None or content == "":
return False
if isinstance(content, list):
# _apply_cache_marker only marks the LAST content part, so the carrier
# predicate must agree: a list whose last element isn't a dict cannot
# actually receive a marker and would waste a breakpoint. Mirror the
# `content` truthiness + last-element-dict check in _apply_cache_marker.
return bool(content) and isinstance(content[-1], dict)
return isinstance(content, str)
def _build_marker(ttl: str) -> Dict[str, str]:
"""Build a cache_control marker dict for the given TTL ('5m' or '1h')."""
marker: Dict[str, str] = {"type": "ephemeral"}
@ -72,7 +107,12 @@ def apply_anthropic_cache_control(
breakpoints_used += 1
remaining = 4 - breakpoints_used
non_sys = [i for i in range(len(messages)) if messages[i].get("role") != "system"]
non_sys = [
i
for i in range(len(messages))
if messages[i].get("role") != "system"
and _can_carry_marker(messages[i], native_anthropic=native_anthropic)
]
for idx in non_sys[-remaining:]:
_apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)

View file

@ -107,7 +107,9 @@ _PREFIX_PATTERNS = [
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
r"fw-[A-Za-z0-9]{30,}", # Fireworks AI API key
r"fw_[A-Za-z0-9]{30,}", # Fireworks AI API key
r"fpk_[A-Za-z0-9]{30,}", # Fireworks AI project key
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name.
@ -137,6 +139,14 @@ _ENV_ASSIGN_RE = re.compile(
# The colon-form URL guard (skip when ``://`` present) lives at the call site.
_SECRET_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential|auth)"
_CFG_VALUE = r"(['\"]?)([^\s&]+?)\2(?=[\s&]|$)"
# Programmatic env lookups (``os.getenv(...)``, ``os.environ[...]``,
# ``os.environ.get(...)``, ``process.env.X``, ``$ENV{X}``) reference variable
# *names*, not secret values. When one appears as the VALUE of a KEY=... match
# it's a code snippet, not a leaked secret — skip redaction (issue #2852).
_ENV_LOOKUP_VALUE_RE = re.compile(
r"^(?:os\.(?:getenv|environ)|process\.env|\$ENV\{)"
)
# Namespaced (dotted) key: the secret word may sit anywhere in a dotted path.
_CFG_DOTTED_RE = re.compile(
rf"((?:[A-Za-z0-9_\-]+\.)+[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*"
@ -541,6 +551,11 @@ def redact_sensitive_text(
if "=" in text:
def _redact_env(m):
name, quote, value = m.group(1), m.group(2), m.group(3)
# Programmatic env lookups reference variable *names*, not
# secret values — masking them corrupts code snippets in
# prose/log contexts (issue #2852): ``KEY=os.getenv('X')``.
if _ENV_LOOKUP_VALUE_RE.match(value):
return m.group(0)
return f"{name}={quote}{_mask_token(value)}{quote}"
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
@ -555,6 +570,11 @@ def redact_sensitive_text(
if ":" in text and '"' in text:
def _redact_json(m):
key, value = m.group(1), m.group(2)
# Same programmatic-env-lookup exception as _redact_env above
# (issue #2852): "apiKey": "os.getenv('X')" is a code snippet,
# not a leaked secret value.
if _ENV_LOOKUP_VALUE_RE.match(value):
return m.group(0)
return f'{key}: "{_mask_token(value)}"'
text = _JSON_FIELD_RE.sub(_redact_json, text)
@ -564,6 +584,11 @@ def redact_sensitive_text(
if ":" in text and "://" not in text:
def _redact_yaml(m):
key, sep, value = m.group(1), m.group(2), m.group(3)
# Same programmatic-env-lookup exception as _redact_env above
# (issue #2852): api_key: os.getenv('X') is a code snippet,
# not a leaked secret value.
if _ENV_LOOKUP_VALUE_RE.match(value):
return m.group(0)
return f"{key}{sep}{_mask_token(value)}"
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)

View file

@ -138,3 +138,120 @@ def sanitize_replay_history(
if not agent_history:
return agent_history
return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history))
# ──────────────────────────────────────────────────────────────────────
# Stale dangerous-confirmation text expiry (#59607)
# ──────────────────────────────────────────────────────────────────────
# How long a high-risk confirmation phrase remains valid.
# Short on purpose: dangerous side effects should not survive any restart
# or session resumption gap. The user can always re-confirm if needed.
_DANGEROUS_CONFIRMATION_EXPIRY_SECONDS = 60.0
# Confirmation phrases that unlock destructive host actions.
# Substring match (case-insensitive) so that user variants (e.g. trailing
# punctuation, additional context) still match. Add new patterns here when
# new high-risk actions are introduced.
_DANGEROUS_CONFIRMATION_PATTERNS: tuple = (
"confirm forced restart",
"confirm forced reboot",
"confirm shutdown",
"confirm reboot",
"confirm power off",
"yes, delete everything",
"confirm wipe",
"confirm factory reset",
# i18n variants observed in the original incident
"確認強制重開機",
"確認強制重開",
"確認重啟",
)
# Replacement text for an expired confirmation. Redacting in place (rather
# than deleting the message) preserves strict user/assistant role
# alternation in the replayed history.
_EXPIRED_CONFIRMATION_SENTINEL = (
"[A high-risk confirmation previously given here has EXPIRED and must "
"not be acted on. Ask the user to re-confirm explicitly before "
"performing any destructive action.]"
)
def is_dangerous_confirmation(content: Any) -> bool:
"""Return True if a user-message text matches a known dangerous confirmation.
Used by ``strip_stale_dangerous_confirmations`` to decide which
transcript rows to expire. Substring + case-insensitive so that
``"Please confirm forced restart, the host is critical"`` still matches.
"""
if not isinstance(content, str):
return False
text = content.strip().lower()
return any(pattern in text for pattern in _DANGEROUS_CONFIRMATION_PATTERNS)
def strip_stale_dangerous_confirmations(
agent_history: List[Dict[str, Any]],
*,
now: float,
expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS,
) -> List[Dict[str, Any]]:
"""Expire stale dangerous-confirmation text in user messages (#59607).
When a high-risk side effect (e.g. host restart via ``shutdown.exe``)
runs, the user's plain-text confirmation phrase is persisted in the
conversation transcript. If the host restart killed the gateway
process before the assistant's tool result was written, the
transcript tail ends on the assistant's text response — and the
dangerous confirmation text remains in the user role.
On the next inbound message possibly a casual "are you there?" from
the user minutes later the LLM sees the stale confirmation and may
interpret the new turn as a fresh re-confirmation, re-executing the
destructive action. This is the failure mode reported in #59607.
Expired confirmations are REDACTED IN PLACE, not removed: deleting a
user message from the incident tail (``user(confirm)
assistant("OK, restarting")``) would leave two consecutive assistant
messages, violating the strict role-alternation invariant providers
enforce. The message survives with its role intact; only the trigger
text is replaced by a sentinel that tells the model the confirmation
has expired.
Messages without a timestamp are left untouched (backward
compatibility: legacy transcripts and in-memory test scaffolding have
no timestamps). User messages that contain dangerous confirmation
text but are within the expiry window are also left untouched they
represent a fresh confirmation that has not yet been acted on.
Complements 75ed07ace (which strips the *assistant* side of the
broken tail) by handling the *user* side: a stale plain-text
confirmation that the assistant has not yet responded to in a way
the resume logic recognises.
"""
if not agent_history:
return agent_history
cleaned: List[Dict[str, Any]] = []
for msg in agent_history:
if (
isinstance(msg, dict)
and msg.get("role") == "user"
and is_dangerous_confirmation(msg.get("content", ""))
):
ts = msg.get("timestamp")
if ts is not None and (now - float(ts)) > expiry_seconds:
logger.debug(
"Redacting stale dangerous-confirmation text in user "
"message (age=%.1fs, expiry=%.1fs): %r",
now - float(ts),
expiry_seconds,
(msg.get("content") or "")[:80],
)
redacted = dict(msg)
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
cleaned.append(redacted)
continue
cleaned.append(msg)
return cleaned

View file

@ -24,6 +24,14 @@ _jitter_lock = threading.Lock()
# not sit silent for 20+ minutes.
_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0)
# Number of initial short retries before the adaptive long-backoff tier kicks
# in. Shared by ``adaptive_rate_limit_backoff`` (which walks the long table
# starting at attempt ``short_attempts + 1``) and
# ``zai_coding_overload_retry_ceiling`` (which sizes the retry loop so every
# long-tier entry is reachable). Keeping it a single module constant prevents
# the two from silently desyncing if the short-retry count is ever tuned.
_ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS = 3
def jittered_backoff(
attempt: int,
@ -104,7 +112,7 @@ def adaptive_rate_limit_backoff(
model: str | None,
error: Any,
default_wait: float,
short_attempts: int = 3,
short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS,
) -> tuple[float, str | None]:
"""Provider-aware rate-limit backoff.
@ -127,3 +135,20 @@ def adaptive_rate_limit_backoff(
# A smaller jitter ratio keeps long waits readable while still avoiding
# synchronized retry storms across concurrent Hermes sessions.
return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long"
def zai_coding_overload_retry_ceiling(short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS) -> int:
"""Retry-loop ceiling needed for the full Z.AI overload backoff schedule.
The adaptive policy runs ``short_attempts`` short retries, then walks the
long-backoff table one entry per subsequent attempt. The retry loop gives
up as soon as ``retry_count >= ceiling`` and that check runs *before* the
attempt's backoff is computed — so the ceiling must sit one past the final
long-backoff entry for every long tier to actually execute.
With the default ``api_max_retries`` (3) equal to ``short_attempts`` (3),
the loop always gave up before reaching the long tier, leaving the whole
long-backoff schedule as dead code. Callers extend the ceiling to this
value for Z.AI Coding overload 429s so the 30/60/90/120s waits run.
"""
return short_attempts + len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) + 1

View file

@ -1,13 +1,41 @@
"""External secret source integrations.
A secret source is anything that can supply environment-variable-shaped
credentials at process startup, _after_ ~/.hermes/.env has loaded. By
default sources are non-destructive: they only set values for env vars
that aren't already present, so .env and shell exports continue to win.
credentials at process startup, _after_ ~/.hermes/.env has loaded.
Currently shipped:
The contract every source implements is
:class:`agent.secret_sources.base.SecretSource`; the orchestrator that
runs the enabled sources (ordering, mapped-beats-bulk precedence,
first-claim-wins conflicts, ``override_existing`` semantics, provenance)
is :func:`agent.secret_sources.registry.apply_all`. Multiple sources
can be enabled at once see the registry module docstring for the
precedence ladder. The atomic-write / 0600 / TTL disk-cache substrate
is shared across backends in ``agent.secret_sources._cache`` so the
security-sensitive bits live in exactly one place.
Currently bundled:
- ``bitwarden`` Bitwarden Secrets Manager (`bws` CLI). See
``agent.secret_sources.bitwarden`` for the integration and
``hermes_cli.secrets_cli`` for the user-facing setup wizard.
- ``onepassword`` 1Password ``op://`` secret references (`op` CLI).
See ``agent.secret_sources.onepassword`` for the integration and
``hermes_cli.onepassword_secrets_cli`` for the user-facing commands.
The bundled set is deliberately closed (policy mirrors memory
providers): new third-party secret managers ship as standalone plugin
repos that subclass ``SecretSource`` and register through
``PluginContext.register_secret_source()`` they are NOT added to this
package. A generic ``command`` source is a possible future exception;
OS keystores (Keychain/DPAPI/libsecret) are under discussion.
"""
from agent.secret_sources.base import ( # noqa: F401
SECRET_SOURCE_API_VERSION,
ErrorKind,
FetchResult,
SecretSource,
is_valid_env_name,
run_secret_cli,
scrub_ansi,
)

View file

@ -0,0 +1,213 @@
"""Shared substrate for external secret-source backends.
Every backend (Bitwarden, 1Password, ) needs the same handful of
security-sensitive primitives:
* a uniform result object (:class:`FetchResult`),
* environment-variable name validation (:func:`is_valid_env_name`),
* a two-layer fetch cache whose disk half writes atomically with ``0600``
permissions and honours a TTL (:class:`DiskCache`, :class:`CachedFetch`).
These used to live inline inside ``bitwarden.py``. Pulling them here means
the atomic-write / ``0600`` / TTL logic is audited and fixed in exactly one
place instead of drifting across copy-pasted per-backend modules each
backend supplies only its own cache-key shape and a serializer for it.
Nothing in this module ever raises out to the caller's hot path: the disk
layer is strictly best-effort (a miss just triggers a refetch), because a
cache problem must never block Hermes startup.
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Generic, Optional, TypeVar
__all__ = [
"FetchResult",
"CachedFetch",
"DiskCache",
"is_valid_env_name",
"resolve_cache_home",
]
# ---------------------------------------------------------------------------
# Result object + env-name validation — canonical definitions live in
# ``agent.secret_sources.base`` (the SecretSource contract module); re-exported
# here so backends that import from ``_cache`` keep working.
# ---------------------------------------------------------------------------
from agent.secret_sources.base import ( # noqa: E402
FetchResult,
is_valid_env_name,
)
# ---------------------------------------------------------------------------
# Cache entry
# ---------------------------------------------------------------------------
@dataclass
class CachedFetch:
"""A set of fetched secret values plus when they were fetched."""
secrets: Dict[str, str]
fetched_at: float
def is_fresh(self, ttl_seconds: float) -> bool:
if ttl_seconds <= 0:
return False
return (time.time() - self.fetched_at) < ttl_seconds
# ---------------------------------------------------------------------------
# Disk cache
# ---------------------------------------------------------------------------
def resolve_cache_home(home_path: Optional[Path] = None) -> Path:
"""Resolve the Hermes home used for cache paths.
``home_path`` is whatever ``load_hermes_dotenv()`` already resolved;
falling back to ``$HERMES_HOME`` / ``~/.hermes`` keeps direct callers
(and tests that don't thread a home through) working.
"""
if home_path is None:
home_path = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
return home_path
K = TypeVar("K")
class DiskCache(Generic[K]):
"""Best-effort, profile-aware on-disk cache for fetched secret values.
One JSON object per backend lives at ``<hermes_home>/cache/<basename>``::
{"key": "<serialized cache key>", "secrets": {...}, "fetched_at": 1.0}
The file holds only secret *values* keyed by the serialized cache key
never raw auth material. Backends are responsible for fingerprinting
tokens/sessions *before* they reach ``key_serializer`` so the token can't
land in the key.
Writes are atomic (``mkstemp`` ``chmod 0600`` ``os.replace``) and the
containing ``cache/`` directory is forced to ``0700`` ``mkdir``'s mode is
umask-subject, so the chmod is the reliable form. Both ``read`` and
``write`` short-circuit when ``ttl_seconds <= 0``, so setting the TTL to
zero disables *both* cache layers symmetrically: a user opting out never
gets secret values written to disk at all.
"""
def __init__(self, basename: str, *, key_serializer: Callable[[K], str]) -> None:
self._basename = basename
self._key_serializer = key_serializer
# Temp-file prefix derived from the basename so concurrent writers for
# different backends in the same dir don't collide on the staging name.
stem = basename.split(".", 1)[0]
self._tmp_prefix = f".{stem}_"
def path(self, home_path: Optional[Path] = None) -> Path:
return resolve_cache_home(home_path) / "cache" / self._basename
def read(
self,
key: K,
ttl_seconds: float,
home_path: Optional[Path] = None,
) -> Optional[CachedFetch]:
"""Return a fresh cached entry for ``key``, or None.
Best-effort: any I/O or parse error, a key mismatch, or a stale entry
all return None so the caller re-fetches.
"""
if ttl_seconds <= 0:
return None
path = self.path(home_path)
try:
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
if payload.get("key") != self._key_serializer(key):
return None
secrets = payload.get("secrets")
fetched_at = payload.get("fetched_at")
if not isinstance(secrets, dict) or not isinstance(fetched_at, (int, float)):
return None
# JSON permits non-string values; env vars need strings, so coerce by
# dropping anything that isn't a str→str pair.
typed: Dict[str, str] = {
k: v for k, v in secrets.items() if isinstance(k, str) and isinstance(v, str)
}
entry = CachedFetch(secrets=typed, fetched_at=float(fetched_at))
if not entry.is_fresh(ttl_seconds):
return None
return entry
def write(
self,
key: K,
entry: CachedFetch,
ttl_seconds: float,
home_path: Optional[Path] = None,
) -> None:
"""Persist ``entry`` for ``key`` atomically at mode ``0600``.
No-op when ``ttl_seconds <= 0`` (so caching is genuinely off) or on any
I/O error the next invocation just re-fetches.
"""
if ttl_seconds <= 0:
return
path = self.path(home_path)
try:
cache_dir = path.parent
cache_dir.mkdir(parents=True, exist_ok=True)
# mkdir's mode is umask-subject; chmod the dir to 0700 so cache
# metadata isn't exposed if HERMES_HOME is ever made traversable.
try:
os.chmod(cache_dir, 0o700)
except OSError:
pass
payload = {
"key": self._key_serializer(key),
"secrets": entry.secrets,
"fetched_at": entry.fetched_at,
}
# Write to a sibling temp file and atomic-rename. tempfile honours
# os.umask, so we explicitly chmod 0600 before the rename.
fd, tmp = tempfile.mkstemp(
prefix=self._tmp_prefix, suffix=".tmp", dir=str(cache_dir)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
pass # best-effort — a disk-cache miss next invocation is fine
def clear(self, home_path: Optional[Path] = None) -> None:
"""Delete the on-disk cache file if present (idempotent)."""
try:
self.path(home_path).unlink()
except (FileNotFoundError, OSError):
pass

View file

@ -0,0 +1,274 @@
"""Secret-source contract: the ABC every secret backend implements.
A *secret source* resolves credentials from an external secret manager
(Bitwarden Secrets Manager, 1Password, an OS keystore, a user script, ...)
into environment-variable-shaped values at process startup, AFTER
``~/.hermes/.env`` has loaded and BEFORE the rest of Hermes reads
``os.environ``.
Scope of the contract (deliberate, please do not widen):
* **Read-only.** Sources resolve refs values. There is no write-back
("save this key to your vault"), no arbitrary secret objects, and no
mid-session secret API. If a future need for rotation/refresh appears
it will arrive as a versioned optional hook do not bolt it on.
* **Startup-time, synchronous.** ``fetch()`` is called once per process
(per HERMES_HOME) by the orchestrator in
:mod:`agent.secret_sources.registry`, which enforces a wall-clock
timeout around it. Sources must not spawn background refreshers.
* **Never raises, never prompts.** ``fetch()`` returns a
:class:`FetchResult` errors go in ``result.error`` with a
machine-readable :class:`ErrorKind`. Interactive auth belongs in the
source's CLI ``setup`` flow, never on the startup path (non-TTY
gateway/cron startup must never block on stdin).
* **Sources fetch; the orchestrator applies.** A source returns the
namevalue mapping it *would* contribute. Precedence (mapped-beats-bulk,
first-wins, ``override_existing``, protected vars), conflict warnings,
provenance tracking, and the actual ``os.environ`` writes are owned by
the orchestrator so no backend can get them wrong.
Versioning: ``SECRET_SOURCE_API_VERSION`` gates plugin compatibility.
New *optional* hooks with default implementations do not bump it;
required-signature changes do, and the registry skips (with a warning)
sources built against a different major version instead of crashing
startup.
"""
from __future__ import annotations
import os
import re
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, FrozenSet, List, Optional, Sequence
# Bump ONLY for breaking changes to the required contract surface
# (abstract-method signatures, FetchResult required fields). Additive
# optional hooks must ship with defaults and must NOT bump this.
SECRET_SOURCE_API_VERSION = 1
# Timeout the orchestrator enforces around fetch() when the source's
# config section doesn't override it. Generous because a first run may
# include a one-time CLI binary auto-install (e.g. bws download+verify).
DEFAULT_FETCH_TIMEOUT_SECONDS = 120.0
# Default timeout for run_secret_cli() subprocess invocations.
DEFAULT_CLI_TIMEOUT_SECONDS = 30.0
class ErrorKind(str, Enum):
"""Machine-readable failure taxonomy for :class:`FetchResult.error`.
A fixed vocabulary keeps startup warnings and ``hermes secrets status``
uniform across backends, and lets the orchestrator implement
kind-dependent policy (e.g. a future stale-cache fallback on
``NETWORK``/``TIMEOUT`` but not on ``AUTH_FAILED``) exactly once.
"""
NOT_CONFIGURED = "not_configured" # enabled but missing token/project/map
BINARY_MISSING = "binary_missing" # helper CLI not found / not installed
AUTH_FAILED = "auth_failed" # bad credentials
AUTH_EXPIRED = "auth_expired" # credentials were valid, aren't now
REF_INVALID = "ref_invalid" # a secret reference failed validation
NETWORK = "network" # transport-level failure
EMPTY_VALUE = "empty_value" # backend returned nothing for a ref
TIMEOUT = "timeout" # fetch exceeded its wall-clock budget
INTERNAL = "internal" # anything else (bug, unexpected shape)
@dataclass
class FetchResult:
"""Outcome of one source's fetch.
``secrets`` holds what the source *would* contribute; whether each
var is actually applied is the orchestrator's decision. ``applied``
and ``skipped`` exist for backward compatibility with the original
Bitwarden fetch-and-apply entry point and are left empty by
conforming ``fetch()`` implementations.
"""
secrets: Dict[str, str] = field(default_factory=dict)
applied: List[str] = field(default_factory=list)
skipped: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
error: Optional[str] = None
error_kind: Optional[ErrorKind] = None
# Path of the helper binary used, when the source is CLI-driven.
# Surfaced by status commands; None for SDK/API-driven sources.
binary_path: Optional[Path] = None
@property
def ok(self) -> bool:
return self.error is None
class SecretSource(ABC):
"""One external secret backend.
Subclasses set the class attributes and implement :meth:`fetch`.
Everything else has a sensible default.
Attributes:
name: Config-section key under ``secrets:`` in config.yaml.
Lowercase ``[a-z0-9_]+``. Also the provenance label stored
for every var this source supplies.
label: Human-readable name used in startup messages and
``hermes secrets status`` (e.g. ``"Bitwarden Secrets Manager"``).
shape: ``"mapped"`` when the user explicitly binds env-var names
to refs (1Password ``env:`` map, command source) or
``"bulk"`` when the backend injects whole projects/folders
of secrets implicitly (Bitwarden BSM). The orchestrator
gives mapped sources precedence over bulk sources: an
explicit binding is stronger intent than a project dump.
scheme: Optional URI scheme this source owns for secret
references (``"op"`` for ``op://...``). Must be unique
across registered sources refs may eventually appear
outside the ``secrets:`` block (e.g. credential-pool
``api_key`` fields), so scheme collisions are rejected at
registration time to keep that future possible.
api_version: Contract version this source was built against.
"""
api_version: int = SECRET_SOURCE_API_VERSION
name: str = ""
label: str = ""
shape: str = "mapped" # "mapped" | "bulk"
scheme: Optional[str] = None
# -- required ----------------------------------------------------------
@abstractmethod
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
"""Resolve this source's secrets. MUST NOT raise or prompt.
``cfg`` is the source's raw config section (``secrets.<name>``)
from config.yaml treat every field defensively, the section
may be malformed. ``home_path`` is the resolved HERMES_HOME.
"""
# -- optional hooks (defaults are correct for most sources) ------------
def is_enabled(self, cfg: dict) -> bool:
"""Whether the user turned this source on."""
return bool(isinstance(cfg, dict) and cfg.get("enabled"))
def override_existing(self, cfg: dict) -> bool:
"""May this source overwrite vars that .env / the shell already set?
This NEVER extends to vars claimed by another secret source in the
same startup pass cross-source overrides are a config error the
orchestrator warns about, not a knob.
"""
return bool(isinstance(cfg, dict) and cfg.get("override_existing", False))
def protected_env_vars(self, cfg: dict) -> FrozenSet[str]:
"""Env vars the orchestrator must never let ANY source overwrite.
Typically the source's own bootstrap-auth var (e.g.
``BWS_ACCESS_TOKEN``) so a vault that contains its own access
token can't clobber the credential used to reach it.
"""
return frozenset()
def fetch_timeout_seconds(self, cfg: dict) -> float:
"""Wall-clock budget the orchestrator enforces around fetch()."""
try:
val = float((cfg or {}).get("timeout_seconds", DEFAULT_FETCH_TIMEOUT_SECONDS))
except (TypeError, ValueError):
return DEFAULT_FETCH_TIMEOUT_SECONDS
return val if val > 0 else DEFAULT_FETCH_TIMEOUT_SECONDS
def config_schema(self) -> dict:
"""Optional description of this source's config keys.
Shape: ``{key: {"description": str, "default": Any}}``. Used by
setup surfaces to render config without hardcoding per-source
knowledge. Purely informational.
"""
return {}
# ---------------------------------------------------------------------------
# Shared helpers — use these instead of hand-rolling per backend
# ---------------------------------------------------------------------------
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# ANSI CSI/OSC escape sequences — helper-CLI stderr often carries color
# codes that must not reach Hermes' own startup output.
_ANSI_RE = re.compile(r"\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)")
def is_valid_env_name(name: str) -> bool:
"""True when ``name`` is a legal environment-variable name."""
return bool(name) and bool(_ENV_NAME_RE.match(name))
def scrub_ansi(text: str) -> str:
"""Strip ANSI escape sequences (whole CSI/OSC sequences, not just ESC)."""
return _ANSI_RE.sub("", text or "")
def run_secret_cli(
argv: Sequence[str],
*,
allow_env: Sequence[str] = (),
extra_env: Optional[Dict[str, str]] = None,
timeout: float = DEFAULT_CLI_TIMEOUT_SECONDS,
) -> subprocess.CompletedProcess:
"""Run a secret-manager helper CLI with a minimal, allowlisted env.
Security posture shared by every subprocess-driven backend:
* argv list only never ``shell=True``. Callers pass user-supplied
reference strings AFTER a ``--`` option terminator in their argv.
* The child gets ``PATH``/``HOME``/locale basics plus only the env
vars named in ``allow_env`` (auth/session vars) and ``extra_env``
never a copy of the full post-dotenv ``os.environ``, which by
this point holds every credential Hermes knows about.
* ``NO_COLOR=1`` is set and stderr/stdout are ANSI-scrubbed so
helper diagnostics can't smuggle escape sequences into Hermes
output.
* stdin is ``/dev/null`` so a helper that decides to prompt fails
fast instead of hanging startup.
Raises ``RuntimeError`` on spawn failure or timeout (message safe to
surface); returns the completed process otherwise callers own
returncode interpretation.
"""
base_keep = ("PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TMPDIR", "TEMP",
"LANG", "LC_ALL", "XDG_CONFIG_HOME", "XDG_DATA_HOME")
env: Dict[str, str] = {}
for key in (*base_keep, *allow_env):
val = os.environ.get(key)
if val is not None:
env[key] = val
if extra_env:
env.update(extra_env)
env.setdefault("NO_COLOR", "1")
try:
proc = subprocess.run( # noqa: S603 — argv list, no shell
list(argv),
env=env,
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"{Path(str(argv[0])).name} timed out after {timeout:.0f}s"
) from exc
except OSError as exc:
raise RuntimeError(
f"failed to invoke {Path(str(argv[0])).name}: {exc}"
) from exc
proc.stdout = proc.stdout or ""
proc.stderr = scrub_ansi(proc.stderr or "")
return proc

View file

@ -42,10 +42,17 @@ import time
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from agent.secret_sources._cache import (
CachedFetch as _CachedFetch,
DiskCache,
FetchResult,
is_valid_env_name as _is_valid_env_name,
)
from agent.secret_sources.base import ErrorKind, SecretSource
logger = logging.getLogger(__name__)
@ -70,7 +77,7 @@ _BWS_RUN_TIMEOUT = 30
# In-process cache so repeated load_hermes_dotenv() calls (CLI startup,
# gateway hot-reload, test suites) don't re-fetch from BSM.
_CacheKey = Tuple[str, str, str] # (access_token_fingerprint, project_id, server_url)
_CACHE: Dict[_CacheKey, "_CachedFetch"] = {}
_CACHE: Dict[_CacheKey, _CachedFetch] = {}
# Disk-persisted cache so back-to-back CLI invocations (e.g. `hermes chat -q ...`
# called from scripts, cron, the gateway forking new agents) don't each pay the
@ -81,124 +88,29 @@ _CACHE: Dict[_CacheKey, "_CachedFetch"] = {}
# <hermes_home>/cache/bws_cache.json. The file holds only the secret VALUES,
# never the access token. It's plaintext-equivalent to ~/.hermes/.env (which
# we already accept) but kept out of the .env file so users editing it won't
# accidentally commit BSM-sourced secrets.
# accidentally commit BSM-sourced secrets. The atomic-write/0600/TTL mechanics
# live in agent.secret_sources._cache.DiskCache, shared with the other backends.
_DISK_CACHE_BASENAME = "bws_cache.json"
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Return the disk cache path under hermes_home/cache/.
`home_path` is what `load_hermes_dotenv()` already resolved; falling back
to `$HERMES_HOME` / `~/.hermes` keeps direct callers working too.
"""
if home_path is None:
home_path = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
return home_path / "cache" / _DISK_CACHE_BASENAME
def _cache_key_str(cache_key: _CacheKey) -> str:
"""Serialize a cache key to a stable string for JSON storage."""
token_fp, project_id, server_url = cache_key
return f"{token_fp}|{project_id}|{server_url}"
def _read_disk_cache(cache_key: _CacheKey, ttl_seconds: float,
home_path: Optional[Path] = None) -> Optional["_CachedFetch"]:
"""Return a cached entry from disk if fresh, else None.
_DISK_CACHE: DiskCache = DiskCache(
_DISK_CACHE_BASENAME, key_serializer=_cache_key_str
)
Best-effort: any I/O or parse error returns None and we re-fetch.
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Return the disk cache path under hermes_home/cache/.
Thin wrapper over the shared DiskCache, kept for tests and any direct
callers; falls back to `$HERMES_HOME` / `~/.hermes` when home is None.
"""
if ttl_seconds <= 0:
return None
path = _disk_cache_path(home_path)
try:
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
if payload.get("key") != _cache_key_str(cache_key):
return None
secrets = payload.get("secrets")
fetched_at = payload.get("fetched_at")
if not isinstance(secrets, dict) or not isinstance(fetched_at, (int, float)):
return None
# Coerce all values to strings — JSON allows numbers but env vars need strings
typed_secrets: Dict[str, str] = {
k: v for k, v in secrets.items() if isinstance(k, str) and isinstance(v, str)
}
entry = _CachedFetch(secrets=typed_secrets, fetched_at=float(fetched_at))
if not entry.is_fresh(ttl_seconds):
return None
return entry
def _write_disk_cache(cache_key: _CacheKey, entry: "_CachedFetch",
home_path: Optional[Path] = None) -> None:
"""Persist a cache entry to disk atomically with mode 0600.
Best-effort: any I/O error is swallowed (the next invocation will just
re-fetch). We never want disk cache failures to break startup.
"""
path = _disk_cache_path(home_path)
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"key": _cache_key_str(cache_key),
"secrets": entry.secrets,
"fetched_at": entry.fetched_at,
}
# Write to a temp file in the same directory and atomic-rename.
# tempfile honors os.umask, so we explicitly chmod 0600 before rename.
fd, tmp = tempfile.mkstemp(
prefix=".bws_cache_", suffix=".tmp", dir=str(path.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
pass # best-effort — disk cache miss on next invocation is fine
@dataclass
class _CachedFetch:
secrets: Dict[str, str]
fetched_at: float
def is_fresh(self, ttl_seconds: float) -> bool:
if ttl_seconds <= 0:
return False
return (time.time() - self.fetched_at) < ttl_seconds
# ---------------------------------------------------------------------------
# Public dataclasses
# ---------------------------------------------------------------------------
@dataclass
class FetchResult:
"""Outcome of a single BSM pull."""
secrets: Dict[str, str] = field(default_factory=dict)
applied: List[str] = field(default_factory=list) # set into os.environ
skipped: List[str] = field(default_factory=list) # already set, not overridden
warnings: List[str] = field(default_factory=list) # non-fatal issues
error: Optional[str] = None # fatal: nothing was fetched
binary_path: Optional[Path] = None
@property
def ok(self) -> bool:
return self.error is None
return _DISK_CACHE.path(home_path)
# ---------------------------------------------------------------------------
@ -479,7 +391,7 @@ def fetch_bitwarden_secrets(
if cached and cached.is_fresh(cache_ttl_seconds):
return cached.secrets, []
# L2: disk cache. ~5ms on cache hit vs ~380ms for `bws secret list`.
disk_cached = _read_disk_cache(cache_key, cache_ttl_seconds, home_path)
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into in-process cache so subsequent fetches in the
# same process skip the disk read too.
@ -499,7 +411,7 @@ def fetch_bitwarden_secrets(
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
_CACHE[cache_key] = entry
if use_cache:
_write_disk_cache(cache_key, entry, home_path)
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
@ -575,14 +487,6 @@ def _run_bws_list(
return secrets, warnings
def _is_valid_env_name(name: str) -> bool:
if not name:
return False
if not (name[0].isalpha() or name[0] == "_"):
return False
return all(c.isalnum() or c == "_" for c in name)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
@ -673,6 +577,142 @@ def apply_bitwarden_secrets(
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class BitwardenSource(SecretSource):
"""Bitwarden Secrets Manager as a registered secret source.
Thin adapter over the module's fetch machinery. ``fetch()`` only
*fetches* precedence, override semantics, conflict warnings, and
the ``os.environ`` writes are the orchestrator's job
(see ``agent.secret_sources.registry.apply_all``).
Bitwarden is a **bulk** source: it injects every secret in the
configured BSM project, so explicit per-var bindings from mapped
sources (e.g. the 1Password ``env:`` map) outrank it.
"""
name = "bitwarden"
label = "Bitwarden Secrets Manager"
shape = "bulk"
scheme = "bws"
def override_existing(self, cfg: dict) -> bool:
# Default True (matches DEFAULT_CONFIG): the point of BSM is
# centralized rotation — if .env had the final say, rotating a
# key in Bitwarden wouldn't take effect until the stale .env
# line was also deleted.
return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
def protected_env_vars(self, cfg: dict):
token_env = "BWS_ACCESS_TOKEN"
if isinstance(cfg, dict):
token_env = str(cfg.get("access_token_env") or token_env)
return frozenset({token_env})
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"access_token_env": {
"description": "Env var holding the machine-account access token",
"default": "BWS_ACCESS_TOKEN",
},
"project_id": {"description": "BSM project UUID", "default": ""},
"cache_ttl_seconds": {
"description": "Disk+memory cache TTL; 0 disables",
"default": 300,
},
"override_existing": {
"description": "BSM values overwrite .env/shell values",
"default": True,
},
"auto_install": {
"description": "Auto-download the pinned bws binary",
"default": True,
},
"server_url": {
"description": "Region / self-hosted endpoint (empty = US Cloud)",
"default": "",
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
access_token_env = str(cfg.get("access_token_env") or "BWS_ACCESS_TOKEN")
access_token = os.environ.get(access_token_env, "").strip()
if not access_token:
result.error = (
f"secrets.bitwarden.enabled is true but {access_token_env} is "
"not set. Run `hermes secrets bitwarden setup`."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
project_id = str(cfg.get("project_id") or "")
if not project_id:
result.error = (
"secrets.bitwarden.project_id is empty. "
"Run `hermes secrets bitwarden setup`."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
auto_install = bool(cfg.get("auto_install", True))
binary = find_bws(install_if_missing=auto_install)
result.binary_path = binary
if binary is None:
result.error = (
"bws binary not available and auto-install is disabled. "
"Run `hermes secrets bitwarden setup` to install."
)
result.error_kind = ErrorKind.BINARY_MISSING
return result
try:
ttl = float(cfg.get("cache_ttl_seconds", 300))
except (TypeError, ValueError):
ttl = 300.0
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
project_id=project_id,
binary=binary,
cache_ttl_seconds=ttl,
server_url=str(cfg.get("server_url", "") or "").strip(),
home_path=home_path,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = _classify_bws_error(str(exc))
return result
result.secrets = secrets
result.warnings.extend(warnings)
return result
def _classify_bws_error(message: str) -> ErrorKind:
"""Best-effort mapping of bws failure text onto the shared taxonomy."""
lowered = message.lower()
if "timed out" in lowered:
return ErrorKind.TIMEOUT
if "binary not available" in lowered or "failed to invoke" in lowered:
return ErrorKind.BINARY_MISSING
if any(tok in lowered for tok in ("unauthorized", "invalid token",
"access token", "401", "403")):
return ErrorKind.AUTH_FAILED
if any(tok in lowered for tok in ("network", "connection", "resolve",
"download", "dns")):
return ErrorKind.NETWORK
return ErrorKind.INTERNAL
# ---------------------------------------------------------------------------
# Test hook — used by hermetic tests to flush the cache between cases.
# ---------------------------------------------------------------------------
@ -686,7 +726,4 @@ def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
writer itself.
"""
_CACHE.clear()
try:
_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
_DISK_CACHE.clear(home_path)

View file

@ -0,0 +1,643 @@
"""1Password (`op` CLI) secret source.
Resolve provider credentials from 1Password ``op://vault/item/field``
references at process startup so they don't have to live in plaintext in
``~/.hermes/.env``.
Design summary
--------------
* Users map environment-variable names to official 1Password secret
references in ``secrets.onepassword.env``::
secrets:
onepassword:
enabled: true
env:
OPENAI_API_KEY: "op://Private/OpenAI/api key"
ANTHROPIC_API_KEY: "op://Private/Anthropic/credential"
* After ``.env`` loads, each reference is resolved with a single
``op read -- <reference>`` call and injected into ``os.environ`` (the
same point in startup as the Bitwarden source).
* Authentication is whatever the user's ``op`` CLI already uses — a
service-account token (``OP_SERVICE_ACCOUNT_TOKEN``) for headless boxes,
or a desktop/interactive session (``OP_SESSION_*``). Hermes never
authenticates on the user's behalf; it shells out to an already-trusted,
already-authenticated CLI.
* Failures NEVER block startup. A missing ``op`` binary, expired auth, a
bad reference, or a permission error each surface a one-line warning and
Hermes continues with whatever credentials ``.env`` already had.
The atomic-write / ``0600`` / TTL cache mechanics are shared with the other
backends via :mod:`agent.secret_sources._cache` successful, complete pulls
are cached in-process and on disk under ``<hermes_home>/cache/op_cache.json``
so back-to-back short-lived ``hermes`` invocations don't re-shell ``op`` for
every reference. The disk file holds only resolved secret *values*; auth
material is fingerprinted, never stored.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from agent.secret_sources._cache import (
CachedFetch,
DiskCache,
FetchResult,
is_valid_env_name,
)
from agent.secret_sources.base import ErrorKind, SecretSource
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration constants
# ---------------------------------------------------------------------------
# How long to wait for a single `op read`, in seconds.
_OP_RUN_TIMEOUT = 30
# Default env var the official `op` CLI reads for service-account auth. Users
# can point `service_account_token_env` at a different name; we always export
# the value to the child as OP_SERVICE_ACCOUNT_TOKEN, which is what `op` itself
# looks for.
_DEFAULT_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN"
# Strip whole ANSI CSI sequences (colour, cursor moves, line erases) from any
# `op` diagnostic we surface — not just the lone ESC byte — so a control
# sequence can't reposition the cursor or hide text after a redaction marker.
_ANSI_CSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
# Env vars the `op` child actually needs. We build a minimal allowlisted env
# rather than copying all of os.environ (which, post-dotenv, holds every
# provider credential) into the child — tighter blast radius if `op` or
# anything it execs ever misbehaves. OP_SESSION_* and the token are added
# dynamically in _op_child_env().
_OP_ENV_ALLOWLIST = (
"PATH",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"SystemRoot",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"OP_ACCOUNT",
"OP_CONNECT_HOST",
"OP_CONNECT_TOKEN",
)
# ---------------------------------------------------------------------------
# Cache
# ---------------------------------------------------------------------------
# In-process cache. The key folds in str(home_path) so a HERMES_HOME switch
# inside one long-lived process (e.g. the gateway) can't return another
# profile's secrets from L1. The disk layer omits home from its serialized
# key because the file already lives under the home dir (see _disk_key_str).
_CacheKey = Tuple[str, str, str, str] # (auth_fp, account, home, refs_fp)
_CACHE: Dict[_CacheKey, CachedFetch] = {}
_DISK_CACHE_BASENAME = "op_cache.json"
def _disk_key_str(cache_key: _CacheKey) -> str:
"""Serialize a cache key for on-disk storage, omitting home_path.
The disk file is already partitioned by home (it lives under
``<home>/cache/``), so the path provides the home dimension; folding it
into the key string too would be redundant.
"""
auth_fp, account, _home, refs_fp = cache_key
return f"{auth_fp}|{account}|{refs_fp}"
_DISK_CACHE: DiskCache = DiskCache(
_DISK_CACHE_BASENAME, key_serializer=_disk_key_str
)
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Path to the on-disk cache (exposed for tests and direct callers)."""
return _DISK_CACHE.path(home_path)
# ---------------------------------------------------------------------------
# Reference validation + fingerprinting
# ---------------------------------------------------------------------------
def _validate_references(
references: Optional[Dict[str, str]],
) -> Tuple[Dict[str, str], List[str]]:
"""Return ``(valid_refs, warnings)`` from an ``env`` mapping.
A reference is kept only if its target env-var name is a valid POSIX
name and the value is a stripped ``op://`` reference string. Everything
else produces a warning and is dropped (never fatal).
"""
valid: Dict[str, str] = {}
warnings: List[str] = []
for name, ref in (references or {}).items():
if not is_valid_env_name(name):
warnings.append(f"Skipping {name!r}: not a valid env-var name")
continue
if not isinstance(ref, str):
warnings.append(f"Skipping {name!r}: reference is not a string")
continue
cleaned = ref.strip()
if not cleaned.startswith("op://"):
warnings.append(
f"Skipping {name!r}: {ref!r} is not an op:// secret reference"
)
continue
valid[name] = cleaned
return valid, warnings
def _auth_fingerprint(token_env: str) -> str:
"""SHA-256 prefix over the auth material `op` would use.
Folds in the service-account token, ``OP_ACCOUNT``, and *all*
``OP_SESSION_*`` vars (the names `op` actually exports for interactive
sessions ``OP_SESSION_<account_shorthand>``). Signing out and into a
different identity therefore changes the cache key, so a value cached under
a previous identity is never served under a new one. Never logged or
displayed; the raw token never leaves this hash.
"""
parts: List[str] = [
f"token={os.environ.get(token_env, '')}",
f"account={os.environ.get('OP_ACCOUNT', '')}",
]
for key in sorted(os.environ):
if key.startswith("OP_SESSION_"):
parts.append(f"{key}={os.environ[key]}")
material = "\n".join(parts)
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
def _refs_fingerprint(references: Dict[str, str]) -> str:
"""SHA-256 prefix over the configured name→reference mapping."""
material = "\n".join(f"{name}={references[name]}" for name in sorted(references))
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
# ---------------------------------------------------------------------------
# Binary discovery
# ---------------------------------------------------------------------------
def find_op(binary_path: str = "") -> Optional[Path]:
"""Resolve a usable ``op`` binary, or None.
When ``binary_path`` is set it is used verbatim and PATH is NOT consulted
pinning an absolute path is a way to avoid trusting whatever ``op`` shows
up first on ``PATH``. A pinned-but-missing path returns None (the caller
surfaces a clear error) rather than silently falling back.
"""
if binary_path:
pinned = Path(binary_path)
if pinned.exists() and os.access(pinned, os.X_OK):
return pinned
return None
found = shutil.which("op")
return Path(found) if found else None
# ---------------------------------------------------------------------------
# `op read` invocation
# ---------------------------------------------------------------------------
def _scrub(text: str) -> str:
"""Remove ANSI control sequences and trim, for safe message surfacing."""
return _ANSI_CSI_RE.sub("", text).replace("\x1b", "").strip()
def _op_child_env(token_value: str) -> Dict[str, str]:
"""Build a minimal allowlisted environment for the ``op`` child process."""
env: Dict[str, str] = {}
for key in _OP_ENV_ALLOWLIST:
val = os.environ.get(key)
if val is not None:
env[key] = val
# Desktop / interactive session credentials.
for key, val in os.environ.items():
if key.startswith("OP_SESSION_"):
env[key] = val
# `op` reads OP_SERVICE_ACCOUNT_TOKEN regardless of which env var the user
# configured Hermes to source it from, so normalize to that name here.
if token_value:
env["OP_SERVICE_ACCOUNT_TOKEN"] = token_value
env["NO_COLOR"] = "1"
return env
def _run_op_read(
op: Path,
reference: str,
*,
account: str = "",
token_value: str = "",
) -> str:
"""Resolve a single ``op://`` reference to its value.
Raises :class:`RuntimeError` on any failure including a ``returncode 0``
with empty output, which would otherwise silently clobber a good
``.env``/shell credential with ``""``.
"""
cmd: List[str] = [str(op), "read"]
if account:
cmd += ["--account", account]
# `--` terminates option parsing so a reference can never be mis-parsed as
# an `op` flag even if validation is ever loosened.
cmd += ["--", reference]
try:
proc = subprocess.run( # noqa: S603 — op path is user-trusted, argv list
cmd,
env=_op_child_env(token_value),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_OP_RUN_TIMEOUT,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"op read timed out after {_OP_RUN_TIMEOUT}s for {reference!r}"
) from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke op: {exc}") from exc
if proc.returncode != 0:
err = _scrub(proc.stderr or "")[:200]
if err:
raise RuntimeError(f"op read failed for {reference!r}: {err}")
raise RuntimeError(
f"op read exited {proc.returncode} for {reference!r}"
)
# `op` appends a trailing newline; strip only that so a value with
# intentional internal/edge spaces survives. But a value that is empty or
# whitespace-only is treated as empty: applying it would silently clobber a
# good .env/shell credential with effectively nothing.
value = (proc.stdout or "").rstrip("\r\n")
if not value.strip():
raise RuntimeError(f"op read returned an empty value for {reference!r}")
return value
# ---------------------------------------------------------------------------
# Fetch
# ---------------------------------------------------------------------------
def fetch_onepassword_secrets(
*,
references: Dict[str, str],
account: str = "",
token_env: str = _DEFAULT_TOKEN_ENV,
binary: Optional[Path] = None,
binary_path: str = "",
use_cache: bool = True,
cache_ttl_seconds: float = 300,
home_path: Optional[Path] = None,
) -> Tuple[Dict[str, str], List[str]]:
"""Resolve ``references`` (name → ``op://…``) to ``(secrets, warnings)``.
Raises :class:`RuntimeError` only when no ``op`` binary is available a
fatal "can't fetch anything" condition. Per-reference failures (expired
auth, bad reference, empty value) are collected as warnings and the
reference is dropped, so one bad entry never sinks the rest.
Only a complete, error-free pull is cached, so a transient auth failure
isn't frozen in for the whole TTL window.
"""
valid, warnings = _validate_references(references)
if not valid:
return {}, warnings
token_value = os.environ.get(token_env, "").strip()
cache_key: _CacheKey = (
_auth_fingerprint(token_env),
account or "",
str(home_path) if home_path is not None else "",
_refs_fingerprint(valid),
)
if use_cache:
cached = _CACHE.get(cache_key)
if cached and cached.is_fresh(cache_ttl_seconds):
return dict(cached.secrets), warnings
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into L1 so later fetches in this process skip the disk read.
_CACHE[cache_key] = disk_cached
return dict(disk_cached.secrets), warnings
op = binary or find_op(binary_path)
if op is None:
raise RuntimeError(
"op CLI not found. Install the 1Password CLI "
"(https://developer.1password.com/docs/cli/get-started/) or set "
"secrets.onepassword.binary_path to its absolute location."
)
secrets: Dict[str, str] = {}
read_errors = 0
for name in sorted(valid):
try:
secrets[name] = _run_op_read(
op, valid[name], account=account, token_value=token_value
)
except RuntimeError as exc:
warnings.append(str(exc))
read_errors += 1
if use_cache and not read_errors and secrets:
entry = CachedFetch(secrets=dict(secrets), fetched_at=time.time())
_CACHE[cache_key] = entry
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
def apply_onepassword_secrets(
*,
enabled: bool,
env: Optional[Dict[str, str]] = None,
account: str = "",
service_account_token_env: str = _DEFAULT_TOKEN_ENV,
binary_path: str = "",
override_existing: bool = True,
cache_ttl_seconds: float = 300,
home_path: Optional[Path] = None,
) -> FetchResult:
"""Resolve configured ``op://`` references and set them on ``os.environ``.
Called by ``load_hermes_dotenv()`` after the .env files have loaded.
Intentionally defensive any failure returns a :class:`FetchResult` with
``error`` set (or surfaces warnings); it never raises.
Parameters mirror the ``secrets.onepassword.*`` config keys so the caller
can splat the dict in. References that are already satisfied by the
current environment (when ``override_existing`` is false) are skipped
*before* fetching, so ``op`` is never invoked for a value that would be
discarded.
"""
result = FetchResult()
if not enabled:
return result
valid, warnings = _validate_references(env)
result.warnings.extend(warnings)
# Skip-before-fetch: never resolve a reference we'd only throw away.
refs_to_fetch: Dict[str, str] = {}
for name, ref in valid.items():
if name == service_account_token_env:
# Never let a resolved secret clobber the very token used to auth.
result.skipped.append(name)
continue
if not override_existing and os.environ.get(name):
result.skipped.append(name)
continue
refs_to_fetch[name] = ref
if not refs_to_fetch:
return result
binary = find_op(binary_path)
result.binary_path = binary
if binary is None:
if binary_path:
result.error = (
f"secrets.onepassword.binary_path ({binary_path!r}) is not an "
"executable op binary."
)
else:
result.error = (
"secrets.onepassword.enabled is true but the op CLI was not "
"found on PATH. Install it "
"(https://developer.1password.com/docs/cli/get-started/) or set "
"secrets.onepassword.binary_path."
)
return result
try:
secrets, fetch_warnings = fetch_onepassword_secrets(
references=refs_to_fetch,
account=account,
token_env=service_account_token_env,
binary=binary,
cache_ttl_seconds=cache_ttl_seconds,
home_path=home_path,
)
except RuntimeError as exc:
result.error = str(exc)
return result
result.secrets = secrets
result.warnings.extend(fetch_warnings)
for name, value in secrets.items():
# The token-var and override guards already filtered refs_to_fetch, but
# re-check defensively in case the fetch layer ever returns extras.
if name == service_account_token_env:
if name not in result.skipped:
result.skipped.append(name)
continue
if not override_existing and os.environ.get(name):
if name not in result.skipped:
result.skipped.append(name)
continue
os.environ[name] = value
result.applied.append(name)
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class OnePasswordSource(SecretSource):
"""1Password as a registered secret source.
Thin adapter over the module's fetch machinery. ``fetch()`` only
*fetches* precedence, override semantics, conflict warnings, and
the ``os.environ`` writes are the orchestrator's job
(see ``agent.secret_sources.registry.apply_all``).
1Password is a **mapped** source: the user explicitly binds each env
var to an ``op://`` reference under ``secrets.onepassword.env``, so
its claims outrank bulk sources (e.g. a Bitwarden project dump) on
contested vars.
"""
name = "onepassword"
label = "1Password"
shape = "mapped"
scheme = "op"
def override_existing(self, cfg: dict) -> bool:
# Default True: an explicit VAR→op:// binding is the strongest
# user intent there is — leaving a stale .env line in place
# should not silently defeat it (same rotation rationale as
# Bitwarden).
return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
def protected_env_vars(self, cfg: dict):
token_env = _DEFAULT_TOKEN_ENV
if isinstance(cfg, dict):
token_env = str(cfg.get("service_account_token_env") or token_env)
return frozenset({token_env})
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"env": {
"description": "Map of ENV_VAR -> op://vault/item/field reference",
"default": {},
},
"account": {
"description": "op --account shorthand (empty = default account)",
"default": "",
},
"service_account_token_env": {
"description": "Env var holding the service-account token "
"(unset = desktop/interactive session)",
"default": _DEFAULT_TOKEN_ENV,
},
"binary_path": {
"description": "Pin the op binary (empty = resolve via PATH)",
"default": "",
},
"cache_ttl_seconds": {
"description": "Disk+memory cache TTL; 0 disables",
"default": 300,
},
"override_existing": {
"description": "Resolved values overwrite .env/shell values",
"default": True,
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
env_map = cfg.get("env")
valid, warnings = _validate_references(
env_map if isinstance(env_map, dict) else None
)
result.warnings.extend(warnings)
if not valid:
if not warnings:
result.error = (
"secrets.onepassword.enabled is true but the env: map is "
"empty. Add ENV_VAR: op://vault/item/field entries."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
binary_path = str(cfg.get("binary_path") or "")
binary = find_op(binary_path)
result.binary_path = binary
if binary is None:
if binary_path:
result.error = (
f"secrets.onepassword.binary_path ({binary_path!r}) is "
"not an executable op binary."
)
else:
result.error = (
"secrets.onepassword.enabled is true but the op CLI was "
"not found on PATH. Install it "
"(https://developer.1password.com/docs/cli/get-started/) "
"or set secrets.onepassword.binary_path."
)
result.error_kind = ErrorKind.BINARY_MISSING
return result
try:
ttl = float(cfg.get("cache_ttl_seconds", 300))
except (TypeError, ValueError):
ttl = 300.0
try:
secrets, fetch_warnings = fetch_onepassword_secrets(
references=valid,
account=str(cfg.get("account") or ""),
token_env=str(
cfg.get("service_account_token_env") or _DEFAULT_TOKEN_ENV
),
binary=binary,
cache_ttl_seconds=ttl,
home_path=home_path,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = _classify_op_error(str(exc))
return result
result.secrets = secrets
result.warnings.extend(fetch_warnings)
return result
def _classify_op_error(message: str) -> ErrorKind:
"""Best-effort mapping of op failure text onto the shared taxonomy."""
lowered = message.lower()
if "timed out" in lowered:
return ErrorKind.TIMEOUT
if "not found on path" in lowered or "not an executable" in lowered \
or "failed to invoke" in lowered:
return ErrorKind.BINARY_MISSING
if any(tok in lowered for tok in ("unauthorized", "not signed in",
"session expired", "authentication",
"401", "403")):
return ErrorKind.AUTH_FAILED
if "empty value" in lowered:
return ErrorKind.EMPTY_VALUE
if any(tok in lowered for tok in ("network", "connection", "resolve host",
"dns")):
return ErrorKind.NETWORK
return ErrorKind.INTERNAL
# ---------------------------------------------------------------------------
# Test hook — used by hermetic tests to flush the cache between cases.
# ---------------------------------------------------------------------------
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
"""Clear in-process AND disk caches.
Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.
Without it we fall back to the same default resolution as the writer.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)

View file

@ -0,0 +1,370 @@
"""Secret-source registry + apply orchestrator.
This module owns everything that must be uniform across secret backends
so no individual source can get it wrong:
* registration (name/scheme uniqueness, API-version gating)
* per-source wall-clock timeout enforcement around ``fetch()``
* precedence: mapped sources beat bulk sources; within a shape,
``secrets.sources`` order (or registration order) decides; first
claim wins later sources never silently clobber an earlier one
* ``override_existing`` semantics (may beat .env/shell, never another
secret source, never a protected var)
* cross-source conflict warnings (shadowed claims are always surfaced)
* provenance: which source supplied every applied var
The single entry point for startup is :func:`apply_all`, called from
``hermes_cli.env_loader._apply_external_secret_sources()``.
Plugins register additional sources via
``PluginContext.register_secret_source()`` which lands in
:func:`register_source`. In-tree sources are registered lazily by
:func:`_ensure_builtin_sources` the set of bundled sources is
deliberately closed (Bitwarden, and 1Password once it lands); new
third-party backends ship as standalone plugin repos implementing
:class:`agent.secret_sources.base.SecretSource`.
"""
from __future__ import annotations
import concurrent.futures
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from agent.secret_sources.base import (
SECRET_SOURCE_API_VERSION,
ErrorKind,
FetchResult,
SecretSource,
is_valid_env_name,
)
logger = logging.getLogger(__name__)
# Ordered registry: name → source instance. Python dicts preserve
# insertion order, which doubles as the default apply order.
_SOURCES: Dict[str, SecretSource] = {}
_BUILTINS_LOADED = False
@dataclass
class AppliedVar:
"""Provenance record for one env var the orchestrator set."""
name: str
source: str # SecretSource.name
shape: str # "mapped" | "bulk"
overrode_env: bool # replaced a pre-existing .env/shell value
@dataclass
class SourceReport:
"""One source's outcome within an :class:`ApplyReport`."""
name: str
label: str
result: FetchResult
applied: List[str] = field(default_factory=list)
skipped_existing: List[str] = field(default_factory=list) # .env/shell won
skipped_claimed: List[str] = field(default_factory=list) # earlier source won
skipped_protected: List[str] = field(default_factory=list) # bootstrap-auth guard
skipped_invalid: List[str] = field(default_factory=list) # bad env-var name
@dataclass
class ApplyReport:
"""Merged outcome of one orchestrated apply pass."""
sources: List[SourceReport] = field(default_factory=list)
provenance: Dict[str, AppliedVar] = field(default_factory=dict)
conflicts: List[str] = field(default_factory=list) # human-readable warnings
@property
def applied_any(self) -> bool:
return bool(self.provenance)
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def register_source(source: SecretSource, *, replace: bool = False) -> bool:
"""Register a secret source. Returns True on success.
Rejections are logged, never raised a bad plugin must not take
down startup. ``replace`` allows tests / user plugins to override
a bundled source of the same name (last-writer-wins like model
providers), but scheme collisions across *different* names are
always rejected.
"""
if not isinstance(source, SecretSource):
logger.warning(
"Ignoring secret source %r: does not inherit from SecretSource",
source,
)
return False
name = getattr(source, "name", "") or ""
if not name or not name.replace("_", "").isalnum() or name != name.lower():
logger.warning("Ignoring secret source with invalid name %r", name)
return False
if getattr(source, "api_version", None) != SECRET_SOURCE_API_VERSION:
logger.warning(
"Ignoring secret source '%s': built against secret-source API v%s, "
"this Hermes speaks v%s",
name, getattr(source, "api_version", "?"), SECRET_SOURCE_API_VERSION,
)
return False
if getattr(source, "shape", None) not in ("mapped", "bulk"):
logger.warning(
"Ignoring secret source '%s': shape must be 'mapped' or 'bulk', got %r",
name, getattr(source, "shape", None),
)
return False
if name in _SOURCES and not replace:
logger.warning("Secret source '%s' already registered; ignoring duplicate", name)
return False
scheme = getattr(source, "scheme", None)
if scheme:
for other_name, other in _SOURCES.items():
if other_name != name and getattr(other, "scheme", None) == scheme:
logger.warning(
"Ignoring secret source '%s': scheme '%s://' is already "
"owned by source '%s'",
name, scheme, other_name,
)
return False
_SOURCES[name] = source
return True
def get_source(name: str) -> Optional[SecretSource]:
_ensure_builtin_sources()
return _SOURCES.get(name)
def list_sources() -> List[SecretSource]:
_ensure_builtin_sources()
return list(_SOURCES.values())
def _ensure_builtin_sources() -> None:
"""Idempotently register the bundled sources.
Lazy so importing this module stays cheap and so a broken bundled
source can never break registration of the others.
"""
global _BUILTINS_LOADED
if _BUILTINS_LOADED:
return
_BUILTINS_LOADED = True
try:
from agent.secret_sources.bitwarden import BitwardenSource
register_source(BitwardenSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled Bitwarden secret source",
exc_info=True)
try:
from agent.secret_sources.onepassword import OnePasswordSource
register_source(OnePasswordSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled 1Password secret source",
exc_info=True)
def _reset_registry_for_tests() -> None:
global _BUILTINS_LOADED
_SOURCES.clear()
_BUILTINS_LOADED = False
# ---------------------------------------------------------------------------
# Orchestrated apply
# ---------------------------------------------------------------------------
def _fetch_with_timeout(
source: SecretSource, cfg: dict, home_path: Path
) -> FetchResult:
"""Run source.fetch() under a wall-clock budget; never raises.
The budget is enforced with a daemon worker thread: a source that
blows its budget is reported as ``TIMEOUT`` and its (eventual)
result is discarded. The thread itself may linger until process
exit acceptable for a startup-only path, and strictly better than
an unbounded hang on every ``hermes`` invocation.
"""
timeout = source.fetch_timeout_seconds(cfg)
executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix=f"secret-src-{source.name}"
)
try:
future = executor.submit(source.fetch, cfg, home_path)
try:
result = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
res = FetchResult()
res.error = (
f"fetch exceeded {timeout:.0f}s budget — startup continued "
"without this source (raise secrets."
f"{source.name}.timeout_seconds if the backend is just slow)"
)
res.error_kind = ErrorKind.TIMEOUT
return res
except Exception as exc: # noqa: BLE001 — contract violation, contain it
res = FetchResult()
res.error = f"fetch raised {type(exc).__name__}: {exc}"
res.error_kind = ErrorKind.INTERNAL
return res
finally:
executor.shutdown(wait=False)
if not isinstance(result, FetchResult):
res = FetchResult()
res.error = (
f"fetch returned {type(result).__name__} instead of FetchResult"
)
res.error_kind = ErrorKind.INTERNAL
return res
return result
def _ordered_enabled_sources(secrets_cfg: dict) -> List[SecretSource]:
"""Resolve which sources run, in which order.
Order: the optional ``secrets.sources`` list wins; sources not named
there follow in registration order. Enabled = the source's own
``is_enabled`` says so for its config section. Mapped-vs-bulk
precedence is applied on top of this order by :func:`apply_all`.
"""
_ensure_builtin_sources()
explicit = secrets_cfg.get("sources")
order: List[str] = []
if isinstance(explicit, list):
for entry in explicit:
if isinstance(entry, str) and entry in _SOURCES and entry not in order:
order.append(entry)
unknown = [e for e in explicit
if isinstance(e, str) and e not in _SOURCES]
if unknown:
logger.warning(
"secrets.sources names unknown source(s): %s (known: %s)",
", ".join(unknown), ", ".join(_SOURCES) or "none",
)
for name in _SOURCES:
if name not in order:
order.append(name)
enabled: List[SecretSource] = []
for name in order:
source = _SOURCES[name]
cfg = secrets_cfg.get(name)
cfg = cfg if isinstance(cfg, dict) else {}
try:
if source.is_enabled(cfg):
enabled.append(source)
except Exception: # noqa: BLE001
logger.warning("Secret source '%s' is_enabled() raised; skipping",
name, exc_info=True)
return enabled
def apply_all(secrets_cfg: dict, home_path: Path,
environ: Optional[Dict[str, str]] = None) -> ApplyReport:
"""Fetch from every enabled source and apply the merged result to env.
``environ`` defaults to ``os.environ``; injectable for tests.
Precedence per env var (most-specific intent wins):
1. Pre-existing env (.env / shell) unless the winning source has
``override_existing: true``.
2. Mapped sources, in configured order.
3. Bulk sources, in configured order.
First claim wins. A later source that also carries the var gets a
``skipped_claimed`` entry and a conflict warning never a silent
clobber, and ``override_existing`` never applies across sources.
"""
import os as _os
env = environ if environ is not None else _os.environ
report = ApplyReport()
secrets_cfg = secrets_cfg if isinstance(secrets_cfg, dict) else {}
enabled = _ordered_enabled_sources(secrets_cfg)
if not enabled:
return report
# Mapped sources outrank bulk sources regardless of list order:
# an explicit VAR→ref binding is stronger intent than a project dump.
ordered = ([s for s in enabled if s.shape == "mapped"]
+ [s for s in enabled if s.shape == "bulk"])
# Fetch phase.
fetches: List[tuple[SecretSource, dict, FetchResult]] = []
protected: Dict[str, str] = {} # var → source that protects it
for source in ordered:
cfg = secrets_cfg.get(source.name)
cfg = cfg if isinstance(cfg, dict) else {}
result = _fetch_with_timeout(source, cfg, home_path)
fetches.append((source, cfg, result))
try:
for var in source.protected_env_vars(cfg):
protected.setdefault(var, source.name)
except Exception: # noqa: BLE001
pass
# Apply phase — sequential, first-wins, fully attributed.
claimed: Dict[str, str] = {} # var → source name that won it
for source, cfg, result in fetches:
sr = SourceReport(name=source.name,
label=source.label or source.name,
result=result)
report.sources.append(sr)
if not result.ok:
continue
try:
override = source.override_existing(cfg)
except Exception: # noqa: BLE001
override = False
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
if not is_valid_env_name(var):
sr.skipped_invalid.append(var)
continue
if var in protected:
sr.skipped_protected.append(var)
continue
if var in claimed:
sr.skipped_claimed.append(var)
report.conflicts.append(
f"{var}: kept value from {claimed[var]}; "
f"{source.name} also supplies it (first source wins — "
"remove one binding or reorder secrets.sources)"
)
continue
existed = bool(env.get(var))
if existed and not override:
sr.skipped_existing.append(var)
continue
env[var] = value
claimed[var] = source.name
sr.applied.append(var)
report.provenance[var] = AppliedVar(
name=var,
source=source.name,
shape=source.shape,
overrode_env=existed,
)
return report

View file

@ -224,6 +224,15 @@ def register_from_config(
if not isinstance(cfg, dict):
return []
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): shell hooks are user
# customizations too — skip registration entirely so a troubleshooting
# run fires zero user-configured code (plugins, MCP, AND hooks).
from utils import env_var_enabled
if env_var_enabled("HERMES_SAFE_MODE"):
logger.info("HERMES_SAFE_MODE=1 — shell-hook registration skipped")
return []
effective_accept = _resolve_effective_accept(cfg, accept_hooks)
specs = _parse_hooks_block(cfg.get("hooks"))
@ -307,6 +316,11 @@ def _parse_hooks_block(hooks_cfg: Any) -> List[ShellHookSpec]:
specs: List[ShellHookSpec] = []
for event_name, entries in hooks_cfg.items():
# Reserved sub-keys that aren't event names — skip silently. These
# are config sub-sections nested under `hooks:` for related
# functionality (e.g. output-spill budgets).
if event_name in ("output_spill",):
continue
if event_name not in VALID_HOOKS:
suggestion = difflib.get_close_matches(
str(event_name), VALID_HOOKS, n=1, cutoff=0.6,

View file

@ -254,6 +254,7 @@ def build_bundle_invocation_message(
cmd_key: str,
user_instruction: str = "",
task_id: str | None = None,
platform: str | None = None,
) -> Optional[Tuple[str, List[str], List[str]]]:
"""Build the user message content for a bundle slash command invocation.
@ -264,6 +265,16 @@ def build_bundle_invocation_message(
loads the agent gets a note about which ones were skipped. This is
the same forgiving stance ``build_preloaded_skills_prompt`` uses for
``-s`` CLI preloading.
Disabled skills are also skipped: bundles load members via
``_load_skill_payload`` directly, bypassing the scan-time disabled
filter in ``get_skill_commands()``, so the disabled list must be
re-applied here. ``platform`` scopes the check to a specific
platform's ``skills.platform_disabled`` config (gateway dispatch
passes it explicitly because the gateway handles multiple platforms
in one process); when *None*, the platform resolves from session env
vars and the global disabled list still applies. Mirrors the
stacked-skill gate in gateway dispatch (#58888).
"""
bundles = get_skill_bundles()
info = bundles.get(cmd_key)
@ -274,8 +285,15 @@ def build_bundle_invocation_message(
# keep skill_bundles cheap to import in test environments.
from agent.skill_commands import _load_skill_payload, _build_skill_message
try:
from agent.skill_utils import get_disabled_skill_names
disabled_names = get_disabled_skill_names(platform=platform)
except Exception:
disabled_names = set()
loaded_names: List[str] = []
missing: List[str] = []
disabled: List[str] = []
skill_blocks: List[str] = []
seen: set[str] = set()
@ -295,6 +313,12 @@ def build_bundle_invocation_message(
continue
loaded_skill, skill_dir, skill_name = loaded
# Per-platform / global disabled gate. Checked against the loaded
# skill's canonical name (identifiers may be paths or aliases).
if skill_name in disabled_names or identifier in disabled_names:
disabled.append(skill_name or identifier)
continue
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
@ -329,6 +353,10 @@ def build_bundle_invocation_message(
]
if missing:
header_lines.append(f"Skills missing (skipped): {', '.join(missing)}")
if disabled:
header_lines.append(
f"Skills disabled for this platform (skipped): {', '.join(disabled)}"
)
if extra_instruction:
header_lines.extend(["", f"Bundle instruction: {extra_instruction}"])
if user_instruction:

View file

@ -143,37 +143,9 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
try:
from tools.skills_tool import SKILLS_DIR, skill_view
from agent.skill_utils import get_external_skills_dirs
from agent.skill_utils import normalize_skill_lookup_name
identifier_path = Path(raw_identifier).expanduser()
if identifier_path.is_absolute():
normalized = None
trusted_roots = [SKILLS_DIR]
try:
trusted_roots.extend(get_external_skills_dirs())
except Exception:
pass
# Prefer the lexical path under a trusted skill root before
# resolving symlinks. Slash-command discovery can legitimately
# find a skill via ~/.hermes/skills/<name> where <name> is a
# symlink to a checked-out skill elsewhere. Resolving first turns
# that trusted visible path into an arbitrary absolute path that
# skill_view() refuses to load.
for root in trusted_roots:
try:
normalized = str(identifier_path.relative_to(root))
break
except ValueError:
continue
if normalized is None:
try:
normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
except Exception:
normalized = raw_identifier
else:
normalized = raw_identifier.lstrip("/")
normalized = normalize_skill_lookup_name(raw_identifier)
loaded_skill = json.loads(
skill_view(normalized, task_id=task_id, preprocess=False)
@ -561,18 +533,162 @@ def build_skill_invocation_message(
)
# ---------------------------------------------------------------------------
# Stacked slash-skill invocations — `/skill-a /skill-b do XYZ` loads every
# leading skill (up to _MAX_STACKED_SKILLS), not just the first.
#
# Inspired by Claude Code v2.1.199 (July 2, 2026): "Stacked slash-skill
# invocations like /skill-a /skill-b do XYZ now load all leading skills
# (up to 5), not just the first."
#
# The generated message deliberately reuses the BUNDLE scaffolding markers
# ("skill bundle," header + "[Loaded as part of the " block prefix) so
# extract_user_instruction_from_skill_message() recovers the user's
# instruction without any new marker plumbing — memory providers keep
# storing what the user actually asked, not N skill bodies.
# ---------------------------------------------------------------------------
_MAX_STACKED_SKILLS = 5
def split_stacked_skill_commands(rest: str) -> tuple[list[str], str]:
"""Consume additional leading ``/skill`` tokens from *rest*.
*rest* is the text that follows the FIRST matched skill command (the
caller has already resolved that one). Leading whitespace-delimited
tokens that start with ``/`` and resolve to installed skill commands are
consumed, up to ``_MAX_STACKED_SKILLS`` total leading skills (i.e. at
most ``_MAX_STACKED_SKILLS - 1`` extra keys here). Parsing stops at the
first token that is not a resolvable skill command that token and
everything after it become the user instruction.
Returns:
``(extra_cmd_keys, remaining_instruction)`` where ``extra_cmd_keys``
are canonical ``/slug`` keys from :func:`get_skill_commands`.
"""
keys: list[str] = []
remaining = rest or ""
while len(keys) < _MAX_STACKED_SKILLS - 1:
stripped = remaining.lstrip()
if not stripped.startswith("/"):
break
parts = stripped.split(None, 1)
token = parts[0]
tail = parts[1] if len(parts) > 1 else ""
cmd_key = resolve_skill_command_key(token.lstrip("/"))
if cmd_key is None or cmd_key in keys:
break
keys.append(cmd_key)
remaining = tail
return keys, remaining.strip()
def build_stacked_skill_invocation_message(
cmd_keys: list[str],
user_instruction: str = "",
task_id: str | None = None,
) -> Optional[tuple[str, list[str], list[str]]]:
"""Build the user message for a stacked multi-skill slash invocation.
Args:
cmd_keys: Canonical ``/slug`` keys, in the order the user typed them.
user_instruction: Text remaining after the leading skill commands.
Returns:
``(message, loaded_skill_names, missing_skill_names)`` or ``None``
when no skill could be loaded at all.
"""
commands = get_skill_commands()
loaded_names: list[str] = []
missing: list[str] = []
skill_blocks: list[str] = []
seen: set[str] = set()
for cmd_key in cmd_keys:
if not cmd_key or cmd_key in seen:
continue
seen.add(cmd_key)
skill_info = commands.get(cmd_key)
if not skill_info:
missing.append(cmd_key.lstrip("/"))
continue
loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
if not loaded:
missing.append(cmd_key.lstrip("/"))
continue
loaded_skill, skill_dir, skill_name = loaded
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
except Exception:
pass # Non-critical
# NOTE: must start with "[Loaded as part of the " — that prefix is
# the bundle block marker the memory-scaffolding extractor cuts on.
activation_note = (
f'[Loaded as part of the stacked skill invocation "{skill_name}".]'
)
skill_blocks.append(
_build_skill_message(
loaded_skill,
skill_dir,
activation_note,
session_id=task_id,
)
)
loaded_names.append(skill_name)
if not skill_blocks:
return None
# Header — must contain " skill bundle," so the bundle-format extractor
# in extract_user_instruction_from_skill_message() applies unchanged.
typed = " ".join(k for k in cmd_keys if k)
header_lines = [
f'[IMPORTANT: The user has invoked the "{typed}" stacked skill bundle, '
f"loading {len(loaded_names)} skills together. Treat every skill below "
"as active guidance for this turn.]",
"",
f"Skills loaded: {', '.join(loaded_names)}",
]
if missing:
header_lines.append(f"Skills missing (skipped): {', '.join(missing)}")
if user_instruction:
header_lines.extend(["", f"User instruction: {user_instruction}"])
header = "\n".join(header_lines)
return ("\n\n".join([header, *skill_blocks]), loaded_names, missing)
def build_preloaded_skills_prompt(
skill_identifiers: list[str],
task_id: str | None = None,
) -> tuple[str, list[str], list[str]]:
"""Load one or more skills for session-wide CLI preloading.
"""Load one or more skills for session-wide CLI/TUI preloading.
Returns (prompt_text, loaded_skill_names, missing_identifiers).
Disabled skills are treated the same as missing ones: this loads via a
raw identifier straight into ``_load_skill_payload``, bypassing
``get_skill_commands()``'s scan-time disabled filter — mirrors the
bundle-invocation gate (#59156). Without this, ``hermes -s <skill>`` or
a deployment's ``HERMES_TUI_SKILLS`` env var could force-load a skill an
operator disabled via ``skills.disabled``/``skills.platform_disabled``.
"""
prompt_parts: list[str] = []
loaded_names: list[str] = []
missing: list[str] = []
try:
from agent.skill_utils import get_disabled_skill_names
disabled_names = get_disabled_skill_names()
except Exception:
disabled_names = set()
seen: set[str] = set()
for raw_identifier in skill_identifiers:
identifier = (raw_identifier or "").strip()
@ -587,6 +703,10 @@ def build_preloaded_skills_prompt(
loaded_skill, skill_dir, skill_name = loaded
if skill_name in disabled_names or identifier in disabled_names:
missing.append(identifier)
continue
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use

View file

@ -507,6 +507,63 @@ def get_all_skills_dirs() -> List[Path]:
return dirs
def normalize_skill_lookup_name(identifier: str) -> str:
"""Normalize a skill identifier to a ``skill_view()``-safe relative path.
Slash commands and cron jobs may store absolute paths to skills that live
under ``~/.hermes/skills/`` (including via symlinks) or configured
``skills.external_dirs``. ``skill_view()`` rejects absolute names for
security, so callers must translate trusted absolute paths to their
relative form first.
"""
raw_identifier = (identifier or "").strip()
if not raw_identifier:
return raw_identifier
identifier_path = Path(raw_identifier).expanduser()
if not identifier_path.is_absolute():
return raw_identifier.lstrip("/")
# Look the primary skills root up on tools.skills_tool at CALL time
# (not via get_skills_dir()): callers and tests patch
# ``tools.skills_tool.SKILLS_DIR`` and skill_view() itself resolves
# against that module attribute, so normalization must agree with the
# exact root skill_view() will enforce. Import deferred to avoid a
# module cycle (tools.skills_tool imports agent.skill_utils).
try:
from tools import skills_tool as _skills_tool
primary_root = Path(_skills_tool.SKILLS_DIR)
except Exception:
primary_root = get_skills_dir()
trusted_roots = [primary_root]
try:
trusted_roots.extend(get_external_skills_dirs())
except Exception:
pass
# Prefer the lexical path under a trusted skill root before resolving
# symlinks. Slash-command discovery can legitimately find a skill via
# ~/.hermes/skills/<name> where <name> is a symlink to a checked-out
# skill elsewhere. Resolving first turns that trusted visible path into
# an arbitrary absolute path that skill_view() refuses to load.
for root in trusted_roots:
try:
return str(identifier_path.relative_to(root))
except ValueError:
continue
try:
return str(identifier_path.resolve().relative_to(primary_root.resolve()))
except Exception:
logger.debug(
"Skill identifier %r is an absolute path outside trusted skills "
"roots — passing through unchanged (skill_view will reject it)",
raw_identifier,
)
return raw_identifier
def _resolve_for_skill_ownership(path) -> Path:
path_obj = path if isinstance(path, Path) else Path(str(path))
try:

View file

@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
@ -44,6 +45,7 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from utils import is_truthy_value
def _ra():
@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->
return base
_TUI_EMBEDDED_PANE_CLARIFIER = (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (Option-drag on macOS, Shift-drag elsewhere) and press "
"Cmd/Ctrl+L to send it to the chat composer."
)
def _tui_embedded_pane_clarifier(hint: str) -> str:
"""Append the desktop-embedded-terminal-pane clarifier to a tui hint.
Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the
shell env of the desktop's embedded TUI PTY — never on the chat backend).
This is a runtime-surface qualifier, not a config override, so it lives at
the resolution site rather than inside ``_resolve_platform_hint`` (which
is purely the config-platform_hints override applier). Byte-stable for the
cache: called once per session build, deterministically from env state.
Idempotent and empty-safe: re-applying on an already-augmented hint is a
no-op, and an empty input returns empty (we never synthesize the
clarifier without its tui framing).
"""
if not hint:
return hint
if _TUI_EMBEDDED_PANE_CLARIFIER in hint:
return hint
if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")):
return hint
return hint + _TUI_EMBEDDED_PANE_CLARIFIER
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
pass
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
if _effective_hint:
stable_parts.append(_effective_hint)

View file

@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
)
else:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
_block_error_type = "tool_scope_block"
else:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
_block_msg = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
_block_msg = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",

398
agent/trace_upload.py Normal file
View file

@ -0,0 +1,398 @@
"""Upload a Hermes session transcript to Hugging Face as an agent trace.
Hermes stores sessions in its own SQLite store (``hermes_state.SessionDB``),
so we reconstruct the conversation and emit it in the **Claude Code JSONL**
shape one of the three formats the Hugging Face Agent Trace Viewer
auto-detects (Claude Code / Codex / Pi). No dataset-side preprocessing is
needed; the Hub tags the dataset ``agent-traces`` and opens it in the viewer.
Docs: https://huggingface.co/docs/hub/agent-traces
Design notes
------------
* **Zero LLM turn.** This is a deterministic export it never spends a
model call. The ``hermes trace upload`` subcommand calls
:func:`upload_session_trace` directly.
* **Private by default.** Traces can contain prompts, tool output, local
paths, and secrets. The dataset is created private and every text body
is passed through Hermes' secret redactor (``force=True``) unless the
caller explicitly opts out with ``redact=False``.
* **Never raises.** Returns a user-facing status string so command
handlers can echo it straight back to the user. Programmatic callers
that need the URL can use :func:`build_trace_jsonl` + :func:`_do_upload`
directly.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
DEFAULT_DATASET_NAME = "hermes-traces"
_HERMES_VERSION = "hermes-agent"
_REDACTION_BLOCKED_MESSAGE = (
"Trace upload blocked: secret redaction failed, so the transcript may "
"still contain credentials or other sensitive data. Fix the redactor or "
"rerun with --no-redact only after manually reviewing the transcript."
)
class TraceRedactionError(RuntimeError):
"""Raised when a trace cannot be safely redacted before upload."""
# ---------------------------------------------------------------------------
# Conversion: Hermes OpenAI-format messages -> Claude Code JSONL
# ---------------------------------------------------------------------------
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _redact(text: Any, enabled: bool) -> Any:
"""Redact secrets from a string body when redaction is enabled.
Non-strings pass through untouched. Uses Hermes' shared redactor with
``force=True`` so an upload always scrubs known secret shapes even if
the user disabled log redaction globally.
"""
if not enabled or not isinstance(text, str) or not text:
return text
try:
from agent.redact import redact_sensitive_text
return redact_sensitive_text(text, force=True)
except Exception as exc:
logger.warning("Trace upload redaction failed; refusing upload", exc_info=True)
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc
def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]:
"""Normalize a message ``content`` field into Anthropic content blocks."""
if content is None:
return []
if isinstance(content, str):
return [{"type": "text", "text": _redact(content, redact)}]
if isinstance(content, list):
blocks: List[Dict[str, Any]] = []
for part in content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
blocks.append({"type": "text", "text": _redact(part.get("text", ""), redact)})
elif ptype in ("image_url", "image"):
# Keep a placeholder; the viewer renders text turns and we
# don't want to inline base64 blobs into a trace.
blocks.append({"type": "text", "text": "[image omitted]"})
else:
blocks.append({"type": "text", "text": _redact(json.dumps(part), redact)})
else:
blocks.append({"type": "text", "text": _redact(str(part), redact)})
return blocks
return [{"type": "text", "text": _redact(json.dumps(content), redact)}]
def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]:
"""Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks."""
blocks: List[Dict[str, Any]] = []
if not isinstance(tool_calls, list):
return blocks
for tc in tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get("function") or {}
name = fn.get("name") or tc.get("name") or "tool"
raw_args = fn.get("arguments")
if isinstance(raw_args, str):
try:
parsed = json.loads(raw_args) if raw_args.strip() else {}
except (json.JSONDecodeError, ValueError):
parsed = {"_raw": raw_args}
elif isinstance(raw_args, dict):
parsed = raw_args
else:
parsed = {}
if redact:
try:
parsed = json.loads(_redact(json.dumps(parsed), redact))
except (json.JSONDecodeError, ValueError):
logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload")
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE)
blocks.append({
"type": "tool_use",
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}",
"name": name,
"input": parsed,
})
return blocks
def build_trace_jsonl(
messages: List[Dict[str, Any]],
*,
session_id: str,
model: str = "",
cwd: str = "",
redact: bool = True,
) -> str:
"""Render Hermes conversation messages as Claude Code JSONL text.
Each non-system message becomes one JSONL line in the Claude Code
transcript shape the HF Agent Trace Viewer auto-detects:
* ``user`` / ``tool`` -> ``{"type": "user", "message": {...}}``
* ``assistant`` -> ``{"type": "assistant", "message": {...}}``
with ``content`` blocks (text + ``tool_use``).
Tool results are emitted as user turns carrying a ``tool_result``
block keyed by ``tool_call_id`` the same way Claude Code records
them. Turns are linked via ``uuid`` / ``parentUuid``.
"""
lines: List[str] = []
parent: Optional[str] = None
base_ts = _now_iso()
git_branch = ""
try:
import subprocess
if cwd:
r = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=3, cwd=cwd,
)
if r.returncode == 0:
git_branch = r.stdout.strip()
except Exception:
git_branch = ""
def _common(turn_uuid: str) -> Dict[str, Any]:
return {
"parentUuid": parent,
"isSidechain": False,
"userType": "external",
"cwd": cwd or os.getcwd(),
"sessionId": session_id,
"version": _HERMES_VERSION,
"gitBranch": git_branch,
"uuid": turn_uuid,
"timestamp": base_ts,
}
for msg in messages:
role = msg.get("role")
if role == "system":
continue
turn_uuid = str(uuid.uuid4())
if role == "assistant":
blocks = _content_to_blocks(msg.get("content"), redact)
blocks.extend(_tool_calls_to_blocks(msg.get("tool_calls"), redact))
if not blocks:
blocks = [{"type": "text", "text": ""}]
entry = _common(turn_uuid)
entry["type"] = "assistant"
entry["message"] = {
"role": "assistant",
"model": model or "unknown",
"content": blocks,
}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
continue
if role == "tool":
tool_use_id = msg.get("tool_call_id") or msg.get("tool_name") or "tool"
result_content = _redact(
msg.get("content") if isinstance(msg.get("content"), str)
else json.dumps(msg.get("content")),
redact,
)
entry = _common(turn_uuid)
entry["type"] = "user"
entry["message"] = {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result_content,
}],
}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
continue
# Default: user (and any unknown role) -> user turn.
content = msg.get("content")
if isinstance(content, str):
message_content: Any = _redact(content, redact)
else:
message_content = _content_to_blocks(content, redact)
entry = _common(turn_uuid)
entry["type"] = "user"
entry["message"] = {"role": "user", "content": message_content}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
return "\n".join(lines) + ("\n" if lines else "")
# ---------------------------------------------------------------------------
# Upload
# ---------------------------------------------------------------------------
def _resolve_hf_token() -> Optional[str]:
"""Return the user's Hugging Face token from the usual env vars."""
for var in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"):
val = os.getenv(var)
if val and val.strip():
return val.strip()
return None
_NO_TOKEN_MESSAGE = (
"Can't upload — no Hugging Face token is available. To set it up:\n"
"\n"
"1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n"
" (New token -> type \"Write\" -> copy it).\n"
"2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n"
" HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n"
"3. Run /upload-trace again (or `hermes trace upload`)."
)
def _do_upload(
jsonl: str,
*,
token: str,
session_id: str,
dataset_name: str = DEFAULT_DATASET_NAME,
private: bool = True,
) -> str:
"""Create (idempotently) the private dataset and push the trace file.
Returns a user-facing status string. Never raises.
"""
try:
from tools import lazy_deps
lazy_deps.ensure("tool.trace_upload", prompt=False)
except Exception:
# lazy-install unavailable/declined — fall through to the import,
# which surfaces the install hint below if the package is missing.
pass
try:
from huggingface_hub import HfApi
except ImportError:
return ("Hugging Face upload needs the `huggingface_hub` package "
"(`pip install huggingface_hub`).")
api = HfApi(token=token)
try:
who = api.whoami()
user = who.get("name") if isinstance(who, dict) else None
except Exception as e:
logger.warning("HF whoami failed: %s", e)
return ("Your Hugging Face token was rejected (whoami failed). "
"Make sure it has WRITE access and isn't expired.")
if not user:
return "Could not resolve your Hugging Face username from the token."
repo_id = f"{user}/{dataset_name}"
try:
api.create_repo(
repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True,
)
except Exception as e:
logger.warning("HF create_repo failed for %s: %s", repo_id, e)
return f"Could not create/access dataset {repo_id}: {e}"
path_in_repo = f"sessions/{session_id}.jsonl"
try:
api.upload_file(
path_or_fileobj=jsonl.encode("utf-8"),
path_in_repo=path_in_repo,
repo_id=repo_id,
repo_type="dataset",
commit_message=f"add session trace {session_id}",
)
except Exception as e:
logger.warning("HF upload_file failed for %s: %s", repo_id, e)
return f"Upload to Hugging Face failed: {e}"
return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n"
f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}")
def load_session_messages(
session_id: str, db_path=None
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""Load a session's conversation + metadata from the SQLite store.
Returns ``(messages, meta)``. ``meta`` is ``{}`` when the session row is
missing (messages may still be present for a live, untitled session).
"""
from hermes_state import SessionDB
db = SessionDB(db_path=db_path) if db_path else SessionDB()
resolved = db.resolve_session_id(session_id) or session_id
meta = db.get_session(resolved) or {}
messages = db.get_messages_as_conversation(resolved)
return messages, meta
def upload_session_trace(
session_id: str,
*,
model: str = "",
cwd: str = "",
redact: bool = True,
private: bool = True,
dataset_name: str = DEFAULT_DATASET_NAME,
db_path=None,
token: Optional[str] = None,
) -> str:
"""Top-level entry point used by the CLI/gateway/subcommand.
Loads the session, converts it to Claude Code JSONL, and uploads it to
the user's private ``{user}/hermes-traces`` dataset. Returns a
user-facing status string and never raises.
"""
if not session_id:
return "No active session to upload."
token = token or _resolve_hf_token()
if not token:
return _NO_TOKEN_MESSAGE
try:
messages, meta = load_session_messages(session_id, db_path=db_path)
except Exception as e:
logger.warning("Failed to load session %s for trace upload: %s", session_id, e)
return f"Could not load session {session_id}: {e}"
if not messages:
return "No transcript to upload for this session yet."
resolved_model = model or meta.get("model") or ""
try:
jsonl = build_trace_jsonl(
messages,
session_id=session_id,
model=resolved_model,
cwd=cwd,
redact=redact,
)
except TraceRedactionError:
return _REDACTION_BLOCKED_MESSAGE
if not jsonl.strip():
return "No transcript content to upload for this session."
return _do_upload(
jsonl,
token=token,
session_id=session_id,
dataset_name=dataset_name,
private=private,
)

View file

@ -423,7 +423,10 @@ class ChatCompletionsTransport(ProviderTransport):
if gh_reasoning is not None:
extra_body["reasoning"] = gh_reasoning
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
_effort = "medium"
if reasoning_config and isinstance(reasoning_config, dict):
_effort = reasoning_config.get("effort", "medium") or "medium"
extra_body["reasoning"] = {"enabled": True, "effort": _effort}
if provider_name == "gemini":
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
@ -606,7 +609,11 @@ class ChatCompletionsTransport(ProviderTransport):
"""
choice = response.choices[0]
msg = choice.message
finish_reason = choice.finish_reason or "stop"
# Poolside returns integer finish_reason (e.g. 24) instead of string
_fr = choice.finish_reason
if isinstance(_fr, int):
_fr = str(_fr)
finish_reason = _fr or "stop"
tool_calls = None
if msg.tool_calls:

View file

@ -75,6 +75,7 @@ class TurnResult:
token_usage_last: Optional[dict[str, Any]] = None
token_usage_total: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
compacted: bool = False
# Hint to the caller that the underlying codex subprocess is likely
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
# token-refresh failure killed the child). The caller should retire
@ -505,6 +506,7 @@ class CodexAppServerSession:
if pending is None:
break
_apply_token_usage_notification(result, pending)
_apply_compaction_notification(result, pending)
self._track_pending_file_change(pending)
proj = projector.project(pending)
if proj.messages:
@ -541,6 +543,7 @@ class CodexAppServerSession:
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
_apply_compaction_notification(result, note)
# Track in-progress fileChange items so the approval bridge
# can surface a real change summary when codex requests
@ -604,6 +607,19 @@ class CodexAppServerSession:
f"turn ended status={turn_status}", err_msg
)
if (
not turn_complete
and not result.interrupted
and result.final_text
and result.error is None
):
logger.warning(
"codex app-server turn reached deadline after a completed "
"assistant message but before turn/completed; accepting "
"the assistant text as the terminal response"
)
turn_complete = True
if not turn_complete and not result.interrupted:
# Hit the deadline. Issue interrupt to stop wasted compute, and
# tell the caller to retire the session — a turn that never
@ -619,6 +635,154 @@ class CodexAppServerSession:
return result
def compact_thread(
self,
*,
turn_timeout: float = 600.0,
notification_poll_timeout: float = 0.25,
) -> TurnResult:
"""Trigger Codex-native history compaction for the current thread.
`thread/compact/start` returns immediately; the actual compaction
progress streams through the same turn/item notifications as a normal
turn. We wait for the matching `turn/completed` so callers can treat a
successful return as a completed compaction boundary.
"""
result = TurnResult()
try:
self.ensure_started()
except (CodexAppServerError, TimeoutError) as exc:
result.error = self._format_error_with_stderr(
"codex app-server startup failed", exc
)
result.should_retire = True
return result
assert self._client is not None and self._thread_id is not None
result.thread_id = self._thread_id
self._interrupt_event.clear()
projector = CodexEventProjector()
try:
self._client.request(
"thread/compact/start",
{"threadId": self._thread_id},
timeout=10,
)
except CodexAppServerError as exc:
stderr_blob = "\n".join(self._client.stderr_tail(40))
hint = _classify_oauth_failure(exc.message, stderr_blob)
if hint is not None:
result.error = hint
result.should_retire = True
else:
result.error = self._format_error_with_stderr(
"thread/compact/start failed", exc
)
return result
except TimeoutError as exc:
stderr_blob = "\n".join(self._client.stderr_tail(40))
hint = _classify_oauth_failure(stderr_blob)
result.error = hint or self._format_error_with_stderr(
"thread/compact/start timed out", exc
)
result.should_retire = True
return result
deadline = time.monotonic() + turn_timeout
turn_complete = False
while time.monotonic() < deadline and not turn_complete:
if self._interrupt_event.is_set():
self._issue_interrupt(result.turn_id)
result.interrupted = True
break
if not self._client.is_alive():
stderr_blob = "\n".join(self._client.stderr_tail(60))
hint = _classify_oauth_failure(stderr_blob)
if hint is not None:
result.error = hint
else:
result.error = self._format_error_with_stderr(
"codex app-server subprocess exited unexpectedly",
tail_lines=20,
)
result.should_retire = True
break
sreq = self._client.take_server_request(timeout=0)
if sreq is not None:
self._handle_server_request(sreq)
continue
note = self._client.take_notification(
timeout=notification_poll_timeout
)
if note is None:
continue
method = note.get("method", "")
if self._on_event is not None:
try:
self._on_event(note)
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
_apply_compaction_notification(result, note)
self._track_pending_file_change(note)
projection = projector.project(note)
if projection.messages:
result.projected_messages.extend(projection.messages)
if projection.is_tool_iteration:
result.tool_iterations += 1
if projection.final_text is not None:
result.final_text = projection.final_text
if _has_turn_aborted_marker(projection.final_text):
turn_complete = True
result.interrupted = True
result.error = (
result.error or "codex reported turn_aborted"
)
if method == "turn/started":
turn_obj = (note.get("params") or {}).get("turn") or {}
result.turn_id = turn_obj.get("id") or result.turn_id
elif method == "turn/completed":
turn_complete = True
turn_obj = (note.get("params") or {}).get("turn") or {}
result.turn_id = turn_obj.get("id") or result.turn_id
turn_status = turn_obj.get("status")
if turn_status and turn_status not in {"completed", "interrupted"}:
err_obj = turn_obj.get("error")
if err_obj:
err_msg = _format_responses_error(err_obj, str(turn_status))
stderr_blob = "\n".join(
self._client.stderr_tail(40)
)
hint = _classify_oauth_failure(err_msg, stderr_blob)
if hint is not None:
result.error = hint
result.should_retire = True
else:
result.error = self._format_error_with_stderr(
f"compact turn ended status={turn_status}",
err_msg,
)
if not turn_complete and not result.interrupted:
self._issue_interrupt(result.turn_id)
result.interrupted = True
if not result.error:
result.error = self._format_error_with_stderr(
f"compact turn timed out after {turn_timeout}s"
)
result.should_retire = True
return result
# ---------- internals ----------
def _issue_interrupt(self, turn_id: Optional[str]) -> None:
@ -832,6 +996,38 @@ def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
result.model_context_window = window
def _apply_compaction_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex-native context compaction boundaries.
Recent app-server builds expose compaction as a ContextCompaction item.
Older builds also emit the deprecated thread/compacted notification. Both
mean the underlying Codex thread history has been compacted.
"""
if not isinstance(note, dict):
return
method = note.get("method") or ""
params = note.get("params") or {}
if not isinstance(params, dict):
return
if method == "thread/compacted":
result.compacted = True
result.thread_id = params.get("threadId") or result.thread_id
result.turn_id = params.get("turnId") or result.turn_id
return
if method not in {"item/started", "item/completed"}:
return
item = params.get("item") or {}
if not isinstance(item, dict) or item.get("type") != "contextCompaction":
return
result.compacted = True
result.thread_id = params.get("threadId") or result.thread_id
result.turn_id = params.get("turnId") or result.turn_id
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
/ FileChangeApprovalDecision wire values.

View file

@ -217,7 +217,9 @@ class CodexEventProjector:
def _project_mcp_tool_call(self, item: dict, item_id: str) -> ProjectionResult:
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
call_id = _deterministic_call_id(f"mcp_{server}_{tool}", item_id)
# Mirror the native MCP tool-name convention (mcp__server__tool) so the
# deterministic call_id input stays consistent with registration names.
call_id = _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
args = item.get("arguments") or {}
if not isinstance(args, dict):
args = {"arguments": args}

View file

@ -185,9 +185,19 @@ def build_turn_context(
# name and leaves the snapshot untouched on no-change).
try:
if not getattr(agent, "_skip_mcp_refresh", False):
from tools.mcp_tool import has_registered_mcp_tools, refresh_agent_mcp_tools
if has_registered_mcp_tools():
refresh_agent_mcp_tools(agent, quiet_mode=True)
# Import-cost gate: ``tools.mcp_tool`` pulls in the whole ``mcp``
# package (~0.4s measured) even when the user has zero MCP servers
# configured. MCP tools can only be registered by code that has
# already imported ``tools.mcp_tool`` (discovery, /reload-mcp,
# late-binding refresh) — so if it isn't in sys.modules yet, there
# is nothing to refresh and the import can be skipped outright.
# This keeps the no-MCP first turn off the heavy import path
# without changing behavior for MCP users.
import sys as _sys
if "tools.mcp_tool" in _sys.modules:
from tools.mcp_tool import has_registered_mcp_tools, refresh_agent_mcp_tools
if has_registered_mcp_tools():
refresh_agent_mcp_tools(agent, quiet_mode=True)
except Exception:
logger.debug("between-turns MCP tool refresh skipped", exc_info=True)
@ -277,6 +287,9 @@ def build_turn_context(
# Track user turns for memory flush and periodic nudge logic.
agent._user_turn_count += 1
# Copilot x-initiator: the first API call of this user turn is
# user-initiated; tool-loop follow-ups revert to "agent" (#3040).
agent._is_user_initiated_turn = True
# Reset the streaming context scrubber at the top of each turn.
scrubber = getattr(agent, "_stream_context_scrubber", None)
@ -356,6 +369,20 @@ def build_turn_context(
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
# Codex app-server threads are compacted by the codex agent itself;
# Hermes only initiates compaction in "hermes" mode (#36801).
_codex_native_auto = (
getattr(agent, "api_mode", None) == "codex_app_server"
and str(
getattr(
agent,
"codex_app_server_auto_compaction",
"native",
)
or "native"
).lower()
in {"native", "off"}
)
if not _preflight_deferred:
_last = _compressor.last_prompt_tokens
@ -384,6 +411,12 @@ def build_turn_context(
int(_compression_cooldown.get("remaining_seconds", 0.0)),
agent.session_id or "none",
)
elif _codex_native_auto:
logger.info(
"Skipping Hermes preflight compression for codex app-server "
"(mode=%s); Hermes will not start thread compaction here.",
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
@ -445,11 +478,37 @@ def build_turn_context(
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
# Spill oversized per-hook context to disk so a runaway plugin
# can't inflate every subsequent turn's prompt. Ported from
# openai/codex PR #21069 ("Spill large hook outputs from context").
try:
from tools.hook_output_spill import (
get_spill_config as _spill_cfg,
spill_if_oversized as _spill_if_oversized,
)
_spill_config_cached = _spill_cfg()
except Exception:
_spill_if_oversized = None # type: ignore[assignment]
_spill_config_cached = None
for r in _pre_results:
_piece: str = ""
if isinstance(r, dict) and r.get("context"):
_ctx_parts.append(str(r["context"]))
_piece = str(r["context"])
elif isinstance(r, str) and r.strip():
_ctx_parts.append(r)
_piece = r
else:
continue
if _spill_if_oversized is not None:
try:
_piece = _spill_if_oversized(
_piece,
session_id=agent.session_id,
source="plugin hook",
config=_spill_config_cached,
)
except Exception as _spill_exc:
logger.warning("hook context spill failed: %s", _spill_exc)
_ctx_parts.append(_piece)
if _ctx_parts:
plugin_user_context = "\n\n".join(_ctx_parts)
except Exception as exc:

View file

@ -820,9 +820,22 @@ def normalize_usage(
input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens)
reasoning_tokens = 0
# Responses API shape: output_tokens_details.reasoning_tokens.
# Chat Completions shape (OpenAI, OpenRouter, DeepSeek, etc.):
# completion_tokens_details.reasoning_tokens. Reading only the former
# left reasoning_tokens=0 for every chat_completions reasoning model —
# hidden thinking was invisible in session accounting even though it
# dominates output spend on models like deepseek-v4-flash (measured:
# single calls burning 21K reasoning tokens to emit 500 visible tokens).
output_details = getattr(response_usage, "output_tokens_details", None)
if output_details:
reasoning_tokens = _to_int(getattr(output_details, "reasoning_tokens", 0))
if not reasoning_tokens:
completion_details = getattr(response_usage, "completion_tokens_details", None)
if completion_details:
reasoning_tokens = _to_int(
getattr(completion_details, "reasoning_tokens", 0)
)
return CanonicalUsage(
input_tokens=input_tokens,

View file

@ -52,7 +52,33 @@ On failure (either capability)::
from __future__ import annotations
import abc
from typing import Any, Dict, List
import os
from typing import Any, Dict, List, Optional
def get_provider_env(name: str) -> str:
"""Config-aware env lookup for web providers.
Resolves *name* via :func:`hermes_cli.config.get_env_value` (checks
``os.environ`` first, then ``~/.hermes/.env``) so credentials set
through Hermes' config layer are visible even when they were never
exported into the process environment gateway sessions, delegate
children, and subprocess agent runs (issue #40190). Falls back to a
bare ``os.getenv`` when the config module is unavailable (stripped
installs, early import contexts).
Returns the stripped value, or ``""`` when unset.
"""
val: Optional[str] = None
try:
from hermes_cli.config import get_env_value
val = get_env_value(name)
except Exception: # noqa: BLE001 — config layer optional here
val = None
if val is None:
val = os.getenv(name, "")
return (val or "").strip()
# ---------------------------------------------------------------------------

View file

@ -219,6 +219,65 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc
return None
def _disabled_web_plugin_for(configured: Optional[str] = None, *, capability: Optional[str] = None) -> Optional[str]:
"""Return the plugin key of a *disabled* bundled web plugin that would
have provided the configured backend, or None.
When a user sets ``web.extract_backend: firecrawl`` (or the search
equivalent) but also lists ``web-firecrawl`` in ``plugins.disabled``,
the provider never registers and the dispatcher would otherwise emit a
misleading "No web extract provider configured. Set web.extract_backend
to ..." error — even though the backend IS configured correctly. The
real fix is to re-enable the plugin. This helper detects that case so
the dispatcher can point the user at the actual cause (issue #40190
follow-up: pi314's disabled-plugin symptom).
Pass ``capability`` ("search" | "extract") to resolve the configured
name straight from ``config.yaml`` (``web.<capability>_backend``
``web.backend``). This is more reliable than the resolved backend the
dispatcher fell back to, since a disabled provider fails the
``_is_backend_available`` gate and the dispatcher silently drops to
the shared default. An explicit ``configured`` name still wins when
given.
Matching is by convention: bundled web plugins live under the
``web/<vendor>`` key with the provider ``name`` differing only in
hyphen/underscore (``brave-free`` provider ``web/brave_free`` key,
``firecrawl`` ``web/firecrawl``). We normalize both sides before
comparing so every bundled provider is covered without hardcoding a
per-vendor table.
"""
def _norm(s: str) -> str:
return s.strip().lower().replace("-", "_")
if not configured and capability in ("search", "extract"):
configured = (
_read_config_key("web", f"{capability}_backend")
or _read_config_key("web", "backend")
)
if not configured:
return None
want = _norm(configured)
try:
from hermes_cli.plugins import get_plugin_manager
pm = get_plugin_manager()
for key, loaded in pm._plugins.items():
if not isinstance(key, str) or not key.startswith("web/"):
continue
if loaded.enabled:
continue
if loaded.error != "disabled via config":
continue
vendor = key.split("/", 1)[1]
if _norm(vendor) == want:
return key
except Exception as exc: # noqa: BLE001 — diagnostics are best-effort
logger.debug("disabled-web-plugin lookup failed: %s", exc)
return None
def get_active_search_provider() -> Optional[WebSearchProvider]:
"""Resolve the currently-active web search provider.

View file

@ -230,6 +230,14 @@ async fn run_update(app: AppHandle) -> Result<()> {
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
//
// NOTE: --force does NOT bypass the venv-python holder guard (that needs
// an explicit `--force-venv`, which we deliberately do not pass). Our lock
// probe only checks the hermes.exe shim and app.asar, so an external venv
// python holding a native .pyd (a user terminal, an unmanaged gateway)
// could still be alive here — mutating the venv under it would strand the
// install half-updated. If that guard fires, it exits 2 and the match arm
// below surfaces the correct "close all Hermes windows" message.
update_args.push("--force".into());
update_args.push("--branch".into());
update_args.push(update_branch);

View file

@ -47,5 +47,5 @@ function sourceDeclaresServe(dashboardPySource) {
module.exports = {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe,
sourceDeclaresServe
}

View file

@ -3,32 +3,14 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe,
} = require('./backend-command.cjs')
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
test('serveBackendArgs builds a headless serve invocation', () => {
assert.deepEqual(serveBackendArgs(), [
'serve',
'--host',
'127.0.0.1',
'--port',
'0',
])
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
})
test('serveBackendArgs pins a profile when provided', () => {
assert.deepEqual(serveBackendArgs('worker'), [
'--profile',
'worker',
'serve',
'--host',
'127.0.0.1',
'--port',
'0',
])
assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'])
})
test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => {
@ -41,7 +23,7 @@ test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -
'--host',
'127.0.0.1',
'--port',
'0',
'0'
])
})
@ -57,7 +39,7 @@ test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => {
'--host',
'127.0.0.1',
'--port',
'0',
'0'
])
})

View file

@ -44,7 +44,7 @@ const PROBE_TIMEOUT_MS = 5000
* @returns {string}
*/
function hermesRuntimeImportProbe() {
return 'import yaml; import hermes_cli.config'
return 'import yaml; import dotenv; import hermes_cli.config'
}
/**

View file

@ -43,6 +43,10 @@ test('canImportHermesCli returns false when binary does not exist', () => {
test('hermes runtime import probe checks config dependencies', () => {
const probe = hermesRuntimeImportProbe()
assert.match(probe, /\bimport yaml\b/)
// dotenv is the first third-party import on the CLI boot path
// (hermes_cli/env_loader.py); a mid-update venv missing python-dotenv
// passed the old probe and produced an unrecoverable boot loop.
assert.match(probe, /\bimport dotenv\b/)
assert.match(probe, /\bimport hermes_cli\.config\b/)
})

View file

@ -1,6 +1,9 @@
const fs = require('node:fs')
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
// works against both the headless backend and old/dashboard runtimes.
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
// The announcement clock starts the instant the backend process is spawned —
// before uvicorn binds its socket. On a cold install the child must first
@ -30,8 +33,8 @@ function resolvePortAnnounceTimeoutMs(env = process.env) {
}
/**
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
* line that web_server.py prints after uvicorn binds its socket.
* Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY
* port=<N>` line that web_server.py prints after uvicorn binds its socket.
*
* Returns the parsed port. Rejects if:
* - the child exits before emitting the line

View file

@ -86,6 +86,13 @@ test('resolves with the announced port', async () => {
assert.equal(await p, 54321)
})
test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n')
assert.equal(await p, 43210)
})
test('parses the port even when the line arrives split across chunks', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)

View file

@ -3,8 +3,7 @@
// Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't
// read a page <title> (bot walls, JS-rendered pages), we briefly load the URL
// in an offscreen window and read its title. That window loads arbitrary
// user-linked pages — including YouTube/`watch` URLs that autoplay — so it must
// never be allowed to emit sound.
// user-linked pages, so it must never emit sound or trigger real downloads.
function linkTitleWindowOptions(partitionSession) {
return {
@ -39,4 +38,34 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) {
return window
}
module.exports = { createLinkTitleWindow, linkTitleWindowOptions }
// Cancel any download the title-fetch window triggers. Without this, a link
// artifact URL served with Content-Disposition: attachment auto-downloads every
// time the Artifacts page renders and fetchLinkTitle loads it.
function guardLinkTitleSession(partitionSession) {
try {
partitionSession.on('will-download', (_event, item) => item.cancel())
} catch {
// best-effort; worst case is a spurious download
}
}
// Read the page title from a title-fetch window. Callers schedule this from
// timers that can fire after finish() destroys the window, so every access must
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
function readLinkTitleWindowTitle(window) {
try {
if (!window || window.isDestroyed()) return ''
const contents = window.webContents
if (!contents || contents.isDestroyed()) return ''
return contents.getTitle() || ''
} catch {
return ''
}
}
module.exports = {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
}

View file

@ -1,7 +1,12 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { createLinkTitleWindow, linkTitleWindowOptions } = require('./link-title-window.cjs')
const {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
} = require('./link-title-window.cjs')
function makeFakeBrowserWindow() {
const calls = { audioMuted: [] }
@ -54,3 +59,70 @@ test('createLinkTitleWindow still returns the window if muting throws', () => {
assert.ok(window instanceof ThrowingBrowserWindow)
})
test('guardLinkTitleSession cancels downloads triggered by the title-fetch window', () => {
let cancelled = false
const handlers = {}
guardLinkTitleSession({
on: (e, h) => {
handlers[e] = h
}
})
handlers['will-download'](null, {
cancel: () => {
cancelled = true
}
})
assert.ok(cancelled)
})
test('guardLinkTitleSession is a no-op when session.on throws', () => {
assert.doesNotThrow(() =>
guardLinkTitleSession({
on() {
throw new Error()
}
})
)
})
test('readLinkTitleWindowTitle returns empty for missing or destroyed windows', () => {
assert.equal(readLinkTitleWindowTitle(null), '')
assert.equal(readLinkTitleWindowTitle(undefined), '')
assert.equal(readLinkTitleWindowTitle({ isDestroyed: () => true }), '')
})
test('readLinkTitleWindowTitle returns empty when webContents is destroyed', () => {
const window = {
isDestroyed: () => false,
webContents: { isDestroyed: () => true, getTitle: () => 'Should Not Read' }
}
assert.equal(readLinkTitleWindowTitle(window), '')
})
test('readLinkTitleWindowTitle swallows getTitle throws after teardown', () => {
const window = {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
getTitle: () => {
throw new Error('Object has been destroyed')
}
}
}
assert.equal(readLinkTitleWindowTitle(window), '')
})
test('readLinkTitleWindowTitle returns trimmed page title', () => {
const window = {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
getTitle: () => 'Example Domain'
}
}
assert.equal(readLinkTitleWindowTitle(window), 'Example Domain')
})

View file

@ -21,6 +21,7 @@ const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
const os = require('node:os')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
@ -35,7 +36,11 @@ const {
SESSION_WINDOW_MIN_WIDTH
} = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { createLinkTitleWindow } = require('./link-title-window.cjs')
const {
createLinkTitleWindow,
guardLinkTitleSession,
readLinkTitleWindowTitle
} = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
@ -45,9 +50,12 @@ const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-ma
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
const {
nativeOverlayWidth: computeNativeOverlayWidth,
macTitleBarOverlayHeight
} = require('./titlebar-overlay-width.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { readLiveUpdateMarker } = require('./update-marker.cjs')
const { readLiveUpdateMarker, writeUpdateMarker } = require('./update-marker.cjs')
const {
resolveUnpackedRelease,
decideRelaunchOutcome,
@ -160,6 +168,9 @@ const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
// Truthful macOS kernel major (Tahoe = 25). Product version lies (16 vs 26) per
// build SDK, so gate Tahoe workarounds on Darwin instead.
const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0
const APP_ROOT = app.getAppPath()
function hiddenWindowsChildOptions(options = {}) {
@ -532,7 +543,10 @@ const TITLEBAR_OVERLAY_COLOR = 'rgba(1, 0, 0, 0)'
function getTitleBarOverlayOptions() {
if (IS_MAC) {
return { height: TITLEBAR_HEIGHT }
// Tahoe (Darwin 25+) misplaces the traffic lights when the overlay has a
// nonzero height (electron#49183); 0 there keeps them at the configured
// inset. See macTitleBarOverlayHeight.
return { height: macTitleBarOverlayHeight({ darwinMajor: DARWIN_MAJOR, titlebarHeight: TITLEBAR_HEIGHT }) }
}
// WSLg paints WCO via the RDP host's own min/max/close, so requesting
@ -1324,6 +1338,28 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) {
if (!fileExists(python)) return null
const root = path.dirname(venvRoot)
// Smoke-test the venv interpreter before trusting it. A venv whose update
// died mid-`pip install` still has python.exe + hermes.exe on disk, but the
// backend dies on its first import (e.g. ModuleNotFoundError: dotenv) before
// the gateway ever binds. Returning it here also BYPASSED the caller's
// `--version` probe, so Retry/"Repair install" re-resolved the same broken
// venv forever instead of falling through to the bootstrap installer.
// Mirror isActiveRuntimeUsable(): probe with the checkout on PYTHONPATH so a
// healthy source-tree venv passes.
if (
!canImportHermesCli(python, {
env: {
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
}
})
) {
rememberLog(
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
)
return null
}
return {
label: `existing Hermes Python at ${python}`,
command: python,
@ -1361,10 +1397,7 @@ function backendSupportsServe(backend) {
let supported = null
if (backend.root) {
try {
const src = fs.readFileSync(
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
'utf8'
)
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
supported = sourceDeclaresServe(src)
} catch {
supported = null // source unreadable — fall through to the probe
@ -2154,9 +2187,25 @@ async function releaseBackendLock(updateRoot, tag) {
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
return { unlocked: true }
}
// A supervised backend can respawn between kill and check (grandchildren,
// pool entries registered mid-teardown). Re-collect and re-kill each pass
// instead of trusting the initial sweep.
const stragglers = []
if (hermesProcess && Number.isInteger(hermesProcess.pid)) stragglers.push(hermesProcess.pid)
for (const entry of backendPool.values()) {
if (entry.process && Number.isInteger(entry.process.pid)) stragglers.push(entry.process.pid)
}
for (const pid of stragglers) forceKillProcessTree(pid)
await new Promise(r => setTimeout(r, 300))
}
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
// Do NOT proceed past a held lock: handing off to the updater while another
// process (a second desktop window, a user terminal, an unkillable child)
// still maps the venv's files guarantees a half-updated venv — the updater's
// dependency sync dies on access-denied partway through uninstalls, leaving
// imports broken (the July 2026 brotlicffi/_sodium.pyd incidents). Failing
// the update loudly and keeping the app running is strictly better than a
// bricked install that needs manual venv surgery.
rememberLog(`[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)`)
return { unlocked: false }
}
@ -2236,7 +2285,20 @@ async function applyUpdates(opts = {}) {
// spawn the updater. Without this the updater races a still-locked
// hermes.exe (held by the backend child / its grandchildren) and the update
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
await releaseBackendLockForUpdate(updateRoot)
const lock = await releaseBackendLockForUpdate(updateRoot)
if (!lock.unlocked) {
// Something OUTSIDE this app holds the venv (a second window, a user
// terminal running hermes, an unkillable child). Handing off anyway
// guarantees a half-updated venv — abort loudly instead and let the
// user close the holder and retry. Restart our own backend so the app
// keeps working after the failed attempt.
const message =
'Update aborted: another process is holding the Hermes install open ' +
'(a second Hermes window or a terminal running hermes?). Close it and retry.'
emitUpdateProgress({ stage: 'error', message, percent: null })
startHermes().catch(() => {})
return { ok: false, error: message }
}
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
@ -2253,6 +2315,17 @@ async function applyUpdates(opts = {}) {
})
child.unref()
// Write the update-in-progress marker IMMEDIATELY — before the 2.5s
// quit dwell. The Tauri updater won't write its own marker for several
// seconds (window init + manifest), and during that gap our renderer
// can reconnect and spawn a fresh backend that re-locks .pyd files in
// the venv. By writing the marker ourselves the renderer's
// waitForUpdateToFinish() gate sees a live update and parks instead.
// The updater overwrites this with its own PID later; same format.
if (Number.isInteger(child.pid)) {
writeUpdateMarker(HERMES_HOME, child.pid)
}
rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`)
// Linger on the "updating — don't reopen" overlay long enough for the user
@ -2292,9 +2365,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
// and the bootstrap-complete marker are present earlier and are better signals.
const haveRealInstall =
fileExists(venvPython) ||
fileExists(venvHermes) ||
fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
@ -2312,6 +2383,13 @@ async function handOffWindowsBootstrapRecovery(reason) {
})
child.unref()
// Same marker pre-write as applyUpdates — see comment there. The recovery
// hand-off has the same window where the renderer can respawn a backend
// before the updater writes its own marker.
if (Number.isInteger(child.pid)) {
writeUpdateMarker(HERMES_HOME, child.pid)
}
rememberLog(
`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`
)
@ -3499,6 +3577,7 @@ function getLinkTitleSession() {
linkTitleSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) })
})
guardLinkTitleSession(linkTitleSession)
return linkTitleSession
}
@ -3546,13 +3625,13 @@ function runRenderTitleJob(rawUrl) {
return finish('')
}
const readTitle = () => window?.webContents?.getTitle?.() || ''
const finishWithTitle = () => finish(readLinkTitleWindowTitle(window))
const scheduleGrace = () => {
if (graceTimer) clearTimeout(graceTimer)
graceTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_GRACE_MS)
graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS)
}
hardTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_TIMEOUT_MS)
hardTimer = setTimeout(finishWithTitle, RENDER_TITLE_TIMEOUT_MS)
window.webContents.setUserAgent(TITLE_USER_AGENT)
window.webContents.on('page-title-updated', scheduleGrace)
@ -4088,17 +4167,15 @@ function installPreviewShortcut(window) {
// survives reloads/restarts) rather than a main-process JSON file. The main
// process owns setZoomLevel, so we mirror each change into localStorage and
// read it back on did-finish-load to re-apply after reloads or crash recovery.
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, -9), 9)
}
const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')
function setAndPersistZoomLevel(window, zoomLevel) {
if (!window || window.isDestroyed()) return
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
// Keep any open settings UI in sync, including changes made via the
// keyboard shortcuts or the View menu.
window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
window.webContents
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
@ -6079,6 +6156,21 @@ ipcMain.handle('hermes:window:openNewSession', async () => {
return { ok: true }
})
// --- Text size (zoom) -------------------------------------------------------
// The settings UI drives the same clamped zoom scale as the Ctrl/Cmd
// shortcuts and the View menu. Reads and writes target the asking window.
ipcMain.handle('hermes:zoom:get', event => {
const window = BrowserWindow.fromWebContents(event.sender)
const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : 0
return { level, percent: zoomLevelToPercent(level) }
})
ipcMain.on('hermes:zoom:set-percent', (event, percent) => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window || window.isDestroyed()) return
setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))
})
// --- Pet overlay (pop-out mascot) -----------------------------------------
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
// bounds (screen=false): convert to screen space by adding the main window's

View file

@ -0,0 +1,30 @@
/**
* Regression coverage for the OAuth-session Electron net.request path.
*
* Electron net rejects manual Content-Length/Host headers with
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
* this guard is scoped to fetchJsonViaOauthSession only.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8')
function extractFetchJsonViaOauthSession() {
const start = source.indexOf('function fetchJsonViaOauthSession')
const end = source.indexOf('// Mint a single-use WS ticket', start)
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
return source.slice(start, end)
}
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
const fn = extractFetchJsonViaOauthSession()
assert.match(fn, /electronNet\.request/)
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
assert.match(fn, /request\.write\(body\)/)
})

View file

@ -78,6 +78,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
},
zoom: {
// Current zoom of this window, as { level, percent }.
get: () => ipcRenderer.invoke('hermes:zoom:get'),
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
// so the settings UI can stay in sync with the keyboard.
onChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:zoom:changed', listener)
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
}
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),

View file

@ -32,11 +32,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
)
// The early-exit guard must return null (not void/undefined).
assert.match(
fnBody,
/return null/,
'early-exit guard should return null, not undefined'
)
assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined')
})
test('hermes:api handler routes profile-delete requests to the primary backend', () => {

View file

@ -12,13 +12,32 @@ const OVERLAY_FALLBACK_WIDTH = 144
* macOS uses traffic lights positioned via trafficLightPosition, not a WCO
* overlay, so it reserves nothing here. Every other desktop platform now paints
* the Electron overlay (Windows, WSLg, and plain Linux KDE/GNOME), so they all
* reserve the fallback width.
* reserve the fallback width the split is simply mac vs. not.
*
* @param {{ isWindows?: boolean, isWsl?: boolean, isMac?: boolean }} opts
* @param {{ isMac?: boolean }} opts
*/
function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
function nativeOverlayWidth({ isMac = false } = {}) {
if (isMac) return 0
return OVERLAY_FALLBACK_WIDTH
}
module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth }
// macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful,
// unlike the product version which macOS reports as 16 or 26 depending on the
// build SDK.
const MACOS_TAHOE_DARWIN_MAJOR = 25
/**
* Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+)
* miscalculates the native traffic-light position when the overlay carries a
* nonzero height (electron#49183), shoving the lights into the left titlebar
* tools. Return 0 there so `setWindowButtonPosition` lands them at the configured
* inset; the renderer paints its own drag strips, so nothing is lost. Pre-Tahoe
* keeps the full titlebar height, byte-identical.
*
* @param {{ darwinMajor?: number, titlebarHeight?: number }} opts
*/
function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight
}
module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth }

View file

@ -1,7 +1,12 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
const {
MACOS_TAHOE_DARWIN_MAJOR,
OVERLAY_FALLBACK_WIDTH,
macTitleBarOverlayHeight,
nativeOverlayWidth
} = require('./titlebar-overlay-width.cjs')
// This static reservation is only the pre-layout FALLBACK. Once laid out the
// renderer reads the exact width from navigator.windowControlsOverlay
@ -34,3 +39,16 @@ test('macOS uses traffic lights, not a WCO overlay, so it reserves nothing', ()
test('the fallback width is a sane positive pixel value', () => {
assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0)
})
test('pre-Tahoe keeps the full titlebar overlay height', () => {
assert.equal(macTitleBarOverlayHeight({ darwinMajor: MACOS_TAHOE_DARWIN_MAJOR - 1, titlebarHeight: 34 }), 34)
})
test('Tahoe (Darwin 25+) drops the overlay height to 0 to avoid electron#49183', () => {
assert.equal(macTitleBarOverlayHeight({ darwinMajor: MACOS_TAHOE_DARWIN_MAJOR, titlebarHeight: 34 }), 0)
assert.equal(macTitleBarOverlayHeight({ darwinMajor: MACOS_TAHOE_DARWIN_MAJOR + 1, titlebarHeight: 34 }), 0)
})
test('macTitleBarOverlayHeight tolerates missing args (unknown platform → 0)', () => {
assert.equal(macTitleBarOverlayHeight(), 0)
})

View file

@ -85,9 +85,43 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
return { pid, ageMs }
}
/**
* Write the update-in-progress marker *from the desktop* before handing off
* to the detached updater.
*
* The Tauri-based hermes-setup.exe takes several seconds to initialise its
* window and reach the Rust `run_update` entry point where it writes the
* marker itself. During that gap the desktop's `app.quit()` teardown kills
* the backend child, the renderer's WebSocket drops, and the renderer
* immediately calls `ensureBackend()` `waitForUpdateToFinish()`. Because
* the updater hasn't written the marker yet, the gate sees no live update
* and spawns a *new* backend which re-locks `.pyd` files in the venv.
* When the updater finally reaches the venv-rebuild stage it finds those
* files locked and the update bricks.
*
* Fix: the desktop writes the marker itself, using the spawned updater's
* PID, immediately after `spawn()`. The updater's `UpdateMarkerGuard` will
* later overwrite it with its own PID that's fine, the marker body is
* the same format and `readLiveUpdateMarker` only cares that *some* live
* pid owns it. When the updater finishes it deletes the marker as before.
* If the updater never starts (spawn failure) the marker still contains a
* real PID, so `readLiveUpdateMarker` will self-heal once that PID exits.
*/
function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
const file = markerPath(hermesHome)
const startedAt = Math.floor(now() / 1000)
try {
fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8')
} catch {
// Best-effort: if we can't write the marker, proceed anyway. The
// updater will write its own when it reaches run_update.
}
}
module.exports = {
UPDATE_MARKER_MAX_AGE_MS,
markerPath,
isPidAlive,
readLiveUpdateMarker
readLiveUpdateMarker,
writeUpdateMarker
}

View file

@ -18,7 +18,7 @@ const fs = require('fs')
const os = require('os')
const path = require('path')
const { markerPath, isPidAlive, readLiveUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs')
const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs')
function tmpHome(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`))
@ -90,3 +90,29 @@ test('isPidAlive: EPERM counts as alive (process owned by another user)', () =>
}
assert.equal(isPidAlive(4242, eperm), true)
})
test('writeUpdateMarker writes a marker that readLiveUpdateMarker accepts', () => {
const home = tmpHome('write')
const now = 1_000_000_000_000
writeUpdateMarker(home, 4242, { now: () => now })
// The marker should be readable and report the same pid.
const res = readLiveUpdateMarker(home, { kill: ALIVE, now: () => now })
assert.ok(res, 'marker written by writeUpdateMarker should be detected as live')
assert.equal(res.pid, 4242)
assert.ok(fs.existsSync(markerPath(home)), 'marker file should exist after write')
})
test('writeUpdateMarker is best-effort (no throw on bad path)', () => {
// A non-existent directory should not throw.
const badHome = path.join(os.tmpdir(), 'hermes-marker-nonexistent-' + Date.now())
assert.doesNotThrow(() => writeUpdateMarker(badHome, 4242))
})
test('writeUpdateMarker + dead pid => self-heals on read', () => {
const home = tmpHome('write-dead')
writeUpdateMarker(home, 999999, { now: () => Date.now() })
// PID 999999 is almost certainly not alive.
const res = readLiveUpdateMarker(home, { kill: DEAD })
assert.equal(res, null, 'a dead-pid marker from writeUpdateMarker self-heals')
assert.ok(!fs.existsSync(markerPath(home)), 'marker file is pruned')
})

View file

@ -14,6 +14,11 @@
// shim, written at the END of venv setup and absent in interrupted
// states), so it escalated to a full venv recreate even on healthy
// installs.
// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO
// runtime probe (bypassing the caller's --version check too), so a venv
// broken mid-update (e.g. missing python-dotenv) was re-selected forever:
// Retry / "Repair install" resolved the same dead interpreter instead of
// falling through to the bootstrap installer.
const test = require('node:test')
const assert = require('node:assert/strict')
@ -43,21 +48,13 @@ test('findOnPath tries PATHEXT extensions before the bare (empty) name on Window
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
const source = readMain()
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
assert.match(
source,
/fileExists\(venvPython\)/,
'recovery must accept the venv interpreter as a real-install signal'
)
assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal')
assert.match(
source,
/\.hermes-bootstrap-complete/,
'recovery must accept the bootstrap-complete marker as a real-install signal'
)
assert.match(
source,
/updaterArgs = haveRealInstall \? \['--update'/,
'updaterArgs must gate on haveRealInstall'
)
assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall')
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
assert.doesNotMatch(
source,
@ -65,3 +62,23 @@ test('Windows bootstrap recovery chooses --update when any real-install signal i
'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
)
})
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
const source = readMain()
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.cjs')
// Slice out just the function body (up to the next top-level function decl)
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
assert.match(
body,
/canImportHermesCli\(python/,
'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' +
'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)'
)
assert.match(
body,
/return null\s*\n\s*\}\s*\n\s*return \{/,
'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung'
)
})

View file

@ -0,0 +1,34 @@
/**
* Pure helpers for window zoom. The main process owns webContents.setZoomLevel,
* so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel
* through this one clamped scale. Percent is the user-facing unit (100 = the
* default size); Chromium's internal unit is the zoom level, where
* factor = 1.2 ^ level.
*/
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
const ZOOM_FACTOR_BASE = 1.2
const MIN_ZOOM_LEVEL = -9
const MAX_ZOOM_LEVEL = 9
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
}
function zoomLevelToPercent(level) {
return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100)
}
function percentToZoomLevel(percent) {
if (!Number.isFinite(percent) || percent <= 0) return 0
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
}
module.exports = {
ZOOM_STORAGE_KEY,
clampZoomLevel,
percentToZoomLevel,
zoomLevelToPercent
}

View file

@ -0,0 +1,54 @@
/**
* Unit tests for the pure zoom helpers: clamping garbage input, the
* percent <-> zoom-level conversion the settings UI relies on, and the
* roundtrip stability of the preset percentages.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')
test('storage key stays stable so persisted zoom survives upgrades', () => {
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
})
test('clampZoomLevel rejects garbage and enforces bounds', () => {
assert.equal(clampZoomLevel(NaN), 0)
assert.equal(clampZoomLevel(Infinity), 0)
assert.equal(clampZoomLevel(undefined), 0)
assert.equal(clampZoomLevel('2'), 0)
assert.equal(clampZoomLevel(0.3), 0.3)
assert.equal(clampZoomLevel(-42), -9)
assert.equal(clampZoomLevel(42), 9)
})
test('level 0 is exactly 100 percent', () => {
assert.equal(zoomLevelToPercent(0), 100)
assert.equal(percentToZoomLevel(100), 0)
})
test('percentToZoomLevel rejects garbage', () => {
assert.equal(percentToZoomLevel(NaN), 0)
assert.equal(percentToZoomLevel(0), 0)
assert.equal(percentToZoomLevel(-50), 0)
assert.equal(percentToZoomLevel(undefined), 0)
})
test('preset percentages roundtrip within rounding', () => {
for (const percent of [90, 100, 110, 125, 150, 175]) {
assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent)
}
})
test('conversion is monotonic across the preset range', () => {
const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel)
for (let i = 1; i < levels.length; i++) {
assert.ok(levels[i] > levels[i - 1])
}
})
test('extreme percentages clamp to the level bounds', () => {
assert.equal(percentToZoomLevel(1), -9)
assert.equal(percentToZoomLevel(1_000_000), 9)
})

View file

@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View file

@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@ -114,14 +115,11 @@ const fmtDuration = (seconds: number | undefined, a: Translations['agents']) =>
return a.durationMinutes(m, s)
}
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
if (!value) {
return ''
}
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
}
const fmtTokens = (value: number | undefined, a: Translations['agents']) =>
value ? a.tokens(compactNumber(value)) : ''
// Distinct contract from coarseElapsed: rounds to the second (this ticks live),
// and hours are unbounded ("25h", never "1d"). Kept local on purpose.
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
@ -135,11 +133,7 @@ const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) =>
const m = Math.floor(s / 60)
if (m < 60) {
return a.ageMinutes(m)
}
return a.ageHours(Math.floor(m / 60))
return m < 60 ? a.ageMinutes(m) : a.ageHours(Math.floor(m / 60))
}
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>

View file

@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Pagination,
@ -17,18 +16,19 @@ import {
PaginationPrevious
} from '@/components/ui/pagination'
import { RowButton } from '@/components/ui/row-button'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
import { normalize } from '@/lib/text'
import { fmtDayTime } from '@/lib/time'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@ -41,15 +41,8 @@ import {
collectArtifactsForSession
} from './artifact-utils'
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short'
})
function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
return fmtDayTime.format(new Date(timestamp))
}
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
@ -115,7 +108,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
@ -123,6 +115,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const [imagePage, setImagePage] = useState(1)
const [filePage, setFilePage] = useState(1)
const [refreshing, setRefreshing] = useState(false)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
@ -165,7 +159,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return []
}
const q = query.trim().toLowerCase()
const q = normalize(query)
return artifacts.filter(artifact => {
if (kindFilter !== 'all' && artifact.kind !== kindFilter) {
@ -209,6 +203,24 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
[currentFilePage, visibleFileArtifacts]
)
// Rotating placeholder nudges from real data — search matches file paths and
// session titles, not just labels; show it.
const searchHints = useMemo(() => {
if (!artifacts?.length) {
return undefined
}
const extensions = [
...new Set(artifacts.map(artifact => /\.(\w{2,4})$/.exec(artifact.value)?.[1]?.toLowerCase()).filter(Boolean))
].slice(0, 3) as string[]
const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2)
const hints = [...extensions.map(ext => t.common.tryHint(`.${ext}`)), ...titles.map(title => t.common.tryHint(title))]
return hints.length > 0 ? hints : undefined
}, [artifacts, t])
const counts = useMemo(() => {
const all = artifacts || []
@ -223,6 +235,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const openArtifact = useCallback(
async (href: string) => {
try {
// A gateway-local file resolves to file:// in remote mode (the file
// lives on the gateway, not this disk). Opening that locally fails —
// and an OAuth remote connection has no query token to build a download
// URL. Fetch the bytes over the authenticated fs bridge instead.
if (isRemoteGateway() && /^file:/i.test(href)) {
await downloadGatewayMediaFile(href)
return
}
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
@ -253,40 +275,33 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return (
<PageSearchShell
{...props}
activeTab={kindFilter}
onSearchChange={setQuery}
onTabChange={id => setKindFilter(id as typeof kindFilter)}
searchHidden={counts.all === 0}
searchHints={searchHints}
searchPlaceholder={a.search}
searchTrailingAction={
<Button
aria-label={refreshing ? a.refreshing : a.refresh}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? a.refreshing : a.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
<Tip label={refreshing ? a.refreshing : a.refresh}>
<Button
aria-label={refreshing ? a.refreshing : a.refresh}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-titlebar"
variant="ghost"
>
{refreshing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</Tip>
}
searchValue={query}
tabs={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
</TextTab>
</>
}
tabs={[
{ id: 'all', label: a.tabAll, meta: artifacts ? counts.all : null },
{ id: 'image', label: a.tabImages, meta: artifacts ? counts.image : null },
{ id: 'file', label: a.tabFiles, meta: artifacts ? counts.file : null },
{ id: 'link', label: a.tabLinks, meta: artifacts ? counts.link : null }
]}
>
{!artifacts ? (
<PageLoader label={a.indexing} />
@ -298,17 +313,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto [scrollbar-gutter:stable]">
<div className="flex flex-col gap-3 px-3 pb-2">
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={a.itemsImage}
@ -334,13 +343,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter, a)}

View file

@ -2,6 +2,7 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import { normalize } from '@/lib/text'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
@ -19,7 +20,7 @@ const STARTER_META: Record<string, string> = {
}
function starterEntries(query: string): CompletionEntry[] {
const q = query.trim().toLowerCase()
const q = normalize(query)
const kinds = Array.from(REF_STARTERS)
const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds

View file

@ -12,6 +12,7 @@ import {
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import { normalize } from '@/lib/text'
import { $sessions } from '@/store/session'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
@ -94,7 +95,7 @@ export function useSlashCompletions(options: {
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
if (sessionArg) {
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
const needle = normalize(sessionArg[1])
const matches = (
needle

View file

@ -1,12 +1,6 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import {
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
useEffect,
useRef
} from 'react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
@ -27,11 +21,7 @@ import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
import { AttachmentList } from './attachments'
import {
COMPOSER_FADE_BACKGROUND,
type QueueEditState,
slashArgStage
} from './composer-utils'
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'

View file

@ -7,16 +7,12 @@ import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { capitalize } from '@/lib/text'
import type { TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
import type { ComposerStatusItem } from '@/store/composer-status'
const toolLabel = (name: string) =>
name
.split('_')
.filter(Boolean)
.map(part => part[0]!.toUpperCase() + part.slice(1))
.join(' ') || name
const toolLabel = (name: string) => name.split('_').filter(Boolean).map(capitalize).join(' ') || name
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
// is still open (pending), codicons once it resolves, a live spinner only on

View file

@ -122,9 +122,9 @@ describe('extractDroppedFiles', () => {
}
it('emits a dropped directory as a path-only entry with isDirectory (no File to upload)', () => {
const transfer = stubTransfer([
{ path: '/Users/jeff/projects/hermes', isDirectory: true }
]) as DataTransfer & { _pathByFile: Map<File, string> }
const transfer = stubTransfer([{ path: '/Users/jeff/projects/hermes', isDirectory: true }]) as DataTransfer & {
_pathByFile: Map<File, string>
}
stubBridge(transfer)
@ -174,9 +174,9 @@ describe('extractDroppedFiles', () => {
it('does not duplicate a folder that appears in both items and files', () => {
// Chromium lists a dropped folder in transfer.files too (as a size-0 File);
// the items pass claims its path first so the files fallback skips it.
const transfer = stubTransfer([
{ path: '/abs/project', isDirectory: true }
]) as DataTransfer & { _pathByFile: Map<File, string> }
const transfer = stubTransfer([{ path: '/abs/project', isDirectory: true }]) as DataTransfer & {
_pathByFile: Map<File, string>
}
stubBridge(transfer)

View file

@ -6,6 +6,7 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { useI18n } from '@/i18n'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs'
import { normalize } from '@/lib/text'
import {
addComposerAttachment,
type ComposerAttachment,
@ -30,9 +31,9 @@ const BLOB_MIME_EXTENSION: Record<string, string> = {
}
function blobExtension(blob: Blob): string {
const mime = blob.type.split(';')[0]?.trim().toLowerCase()
const mime = normalize(blob.type.split(';')[0])
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
return BLOB_MIME_EXTENSION[mime] || '.png'
}
export function isImagePath(filePath: string): boolean {

View file

@ -358,7 +358,10 @@ export function ChatView({
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
return gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
explicit_only: true
})
},
enabled: gatewayOpen
})

View file

@ -14,6 +14,7 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
import { RichCodeBlock } from '@/components/assistant-ui/embeds'
import { CodeEditor } from '@/components/chat/code-editor'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
@ -291,7 +292,9 @@ function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>)
)
}
return (
const code = String(children).replace(/\n$/, '')
const highlighted = (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
@ -301,9 +304,13 @@ function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>)
showLanguage={false}
theme={SHIKI_THEME}
>
{String(children).replace(/\n$/, '')}
{code}
</ShikiHighlighter>
)
// ```mermaid / ```svg fences route to the shared lazy renderers (same
// registry the chat transcript uses); everything else stays on Shiki.
return <RichCodeBlock code={code} fallback={highlighted} language={language} />
}
const MARKDOWN_COMPONENTS = {

View file

@ -8,6 +8,7 @@ import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { fmtDayTime, relativeTime } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $selectedStoredSessionId } from '@/store/session'
import type { CronJob } from '@/types/hermes'
@ -32,30 +33,6 @@ const PEEK_POLL_INTERVAL_MS = 8000
const INITIAL_VISIBLE_JOBS = 3
const LOAD_MORE_STEP = 10
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min".
function relativeTime(targetMs: number, nowMs: number): string {
const diff = targetMs - nowMs
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
if (abs < 60_000) {
return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
}
if (abs < 3_600_000) {
return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
}
if (abs < 86_400_000) {
return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
}
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
}
function nextRunMs(job: CronJob): null | number {
if (!job.next_run_at) {
return null
@ -76,9 +53,7 @@ function formatRunTime(seconds?: null | number): string {
const date = new Date(seconds * 1000)
return Number.isNaN(date.valueOf())
? '—'
: date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' })
return Number.isNaN(date.valueOf()) ? '—' : fmtDayTime.format(date)
}
interface SidebarCronJobsSectionProps {

View file

@ -1132,7 +1132,7 @@ export function ChatSidebar({
searchPending ? (
<SidebarSessionSkeletons />
) : (
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
<div className="wrap-anywhere grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
)

View file

@ -22,17 +22,21 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { CodeEditor } from '@/components/chat/code-editor'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getProfileSoul, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import {
$activeGatewayProfile,
$profileColors,
@ -106,6 +110,7 @@ export function ProfileRail() {
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [pendingSoul, setPendingSoul] = useState<null | string>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// Too many profiles for the square strip → collapse to the select. Declared
@ -277,6 +282,7 @@ export function ProfileRail() {
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onEditSoul={() => setPendingSoul(profile.name)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
@ -322,10 +328,89 @@ export function ProfileRail() {
open={pendingDelete !== null}
profile={pendingDelete}
/>
<EditSoulDialog onClose={() => setPendingSoul(null)} profileName={pendingSoul} />
</div>
)
}
// Right-click → Edit SOUL.md for a sidebar profile — the same in-app markdown
// editor as the memory-graph node edit, so a profile's persona is editable
// without opening the Manage overlay.
function EditSoulDialog({ onClose, profileName }: { onClose: () => void; profileName: null | string }) {
const { t } = useI18n()
const p = t.profiles
const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!profileName) {
return
}
let cancelled = false
setLoading(true)
setContent('')
getProfileSoul(profileName)
.then(soul => !cancelled && setContent(soul.content))
.catch(err => !cancelled && notifyError(err, p.failedLoadSoul))
.finally(() => !cancelled && setLoading(false))
return () => void (cancelled = true)
}, [p, profileName])
const save = async () => {
if (!profileName) {
return
}
setSaving(true)
try {
await updateProfileSoul(profileName, content)
notify({ kind: 'success', title: p.soulSaved, message: profileName })
onClose()
} catch (err) {
notifyError(err, p.failedSaveSoul)
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={open => !open && !saving && onClose()} open={profileName !== null}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{profileName} · SOUL.md</DialogTitle>
</DialogHeader>
<div className="h-80">
{!loading && profileName && (
<CodeEditor
filePath="SOUL.md"
framed
initialValue={content}
key={profileName}
onCancel={() => !saving && onClose()}
onChange={setContent}
onSave={() => void save()}
/>
)}
</div>
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={saving || loading} onClick={() => void save()}>
{saving ? p.saving : p.saveSoul}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// The "+" create button, shared by both rail render paths.
function AddProfileButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
@ -427,6 +512,7 @@ interface ProfileSquareProps {
onSelect: () => void
onRecolor: (color: null | string) => void
onRename: () => void
onEditSoul: () => void
onDelete: () => void
}
@ -441,7 +527,16 @@ const LONG_PRESS_MS = 450
// right-click to rename/delete. The button carries both the tooltip and
// context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
function ProfileSquare({
active,
color,
label,
onDelete,
onEditSoul,
onRecolor,
onRename,
onSelect
}: ProfileSquareProps) {
const { t } = useI18n()
const p = t.profiles
const hue = color ?? 'var(--ui-text-quaternary)'
@ -565,8 +660,12 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
<span>{p.color}</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onRename}>
<Codicon name="text-size" size="0.875rem" />
<span>{p.renameMenu}</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onEditSoul}>
<Codicon name="edit" size="0.875rem" />
<span>{p.rename}</span>
<span>{p.editSoul}</span>
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"

View file

@ -149,10 +149,7 @@ export function ProjectDialog() {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="max-w-md"
onInteractOutside={event => event.preventDefault()}
>
<DialogContent className="max-w-md" onInteractOutside={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}

View file

@ -1,5 +1,6 @@
import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/hermes'
import { normalize } from '@/lib/text'
// Session grouping is now computed authoritatively on the backend
// (`tui_gateway/project_tree.py`, exposed via `projects.tree` /
@ -191,7 +192,7 @@ export function mergeRepoWorktreeGroups(
return branchForPath !== group.label ? { ...group, label: branchForPath } : group
}
const livePath = livePathByBranch.get(group.label.trim().toLowerCase())
const livePath = livePathByBranch.get(normalize(group.label))
if (livePath && normalizePath(livePath) !== normalizePath(group.path)) {
return { ...group, id: livePath, path: livePath }

Some files were not shown because too many files have changed in this diff Show more