mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
commit
1508ece2d9
948 changed files with 85189 additions and 7133 deletions
2
.envrc
2
.envrc
|
|
@ -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
|
||||
|
||||
|
|
|
|||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
15
.github/workflows/docker.yml
vendored
15
.github/workflows/docker.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ __pycache__/
|
|||
.venv
|
||||
.vscode/
|
||||
.env
|
||||
.op.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.11–3.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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.11–3.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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 from→to 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
148
agent/bounded_response.py
Normal 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
|
||||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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  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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
213
agent/secret_sources/_cache.py
Normal file
213
agent/secret_sources/_cache.py
Normal 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
|
||||
274
agent/secret_sources/base.py
Normal file
274
agent/secret_sources/base.py
Normal 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
|
||||
name→value 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
643
agent/secret_sources/onepassword.py
Normal file
643
agent/secret_sources/onepassword.py
Normal 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)
|
||||
370
agent/secret_sources/registry.py
Normal file
370
agent/secret_sources/registry.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
398
agent/trace_upload.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -47,5 +47,5 @@ function sourceDeclaresServe(dashboardPySource) {
|
|||
module.exports = {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe,
|
||||
sourceDeclaresServe
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
apps/desktop/electron/oauth-session-request.test.cjs
Normal file
30
apps/desktop/electron/oauth-session-request.test.cjs
Normal 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\)/)
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
|
|
|
|||
34
apps/desktop/electron/zoom.cjs
Normal file
34
apps/desktop/electron/zoom.cjs
Normal 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
|
||||
}
|
||||
54
apps/desktop/electron/zoom.test.cjs
Normal file
54
apps/desktop/electron/zoom.test.cjs
Normal 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)
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[] =>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue