diff --git a/.env.example b/.env.example
index 3cf4ee539a..0bed6a4d57 100644
--- a/.env.example
+++ b/.env.example
@@ -105,6 +105,7 @@
# Get your token at: https://huggingface.co/settings/tokens
# Required permission: "Make calls to Inference Providers"
# HF_TOKEN=
+# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
# =============================================================================
@@ -411,6 +412,9 @@ IMAGE_TOOLS_DEBUG=false
# Groq API key (free tier — used for Whisper STT in voice mode)
# GROQ_API_KEY=
+# ElevenLabs API key (cloud STT/TTS — Scribe transcription)
+# ELEVENLABS_API_KEY=
+
# =============================================================================
# STT PROVIDER SELECTION
# =============================================================================
diff --git a/.envrc b/.envrc
index f746973cae..01232045f1 100644
--- a/.envrc
+++ b/.envrc
@@ -1,5 +1,5 @@
watch_file pyproject.toml uv.lock
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
+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
use flake
diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml
new file mode 100644
index 0000000000..268b0aa103
--- /dev/null
+++ b/.github/actions/detect-changes/action.yml
@@ -0,0 +1,62 @@
+name: Detect affected areas
+description: >-
+ Classify a PR's changed files into CI work lanes (python, frontend, site,
+ scan, deps, mcp_catalog) so the orchestrator can conditionally call only
+ the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
+ events and fail open (everything "true") when the diff cannot be computed.
+
+outputs:
+ python:
+ description: Run Python tests / ruff / ty / windows-footguns.
+ value: ${{ steps.classify.outputs.python }}
+ frontend:
+ description: Run the TypeScript typecheck matrix + desktop build.
+ value: ${{ steps.classify.outputs.frontend }}
+ docker_meta:
+ description: Docker setup and meta files have changed.
+ value: ${{ steps.classify.outputs.docker_meta }}
+ site:
+ description: Build the Docusaurus docs site.
+ value: ${{ steps.classify.outputs.site }}
+ scan:
+ description: Run the supply-chain critical-pattern scanner.
+ value: ${{ steps.classify.outputs.scan }}
+ deps:
+ description: Check pyproject.toml dependency upper bounds.
+ value: ${{ steps.classify.outputs.deps }}
+ mcp_catalog:
+ description: Require MCP catalog security review label.
+ value: ${{ steps.classify.outputs.mcp_catalog }}
+
+runs:
+ using: composite
+ steps:
+ - name: Classify changed files
+ id: classify
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ EVENT_NAME: ${{ github.event_name }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ set -euo pipefail
+
+ # Only pull_request events are gated. Other events (push, release,
+ # dispatch) leave CHANGED empty, so the classifier fails open and every
+ # lane runs. Post-merge / on-demand validation is never weakened.
+ if [ "$EVENT_NAME" = "pull_request" ]; then
+ # Use the compare endpoint with the pinned base/head SHAs from the
+ # event payload instead of the "current PR files" endpoint. The SHAs
+ # are frozen at trigger time, so the file list is deterministic even
+ # if the PR receives a new push between trigger and detect.
+ CHANGED="$(gh api \
+ --paginate \
+ "repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
+ --jq '.files[].filename' || true)"
+ fi
+
+ echo "Changed files:"
+ printf '%s\n' "${CHANGED:-(none)}"
+ printf '%s\n' "${CHANGED:-}" | python3 scripts/ci/classify_changes.py
diff --git a/.github/actions/hermes-smoke-test/action.yml b/.github/actions/hermes-smoke-test/action.yml
deleted file mode 100644
index 8b79c4bf34..0000000000
--- a/.github/actions/hermes-smoke-test/action.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-name: Hermes smoke test
-description: >
- Run the image's built-in entrypoint against `--help` and `dashboard --help`
- to catch basic runtime regressions before publishing. Requires the image
- to already be loaded into the local Docker daemon under `image`.
-
- Works identically on amd64 and arm64 runners.
-
-inputs:
- image:
- description: Fully-qualified image tag (e.g. nousresearch/hermes-agent:test)
- required: true
-
-runs:
- using: composite
- steps:
- - name: Ensure /tmp/hermes-test is hermes-writable
- shell: bash
- run: |
- # The image runs as the hermes user (UID 10000). GitHub Actions
- # creates /tmp/hermes-test root-owned by default, which hermes
- # can't write to — chown it to match the in-container UID before
- # bind-mounting. Real users doing `docker run -v ~/.hermes:...`
- # with their own UID hit the same issue and have their own
- # remediations (HERMES_UID env var, or chown locally).
- mkdir -p /tmp/hermes-test
- sudo chown -R 10000:10000 /tmp/hermes-test
-
- - name: hermes --help
- shell: bash
- run: |
- # Use the image's real ENTRYPOINT (/init + main-wrapper.sh) so
- # this exercises the actual production startup path. PR #30136
- # review caught that an --entrypoint override here had been
- # silently neutered by the s6-overlay migration — stage2-hook
- # ignores its CMD args, so the smoke test was a no-op.
- docker run --rm \
- -v /tmp/hermes-test:/opt/data \
- "${{ inputs.image }}" --help
-
- - name: hermes dashboard --help
- shell: bash
- run: |
- # Regression guard for #9153: dashboard was present in source but
- # missing from the published image. If this fails, something in
- # the Dockerfile is excluding the dashboard subcommand from the
- # installed package.
- docker run --rm \
- -v /tmp/hermes-test:/opt/data \
- "${{ inputs.image }}" dashboard --help
diff --git a/.github/actions/retry/action.yml b/.github/actions/retry/action.yml
new file mode 100644
index 0000000000..0eba2866eb
--- /dev/null
+++ b/.github/actions/retry/action.yml
@@ -0,0 +1,50 @@
+name: Retry a flaky command
+description: >-
+ Run a shell command, retrying on non-zero exit. For dependency installs
+ (npm ci, uv sync) whose only failures are transient network/toolchain
+ flakes — a node-gyp header fetch, a registry blip — so CI self-heals
+ instead of needing a manual re-run.
+
+inputs:
+ command:
+ description: Shell command to run (and retry).
+ required: true
+ attempts:
+ description: Max attempts before giving up.
+ default: "3"
+ delay:
+ description: Seconds to wait between attempts.
+ default: "10"
+ working-directory:
+ description: Directory to run in.
+ default: "."
+
+runs:
+ using: composite
+ steps:
+ - shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ # command goes through env, never interpolated into the script body, so
+ # a command with quotes/specials can't break or inject into the runner.
+ env:
+ _CMD: ${{ inputs.command }}
+ _ATTEMPTS: ${{ inputs.attempts }}
+ _DELAY: ${{ inputs.delay }}
+ run: |
+ set -uo pipefail
+ n=0
+ while :; do
+ n=$((n + 1))
+ echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
+ if bash -c "$_CMD"; then
+ echo "::endgroup::"
+ exit 0
+ fi
+ echo "::endgroup::"
+ if [ "$n" -ge "$_ATTEMPTS" ]; then
+ echo "::error::failed after $n attempts: $_CMD"
+ exit 1
+ fi
+ echo "::warning::attempt $n failed; retrying in ${_DELAY}s: $_CMD"
+ sleep "$_DELAY"
+ done
diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml
deleted file mode 100644
index 3fc4f2b074..0000000000
--- a/.github/workflows/build-windows-installer.yml
+++ /dev/null
@@ -1,100 +0,0 @@
-name: Build Windows Installer
-
-on:
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- # Gate: workflow_dispatch is already restricted to users with write access,
- # but we want ADMIN-only. Explicitly check the triggering actor's repo
- # permission via the API and fail fast for anyone below admin.
- authorize:
- name: Authorize (admins only)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - name: Check actor is a repo admin
- env:
- GH_TOKEN: ${{ github.token }}
- ACTOR: ${{ github.actor }}
- run: |
- set -euo pipefail
- perm=$(gh api \
- "repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
- --jq '.permission')
- echo "Actor '${ACTOR}' has permission: ${perm}"
- if [ "${perm}" != "admin" ]; then
- echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
- exit 1
- fi
- echo "Authorized: '${ACTOR}' is an admin."
-
- build:
- name: Hermes-Setup.exe
- needs: authorize
- runs-on: windows-latest
- timeout-minutes: 30
- permissions:
- contents: read
- # Required for OIDC auth to Azure (azure/login federated credentials).
- id-token: write
-
- steps:
- - name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Setup Node.js
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- with:
- node-version: 22
- cache: npm
-
- - name: Install npm dependencies
- run: npm ci
-
- - name: Setup Rust
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
-
- - name: Cache Rust targets
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- with:
- workspaces: apps/bootstrap-installer/src-tauri
-
- - name: Build installer
- run: npm run tauri:build
- working-directory: apps/bootstrap-installer
-
- - name: Azure login (OIDC)
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
- with:
- client-id: ${{ secrets.AZURE_CLIENT_ID }}
- tenant-id: ${{ secrets.AZURE_TENANT_ID }}
- subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
-
- - name: Sign Hermes-Setup.exe with Azure Artifact Signing
- uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
- with:
- endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
- signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
- certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
- # Sign both the raw exe and the bundled NSIS installer.
- files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
- files-folder-filter: exe
- files-folder-recurse: true
- file-digest: SHA256
- timestamp-rfc3161: http://timestamp.acs.microsoft.com
- timestamp-digest: SHA256
-
- - name: Upload NSIS installer
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: Hermes-Setup-installer
- path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
-
- - name: Upload raw exe
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: Hermes-Setup-exe
- path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000..c02a436efb
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,167 @@
+name: CI
+
+# Orchestrator workflow. Runs ``detect-changes`` once, then conditionally
+# calls the sub-workflows that a PR can actually affect. A final
+# ``all-checks-pass`` gate job aggregates results so branch protection only
+# needs to require a single check.
+#
+# Sub-workflows are triggered via ``workflow_call`` and keep their own job
+# definitions, matrices, and concurrency settings. They no longer have
+# ``push:`` / ``pull_request:`` triggers of their own — everything flows
+# through this file.
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+ pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
+ actions: read # needed by osv-scanner (SARIF upload)
+ security-events: write # needed by osv-scanner (SARIF upload)
+ packages: write # needed by docker build
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ # ─────────────────────────────────────────────────────────────────────
+ # detect: run the classifier once. Every downstream job reads its outputs
+ # to decide whether to run. On push/dispatch the classifier fails open
+ # (all lanes true) so post-merge validation is never weakened.
+ # ─────────────────────────────────────────────────────────────────────
+ detect:
+ name: Detect affected areas
+ runs-on: ubuntu-latest
+ outputs:
+ python: ${{ steps.classify.outputs.python }}
+ frontend: ${{ steps.classify.outputs.frontend }}
+ site: ${{ steps.classify.outputs.site }}
+ scan: ${{ steps.classify.outputs.scan }}
+ deps: ${{ steps.classify.outputs.deps }}
+ docker_meta: ${{ steps.classify.outputs.docker_meta }}
+ mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
+ event_name: ${{ github.event_name }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Detect affected areas
+ id: classify
+ uses: ./.github/actions/detect-changes
+
+ # ─────────────────────────────────────────────────────────────────────
+ # Lane-gated sub-workflows. Each runs in parallel after detect finishes.
+ # Skipped workflows (if condition is false) don't spin up runners.
+ # ─────────────────────────────────────────────────────────────────────
+ tests:
+ name: Python tests
+ needs: detect
+ if: needs.detect.outputs.python == 'true'
+ uses: ./.github/workflows/tests.yml
+ with:
+ slice_count: 8
+
+ lint:
+ name: Python lints
+ needs: detect
+ if: needs.detect.outputs.python == 'true'
+ uses: ./.github/workflows/lint.yml
+ with:
+ event_name: ${{ needs.detect.outputs.event_name }}
+
+ typecheck:
+ name: TypeScript
+ needs: detect
+ if: needs.detect.outputs.frontend == 'true'
+ uses: ./.github/workflows/typecheck.yml
+
+ docs-site:
+ name: Docs Site
+ needs: detect
+ if: needs.detect.outputs.site == 'true'
+ uses: ./.github/workflows/docs-site-checks.yml
+
+ history-check:
+ name: Deny unrelated histories
+ needs: detect
+ if: needs.detect.outputs.event_name == 'pull_request'
+ uses: ./.github/workflows/history-check.yml
+
+ contributor-check:
+ name: Check contributors
+ needs: detect
+ if: needs.detect.outputs.python == 'true'
+ uses: ./.github/workflows/contributor-check.yml
+
+ uv-lockfile:
+ name: Check uv.lock
+ needs: detect
+ uses: ./.github/workflows/uv-lockfile-check.yml
+
+ docker-lint:
+ name: Lint Docker scripts
+ needs: detect
+ if: needs.detect.outputs.docker_meta == 'true'
+ uses: ./.github/workflows/docker-lint.yml
+
+ docker:
+ name: Build&Test Docker image
+ needs: detect
+ if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
+ uses: ./.github/workflows/docker.yml
+ secrets: inherit
+
+ supply-chain:
+ name: Supply-chain scan
+ needs: detect
+ if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
+ uses: ./.github/workflows/supply-chain-audit.yml
+ with:
+ event_name: ${{ needs.detect.outputs.event_name }}
+ scan: ${{ needs.detect.outputs.scan == 'true' }}
+ deps: ${{ needs.detect.outputs.deps == 'true' }}
+ mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
+
+ osv-scanner:
+ name: OSV scan
+ uses: ./.github/workflows/osv-scanner.yml
+
+ # ─────────────────────────────────────────────────────────────────────
+ # Gate: runs after everything. ``if: always()`` ensures it reports a
+ # status even when some deps were skipped. Only actual ``failure``
+ # results cause it to fail; ``skipped`` is treated as success.
+ #
+ # Branch protection should require ONLY this check.
+ # ─────────────────────────────────────────────────────────────────────
+ all-checks-pass:
+ name: All required checks pass
+ needs:
+ - tests
+ - lint
+ - typecheck
+ - docs-site
+ - history-check
+ - contributor-check
+ - uv-lockfile
+ - docker-lint
+ - supply-chain
+ - osv-scanner
+ # we don't require docker to pass rn because it's so slow lol
+ # - docker
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Evaluate job results
+ env:
+ RESULTS: ${{ toJSON(needs.*.result) }}
+ run: |
+ echo "$RESULTS" | python3 -c "
+ import json, sys
+ results = json.load(sys.stdin)
+ failed = [r for r in results if r == 'failure']
+ if failed:
+ print(f'::error::{len(failed)} job(s) failed')
+ sys.exit(1)
+ print('All checks passed (or were skipped)')
+ "
diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml
index 23266931a6..b7c3db7f82 100644
--- a/.github/workflows/contributor-check.yml
+++ b/.github/workflows/contributor-check.yml
@@ -1,11 +1,8 @@
name: Contributor Attribution Check
on:
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
+ workflow_call:
+
permissions:
contents: read
@@ -17,21 +14,7 @@ jobs:
with:
fetch-depth: 0 # Full history needed for git log
- - name: Check if relevant files changed
- id: filter
- run: |
- BASE="${{ github.event.pull_request.base.sha }}"
- HEAD="${{ github.event.pull_request.head.sha }}"
- CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
- if [ -n "$CHANGED" ]; then
- echo "run=true" >> "$GITHUB_OUTPUT"
- else
- echo "run=false" >> "$GITHUB_OUTPUT"
- echo "No Python files changed, skipping attribution check."
- fi
-
- name: Check for unmapped contributor emails
- if: steps.filter.outputs.run == 'true'
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml
index 631add200a..89b80fa10e 100644
--- a/.github/workflows/docker-lint.yml
+++ b/.github/workflows/docker-lint.yml
@@ -2,7 +2,7 @@ name: Docker / shell lint
# Lints the container build inputs: Dockerfile (via hadolint) and any shell
# scripts under docker/ (via shellcheck). These catch the class of regression
-# the behavioral docker-publish smoke test can't — unquoted variable
+# the behavioral docker smoke test can't — unquoted variable
# expansions, silently-failing RUN commands, etc.
#
# Rules and ignores are documented in .hadolint.yaml at the repo root.
@@ -11,19 +11,7 @@ name: Docker / shell lint
# activate script doesn't exist at lint time.
on:
- push:
- branches: [main]
- paths:
- - Dockerfile
- - docker/**
- - .hadolint.yaml
- - .github/workflows/docker-lint.yml
-
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
+ workflow_call:
permissions:
contents: read
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker.yml
similarity index 71%
rename from .github/workflows/docker-publish.yml
rename to .github/workflows/docker.yml
index 09b8913841..13b86722b8 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker.yml
@@ -1,25 +1,9 @@
-name: Docker Build and Publish
+name: Docker Build, Test, and Publish
on:
- push:
- branches: [main]
- paths:
- - '**/*.py'
- - 'pyproject.toml'
- - 'uv.lock'
- - 'Dockerfile'
- - 'docker/**'
- - '.github/workflows/docker-publish.yml'
- - '.github/actions/hermes-smoke-test/**'
-
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
-
release:
types: [published]
+ workflow_call:
permissions:
contents: read
@@ -40,11 +24,7 @@ env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
- # ---------------------------------------------------------------------------
- # Build amd64 natively. This job also runs the smoke tests (basic --help
- # and the dashboard subcommand regression guard from #9153), because amd64
- # is the only arch we can `load` into the local daemon on an amd64 runner.
- # ---------------------------------------------------------------------------
+ # Build, test, and optionally push the amd64 image.
build-amd64:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
@@ -54,16 +34,19 @@ jobs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ # The image build + integration tests run on every event
+ # (PRs, push-to-main, release). Publish steps below are gated to
+ # push-to-main / release only.
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- # Build once, load into the local daemon for smoke testing. Cached
+ # Build once, load into the local daemon for testing. Cached
# to gha with a per-arch scope; the push step below reuses every
# layer from this build.
- - name: Build image (amd64, smoke test)
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ - name: Build image (amd64)
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -75,24 +58,12 @@ jobs:
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
- - name: Smoke test image
- uses: ./.github/actions/hermes-smoke-test
- with:
- image: ${{ env.IMAGE_NAME }}:test
-
- # ---------------------------------------------------------------------
# Run the docker-integration test suite against the freshly-built
- # image already loaded into the local daemon (`:test`). These tests
- # are excluded from the sharded `tests.yml :: test` matrix on purpose
- # (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
- # shard would otherwise reach the session-scoped ``built_image``
- # fixture in ``tests/docker/conftest.py`` and start a 3-7min
- # ``docker build`` — guaranteed to
- # die in fixture setup.
+ # image already loaded into the local daemon (`:test`).
#
- # Piggybacking here avoids a second image build: the smoke test
- # already proved the image loads + runs, so the daemon has it under
- # `${IMAGE_NAME}:test` and we just point ``HERMES_TEST_IMAGE`` at
+ # Piggybacking here avoids a second image build: the build step
+ # already loaded the image into the daemon under
+ # `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
#
@@ -102,20 +73,18 @@ jobs:
# cheapest path to coverage on every PR that touches docker code.
# ---------------------------------------------------------------------
- name: Install uv (for docker tests)
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Python 3.11 (for docker tests)
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
run: |
- uv venv .venv --python 3.11
- source .venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
- uv pip install -e ".[dev]"
+ uv sync --locked --python 3.11 --extra dev
- name: Run docker integration tests
env:
@@ -127,12 +96,11 @@ jobs:
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
- source .venv/bin/activate
- python -m pytest tests/docker/ -v --tb=short
+ scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -143,7 +111,7 @@ jobs:
- name: Push amd64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -167,7 +135,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-amd64
path: /tmp/digests/*
@@ -175,10 +143,7 @@ jobs:
retention-days: 1
# ---------------------------------------------------------------------------
- # Build arm64 natively on GitHub's free arm64 runner. This replaces the
- # previous QEMU-emulated arm64 build, which was ~5-10x slower and shared
- # a cache scope with amd64. Matches the amd64 job's shape: build+load,
- # smoke test, then on push/release push by digest.
+ # Build, test, and optionally push the arm64 image.
# ---------------------------------------------------------------------------
build-arm64:
if: github.repository == 'NousResearch/hermes-agent'
@@ -188,57 +153,35 @@ jobs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
# the whole job — unlike the gha cache backend's short-lived Azure SAS
# token, which expired mid-build on slow cold-cache arm64 runs and
- # crashed the build before the smoke test (the reason the gha cache
+ # crashed the build before the tests ran (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- # Build once, load into the local daemon for smoke testing.
- #
- # PR builds use the registry-backed cache READ-ONLY (cache-from only):
- # they pull warm layers pushed by the most recent main build but never
- # write, so rapid PR pushes don't race on cache writes or pollute the
- # cache ref. This restores warm-cache speed to arm64 PR builds (which
- # were running fully uncached and were ~45% slower than amd64, making
- # them the job most often cancelled on supersede).
+ # Build once, load into the local daemon for testing, then push
+ # by digest below. Reads AND writes the registry-backed cache so the
+ # push reuses layers from this build and the next build starts warm.
#
# Registry cache (type=registry on ghcr.io) is used instead of the gha
# cache that previously broke here: its credential is the job-lifetime
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
# token failure mode cannot recur.
- - name: Build image (arm64, smoke test, cache read-only PR)
- if: github.event_name == 'pull_request'
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- with:
- context: .
- file: Dockerfile
- load: true
- platforms: linux/arm64
- tags: ${{ env.IMAGE_NAME }}:test
- build-args: |
- HERMES_GIT_SHA=${{ github.sha }}
- cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
-
- # Main/release builds read AND write the registry cache so the digest
- # push below reuses layers from this smoke-test build, and so the next
- # PR/main build starts warm.
- - name: Build image (arm64, smoke test, cached publish)
- if: github.event_name != 'pull_request'
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ - name: Build image (arm64, cached publish)
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -250,14 +193,29 @@ jobs:
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- - name: Smoke test image
- uses: ./.github/actions/hermes-smoke-test
- with:
- image: ${{ env.IMAGE_NAME }}:test
+ - name: Install uv for docker tests
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
+
+ - name: Set up Python 3.11 for docker tests
+ run: uv python install 3.11
+
+ - name: Install Python dependencies for docker tests
+ run: |
+ uv sync --locked --python 3.11 --extra dev
+
+ - name: Run docker tests
+ env:
+ # Skip rebuild; use the image already loaded by the build step.
+ HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
+ OPENROUTER_API_KEY: ""
+ OPENAI_API_KEY: ""
+ NOUS_API_KEY: ""
+ run: |
+ scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -265,7 +223,7 @@ jobs:
- name: Push arm64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -287,7 +245,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-arm64
path: /tmp/digests/*
@@ -309,17 +267,17 @@ jobs:
timeout-minutes: 10
steps:
- name: Download digests
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml
index 975028afe2..705f2171e5 100644
--- a/.github/workflows/docs-site-checks.yml
+++ b/.github/workflows/docs-site-checks.yml
@@ -1,13 +1,7 @@
name: Docs Site Checks
on:
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
-
- workflow_dispatch:
+ workflow_call:
permissions:
contents: read
@@ -25,15 +19,19 @@ jobs:
cache-dependency-path: website/package-lock.json
- name: Install website dependencies
- run: npm ci
- working-directory: website
+ uses: ./.github/actions/retry
+ with:
+ command: npm ci
+ working-directory: website
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install ascii-guard
- run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
+ uses: ./.github/actions/retry
+ with:
+ command: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml
index ef657d5982..07e4fa348e 100644
--- a/.github/workflows/history-check.yml
+++ b/.github/workflows/history-check.yml
@@ -14,11 +14,7 @@ name: History Check
# the PR head and main to be non-empty.
on:
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
+ workflow_call:
permissions:
contents: read
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index f2765823a0..511119ca61 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -9,18 +9,12 @@ name: Lint (ruff + ty)
# enforcement fails.
on:
- push:
- branches: [main]
- paths-ignore:
- - "**/*.md"
- - "docs/**"
- - "website/**"
-
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
+ workflow_call:
+ inputs:
+ event_name:
+ description: The event name from the calling orchestrator (pull_request or push).
+ type: string
+ required: true
permissions:
contents: read
@@ -33,6 +27,7 @@ concurrency:
jobs:
lint-diff:
name: ruff + ty diff
+ if: inputs.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -42,19 +37,19 @@ jobs:
fetch-depth: 0 # need full history for merge-base + worktree
- name: Install uv
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Install ruff + ty
- run: |
- uv tool install ruff
- uv tool install ty
+ uses: ./.github/actions/retry
+ with:
+ command: uv tool install ruff && uv tool install ty
- name: Determine base ref
id: base
run: |
# For PRs, diff against the merge base with the target branch.
# For pushes to main, diff against the previous commit on main.
- if [ "${{ github.event_name }}" = "pull_request" ]; then
+ if [ "${{ inputs.event_name }}" = "pull_request" ]; then
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
BASE_REF="origin/${{ github.base_ref }}"
else
@@ -110,19 +105,19 @@ jobs:
--base-ty .lint-reports/base/ty.json \
--head-ty .lint-reports/head/ty.json \
--base-ref "${{ steps.base.outputs.ref }}" \
- --head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
+ --head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
--output .lint-reports/summary.md
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload reports as artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lint-reports
path: .lint-reports/
retention-days: 14
- name: Post / update PR comment
- if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
+ if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
@@ -169,10 +164,12 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Install ruff
- run: uv tool install ruff
+ uses: ./.github/actions/retry
+ with:
+ command: uv tool install ruff
- name: ruff check .
# No --exit-zero, no || true. Exit code propagates to the job,
diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml
index d1b318cc73..48b485c55f 100644
--- a/.github/workflows/osv-scanner.yml
+++ b/.github/workflows/osv-scanner.yml
@@ -1,8 +1,8 @@
name: OSV-Scanner
# Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability
-# database. Runs on every PR that touches a lockfile and on a weekly schedule
-# against main.
+# database. Runs on every PR/push (via the ci.yml orchestrator's workflow_call)
+# and on a weekly schedule against main.
#
# This is detection-only — OSV-Scanner does NOT open PRs or modify pins.
# It reports known CVEs in currently-pinned dependency versions so we can
@@ -10,9 +10,9 @@ name: OSV-Scanner
# (full SHA / exact version) is preserved; only the notification signal
# is added.
#
-# Complements the existing supply-chain-audit.yml workflow (which scans
-# for malicious code patterns in PR diffs) by covering the orthogonal
-# "currently-pinned dep became known-vulnerable" case.
+# Complements the supply-chain-audit.yml workflow (which scans for malicious
+# code patterns in PR diffs) by covering the orthogonal "currently-pinned
+# dep became known-vulnerable" case.
#
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
@@ -20,19 +20,7 @@ name: OSV-Scanner
# vulnerabilities in pinned deps that we may need to patch deliberately.
on:
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
- push:
- branches: [main]
- paths:
- - "uv.lock"
- - "pyproject.toml"
- - "package.json"
- - "package-lock.json"
- - "website/package-lock.json"
+ workflow_call:
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.
diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml
index c6caf09813..1997dedf5c 100644
--- a/.github/workflows/skills-index.yml
+++ b/.github/workflows/skills-index.yml
@@ -3,17 +3,17 @@ name: Build Skills Index
on:
schedule:
# Run twice daily: 6 AM and 6 PM UTC
- - cron: '0 6,18 * * *'
- workflow_dispatch: # Manual trigger
+ - cron: "0 6,18 * * *"
+ workflow_dispatch: # Manual trigger
push:
branches: [main]
paths:
- - 'scripts/build_skills_index.py'
- - '.github/workflows/skills-index.yml'
+ - "scripts/build_skills_index.py"
+ - ".github/workflows/skills-index.yml"
permissions:
contents: read
- actions: write # to trigger deploy-site.yml on schedule
+ actions: write # to trigger deploy-site.yml on schedule
jobs:
build-index:
@@ -21,11 +21,11 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
- python-version: '3.11'
+ python-version: "3.11"
- name: Install dependencies
run: pip install httpx==0.28.1 pyyaml==6.0.2
@@ -36,7 +36,7 @@ jobs:
run: python scripts/build_skills_index.py
- name: Upload index artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: skills-index
path: website/static/api/skills-index.json
diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml
index f3405b7660..201e92d174 100644
--- a/.github/workflows/supply-chain-audit.yml
+++ b/.github/workflows/supply-chain-audit.yml
@@ -1,16 +1,5 @@
name: Supply Chain Audit
-on:
- # No paths filter — the jobs must always run so required checks
- # report a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- types: [opened, synchronize, reopened]
-
-permissions:
- pull-requests: write
- contents: read
-
# Narrow, high-signal scanner. Only fires on critical indicators of supply
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
@@ -19,56 +8,40 @@ permissions:
# the scanner. Keep this file's checks ruthlessly narrow: if you find
# yourself adding WARNING-tier patterns here again, make a separate
# advisory-only workflow instead.
+#
+# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
+# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
+# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
+# inputs instead of re-computing the diff.
-jobs:
- # ── Path filter (shared by both scan and dep-bounds) ───────────────
- changes:
- runs-on: ubuntu-latest
- outputs:
- # True when any file the scanner cares about changed in this PR
- scan: ${{ steps.filter.outputs.scan }}
- # True when pyproject.toml changed in this PR
- deps: ${{ steps.filter.outputs.deps }}
- # True when the curated MCP catalog / bundled MCP manifests changed.
- mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
- steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 0
- - name: Check for relevant file changes
- id: filter
- run: |
- BASE="${{ github.event.pull_request.base.sha }}"
- HEAD="${{ github.event.pull_request.head.sha }}"
- SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
- '*.py' '**/*.py' '*.pth' '**/*.pth' \
- 'setup.py' 'setup.cfg' \
- 'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
- 'pyproject.toml' || true)
- if [ -n "$SCAN_FILES" ]; then
- echo "scan=true" >> "$GITHUB_OUTPUT"
- else
- echo "scan=false" >> "$GITHUB_OUTPUT"
- fi
- DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
- if [ -n "$DEPS_FILES" ]; then
- echo "deps=true" >> "$GITHUB_OUTPUT"
- else
- echo "deps=false" >> "$GITHUB_OUTPUT"
- fi
- MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
- 'optional-mcps/**' \
- 'hermes_cli/mcp_catalog.py' || true)
- if [ -n "$MCP_CATALOG_FILES" ]; then
- echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
- else
- echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
- fi
+on:
+ workflow_call:
+ inputs:
+ event_name:
+ description: The event name from the calling orchestrator.
+ type: string
+ required: true
+ scan:
+ description: Whether supply-chain-relevant files changed.
+ type: boolean
+ required: true
+ deps:
+ description: Whether pyproject.toml changed.
+ type: boolean
+ required: true
+ mcp_catalog:
+ description: Whether the MCP catalog / installer changed.
+ type: boolean
+ required: true
+
+permissions:
+ pull-requests: write
+ contents: read
+jobs:
scan:
name: Scan PR for critical supply chain risks
- needs: changes
- if: needs.changes.outputs.scan == 'true'
+ if: inputs.scan
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -111,7 +84,7 @@ jobs:
fi
# --- base64 decode + exec/eval on the same line (the litellm attack pattern) ---
- B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
+ B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
if [ -n "$B64_EXEC_HITS" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: base64 decode + exec/eval combo
@@ -125,7 +98,7 @@ jobs:
fi
# --- subprocess with encoded/obfuscated command argument ---
- PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
+ PROC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
if [ -n "$PROC_HITS" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
@@ -187,23 +160,9 @@ jobs:
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
- # Gate: reports success when scan was skipped (no relevant files changed).
- # This ensures the required check always gets a status.
- scan-gate:
- name: Scan PR for critical supply chain risks
- needs: changes
- # always() so the gate still reports SUCCESS even if `changes` fails/is
- # skipped — without it, a failed dependency would leave the required
- # check unreported (i.e. "pending"), the exact failure mode this fixes.
- if: always() && needs.changes.outputs.scan != 'true'
- runs-on: ubuntu-latest
- steps:
- - run: echo "No supply-chain-relevant files changed, skipping scan."
-
dep-bounds:
name: Check PyPI dependency upper bounds
- needs: changes
- if: needs.changes.outputs.deps == 'true'
+ if: inputs.deps
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -253,7 +212,7 @@ jobs:
$(cat /tmp/unbounded.txt)
\`\`\`
- **Fix:** Add an upper bound, e.g. \`\"package>=1.2.0,<2\"\`
+ **Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
---
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
@@ -266,23 +225,9 @@ jobs:
echo "::error::PyPI dependencies without upper bounds detected. Add > "$GITHUB_OUTPUT"
+
+ test:
+ name: Run tests slice ${{ matrix.slice.index }}/${{ inputs.slice_count }}
+ needs: generate
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
- name: Install ripgrep (prebuilt binary)
run: |
set -euo pipefail
RG_VERSION=15.1.0
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
- curl -sSfL -o "$RG_TARBALL" \
+ curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
tar -xzf "$RG_TARBALL"
@@ -58,7 +65,7 @@ jobs:
rg --version
- name: Install uv
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
@@ -78,40 +85,28 @@ jobs:
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
- run: uv sync --locked --python 3.11 --extra all --extra dev
+ uses: ./.github/actions/retry
+ with:
+ command: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- - name: Run tests (slice ${{ matrix.slice }}/6)
- # Per-file isolation via scripts/run_tests_parallel.py: discovers
- # every test_*.py file under tests/ (excluding integration/ + e2e/),
- # then runs `python -m pytest ` in a freshly-spawned subprocess
+ - name: Run tests (slice ${{ matrix.slice.index }}/${{ inputs.slice_count }})
+ # Per-file isolation via scripts/run_tests.sh: each test file runs
+ # in its own freshly-spawned `python -m pytest ` subprocess
# with bounded parallelism. No xdist, no shared workers, no
# module-level state leakage between files.
#
- # Why per-file (not per-test): per-test spawn cost (~250ms × 17k
- # tests = 70min CPU minimum) blew the wall-clock budget. Per-file
- # spawn (~250ms × ~850 files = ~3.5min) fits while still giving
- # every file a fresh interpreter — the only isolation boundary
- # that matters in practice (cross-file leakage was the original
- # flake source; intra-file is the test author's responsibility).
- #
- # Why drop xdist entirely: xdist's persistent workers accumulate
- # state across files, which is exactly the leakage we wanted to
- # fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
- # the job with cleaner semantics.
- #
- # Matrix slicing (--slice I/N): files are distributed across 6
- # jobs by cached duration (LPT algorithm) so each job gets
- # roughly equal wall time. Without a cache, files default to 2s
- # estimate and get split roughly evenly by count — still correct,
- # just not perfectly balanced.
+ # File list is pre-computed by the generate job (--generate-slices)
+ # which runs LPT distribution once and passes the file list to each
+ # matrix job via --files. Previously each job re-discovered files and
+ # re-ran LPT independently — redundant N times.
run: |
source .venv/bin/activate
- python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
+ scripts/run_tests.sh --files '${{ matrix.slice.files }}'
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
@@ -121,7 +116,7 @@ jobs:
- name: Upload per-slice durations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: test-durations-slice-${{ matrix.slice }}
+ name: test-durations-slice-${{ matrix.slice.index }}
path: test_durations.json
retention-days: 1
@@ -171,7 +166,7 @@ jobs:
RG_VERSION=15.1.0
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
- curl -sSfL -o "$RG_TARBALL" \
+ curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
tar -xzf "$RG_TARBALL"
@@ -180,7 +175,7 @@ jobs:
rg --version
- name: Install uv
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
@@ -200,7 +195,9 @@ jobs:
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
- run: uv sync --locked --python 3.11 --extra all --extra dev
+ uses: ./.github/actions/retry
+ with:
+ command: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index 29994e3e29..dd2906629b 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -2,16 +2,11 @@
name: Typecheck
on:
- push:
- branches: [main]
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
+ workflow_call:
jobs:
typecheck:
+ name: Check TypeScript
runs-on: ubuntu-latest
strategy:
matrix:
@@ -24,7 +19,13 @@ jobs:
with:
node-version: 22
cache: npm
- - run: npm ci
+ # --ignore-scripts: typecheck only needs the TS sources + type defs, not
+ # native builds. Skipping install scripts drops node-pty's node-gyp
+ # header fetch — the transient flake that killed this job pre-`tsc` — and
+ # is faster. retry covers the remaining registry blips.
+ - uses: ./.github/actions/retry
+ with:
+ command: npm ci --ignore-scripts
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
@@ -34,6 +35,7 @@ jobs:
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
+ name: Build desktop app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -41,5 +43,9 @@ jobs:
with:
node-version: 22
cache: npm
- - run: npm ci
+ # Keep install scripts here: the production build may need node-pty's
+ # native binary. retry handles the transient install-time fetch flakes.
+ - uses: ./.github/actions/retry
+ with:
+ command: npm ci
- run: npm run --prefix apps/desktop build
diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml
index 9d1806d6f7..03fad4eba0 100644
--- a/.github/workflows/upload_to_pypi.yml
+++ b/.github/workflows/upload_to_pypi.yml
@@ -5,11 +5,11 @@ name: Publish to PyPI
on:
push:
tags:
- - 'v20*' # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
+ - "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
workflow_dispatch:
inputs:
confirm_tag:
- description: 'Tag to publish (e.g. v2026.5.15). Must already exist.'
+ description: "Tag to publish (e.g. v2026.5.15). Must already exist."
required: true
type: string
@@ -27,7 +27,7 @@ jobs:
name: Build distribution 📦
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
@@ -43,17 +43,17 @@ jobs:
fi
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
- python-version: '3.13'
+ python-version: "3.13"
- name: Install uv
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Node.js
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: '22'
+ node-version: "22"
- name: Build web dashboard
run: cd web && npm ci && npm run build
@@ -81,7 +81,7 @@ jobs:
run: uv build --sdist --wheel
- name: Upload distribution artifacts
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-package-distributions
path: dist/
@@ -94,17 +94,17 @@ jobs:
name: pypi
url: https://pypi.org/p/hermes-agent
permissions:
- id-token: write # OIDC trusted publishing
+ id-token: write # OIDC trusted publishing
steps:
- name: Download distribution artifacts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
@@ -116,12 +116,12 @@ jobs:
needs: publish
runs-on: ubuntu-latest
permissions:
- contents: write # attach assets to the existing release
- id-token: write # sigstore signing
+ contents: write # attach assets to the existing release
+ id-token: write # sigstore signing
steps:
- name: Download distribution artifacts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
@@ -145,7 +145,7 @@ jobs:
- name: Sign with Sigstore
if: env.skip_sign != 'true'
- uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
+ uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml
index 54662b23ed..8a7f52e899 100644
--- a/.github/workflows/uv-lockfile-check.yml
+++ b/.github/workflows/uv-lockfile-check.yml
@@ -4,7 +4,7 @@ name: uv.lock check
# that modify pyproject.toml without regenerating uv.lock (or vice versa)
# must not merge, because the Docker build's `uv sync --frozen` step will
# fail on a stale lockfile and we'd rather catch it here than in the
-# docker-publish workflow on main.
+# docker workflow on main.
#
# ─────────────────────────────────────────────────────────────────────────
# IMPORTANT: this check runs against the MERGED state, not just your branch
@@ -44,25 +44,14 @@ name: uv.lock check
# the same way. Better to catch it here than after merge.
on:
- push:
- branches: [main]
- paths:
- - "pyproject.toml"
- - "uv.lock"
- - ".github/workflows/uv-lockfile-check.yml"
-
- # No paths filter — the job must always run so the required check
- # reports a status (path-gated workflows leave checks "pending" forever
- # when no matching files change, which blocks merge).
- pull_request:
- branches: [main]
+ workflow_call:
permissions:
contents: read
concurrency:
group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+ cancel-in-progress: true
jobs:
check:
@@ -74,7 +63,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.
@@ -111,7 +100,7 @@ jobs:
This check is blocking because the Docker image build uses
`uv sync --frozen --extra all`, which rejects stale lockfiles
- — catching it here avoids a ~15 min failed docker-publish run
+ — catching it here avoids a ~15 min failed docker run
on `main` post-merge.
EOF
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
diff --git a/AGENTS.md b/AGENTS.md
index e032f76544..2124476549 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -123,6 +123,17 @@ conservative at the waist.
without E2E proof, and plugins that touch core files.** Plugins live in their
own directory and work within the ABCs/hooks we provide; if a plugin needs
more, widen the generic plugin surface, don't special-case it in core.
+- **Third-party products / other people's projects integrated into the core
+ tree.** Observability backends, vendor SaaS integrations, analytics dashboards,
+ and similar "someone else's product" plugins do NOT land under `plugins/` in
+ this repo. They place an ongoing maintenance burden on us to keep them working
+ against a fast-moving core, for a backend we don't own. Ship them as a
+ **standalone plugin repo** users install into `~/.hermes/plugins/` (or via a
+ pip entry point), and promote them in the Nous Research Discord
+ (`#plugins-skills-and-skins`). This is a coupling-and-maintenance decision, not
+ a quality bar — the plugin can be excellent and still be a close. PRs that add
+ such a directory to the tree are closed with a pointer to publish it as its own
+ repo.
### Before you call it a bug — verify the premise (and when NOT to close)
@@ -783,6 +794,24 @@ landing in this tree. PRs that add a new directory under
provider as its own repo. Existing in-tree providers stay; bug fixes
to them are welcome.
+**No new third-party-product plugins in-tree (policy, June 2026):** the
+same rule applies beyond memory providers. Plugins that integrate
+someone else's product or project — observability/metrics backends,
+vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
+must ship as **standalone plugin repos** that users install into
+`~/.hermes/plugins/` (or via pip entry points). They register through
+the existing plugin discovery path and use the ABCs/hooks/ctx surface
+we expose; nothing special is needed in core. The reason is
+maintenance load: every product we absorb into the tree becomes our
+burden to keep working against a fast-moving core, for a backend we
+don't own. Promote standalone plugins in the Nous Research Discord
+(`#plugins-skills-and-skins`). PRs that add such a directory under
+`plugins/` are closed with a pointer to publish it as its own repo —
+this is a coupling decision, not a quality judgment. (The
+`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
+in the tree are existing precedent, not an invitation to add more
+third-party-product plugins alongside them.)
+
### Model-provider plugins (`plugins/model-providers//`)
Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
@@ -954,9 +983,10 @@ Enable/disable per platform via `hermes tools` (the curses UI) or the
## Delegation (`delegate_task`)
`tools/delegate_tool.py` spawns a subagent with an isolated
-context + terminal session. Synchronous: the parent waits for the
-child's summary before continuing its own loop — if the parent is
-interrupted, the child is cancelled.
+context + terminal session. By default the parent waits for the
+child's summary before continuing its own loop. With `background=true`,
+Hermes returns a delegation id immediately and the result re-enters the
+conversation later through the async-delegation completion queue.
Two shapes:
@@ -978,9 +1008,9 @@ Key config knobs (under `delegation:` in `config.yaml`):
`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
`max_iterations`.
-Synchronicity rule: delegate_task is **not** durable. For long-running
-work that must outlive the current turn, use `cronjob` or
-`terminal(background=True, notify_on_complete=True)` instead.
+Durability rule: background `delegate_task` is detached from the current
+turn but still process-local. For work that must survive process restart, use
+`cronjob` or `terminal(background=True, notify_on_complete=True)` instead.
---
@@ -1174,7 +1204,7 @@ automatically scope to the active profile.
a unique credential (bot token, API key), call `acquire_scoped_lock()` from
`gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
`disconnect()`/`stop()`. This prevents two profiles from using the same credential.
- See `gateway/platforms/telegram.py` for the canonical pattern.
+ See `plugins/platforms/irc/adapter.py` for the canonical pattern.
6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
diff --git a/CONTRIBUTING.es.md b/CONTRIBUTING.es.md
new file mode 100644
index 0000000000..ab34206dd6
--- /dev/null
+++ b/CONTRIBUTING.es.md
@@ -0,0 +1,602 @@
+# Contribuir a Hermes Agent
+
+¡Gracias por contribuir a Hermes Agent! Esta guía cubre todo lo que necesitas: configurar tu entorno de desarrollo, entender la arquitectura, decidir qué construir y conseguir que tu PR sea aceptado.
+
+---
+
+## Prioridades de Contribución
+
+Valoramos las contribuciones en este orden:
+
+1. **Correcciones de errores** — bloqueos, comportamiento incorrecto, pérdida de datos. Siempre la máxima prioridad.
+2. **Compatibilidad entre plataformas** — macOS, diferentes distribuciones de Linux y WSL2 en Windows. Queremos que Hermes funcione en todas partes.
+3. **Fortalecimiento de seguridad** — inyección de shell, inyección de prompts, traversal de rutas, escalada de privilegios. Ver [Consideraciones de Seguridad](#consideraciones-de-seguridad).
+4. **Rendimiento y robustez** — lógica de reintento, manejo de errores, degradación elegante.
+5. **Nuevas habilidades** — pero solo las ampliamente útiles. Ver [¿Debería ser una Habilidad o una Herramienta?](#debería-ser-una-habilidad-o-una-herramienta)
+6. **Nuevas herramientas** — raramente necesarias. La mayoría de las capacidades deberían ser habilidades. Ver más abajo.
+7. **Documentación** — correcciones, aclaraciones, nuevos ejemplos.
+
+---
+
+## ¿Debería ser una Habilidad o una Herramienta?
+
+Esta es la pregunta más común para los nuevos colaboradores. La respuesta casi siempre es **habilidad**.
+
+### Hazlo una Habilidad cuando:
+
+- La capacidad se puede expresar como instrucciones + comandos de shell + herramientas existentes
+- Envuelve una CLI externa o API que el agente puede llamar a través de `terminal` o `web_extract`
+- No necesita integración personalizada de Python ni gestión de claves API integrada en el agente
+- Ejemplos: búsqueda en arXiv, flujos de trabajo de git, gestión de Docker, procesamiento de PDF, email a través de herramientas CLI
+
+### Hazlo una Herramienta cuando:
+
+- Requiere integración de extremo a extremo con claves API, flujos de autenticación o configuración de múltiples componentes gestionada por el harness del agente
+- Necesita lógica de procesamiento personalizada que debe ejecutarse con precisión en cada ocasión (no "mejor esfuerzo" de la interpretación del LLM)
+- Maneja datos binarios, streaming o eventos en tiempo real que no pueden pasar por el terminal
+- Ejemplos: automatización de navegador (gestión de sesiones Browserbase), TTS (codificación de audio + entrega en plataforma), análisis de visión (manejo de imágenes base64)
+
+### ¿Debería la Habilidad estar incluida?
+
+Las habilidades incluidas (en `skills/`) se envían con cada instalación de Hermes. Deben ser **ampliamente útiles para la mayoría de los usuarios**:
+
+- Manejo de documentos, investigación web, flujos de trabajo de desarrollo comunes, administración de sistemas
+- Usadas regularmente por una amplia gama de personas
+
+Si tu habilidad es oficial y útil pero no universalmente necesaria (ej., una integración de servicio de pago, una dependencia pesada), ponla en **`optional-skills/`** — se envía con el repositorio pero no está activada por defecto. Los usuarios pueden descubrirla a través de `hermes skills browse` (etiquetada como "oficial") e instalarla con `hermes skills install` (sin advertencia de terceros, confianza integrada).
+
+Si tu habilidad es especializada, contribuida por la comunidad o de nicho, es mejor para un **Skills Hub** — súbela a un registro de habilidades y compártela en el [Discord de Nous Research](https://discord.gg/NousResearch). Los usuarios pueden instalarla con `hermes skills install`.
+
+---
+
+## Proveedores de Memoria: Publicar como Plugin Independiente
+
+**Ya no aceptamos nuevos proveedores de memoria en este repositorio.** El conjunto de proveedores integrados en `plugins/memory/` (honcho, mem0, supermemory, byterover, hindsight, holographic, openviking, retaindb) está cerrado. Si quieres añadir un nuevo backend de memoria, publícalo como un **repositorio de plugin independiente** que los usuarios instalen en `~/.hermes/plugins/` (o a través de un entry point de pip).
+
+Los plugins de memoria independientes:
+
+- Implementan el mismo ABC `MemoryProvider` (`agent/memory_provider.py`) — `sync_turn`, `prefetch`, `shutdown` y opcionalmente `post_setup(hermes_home, config)` para integración con el asistente de configuración
+- Usan el mismo sistema de descubrimiento — `discover_memory_providers()` los recoge desde directorios de plugins de usuario/proyecto y entry points de pip
+- Se integran con `hermes memory setup` a través de `post_setup()` — sin necesidad de tocar el código base
+- Pueden registrar sus propios subcomandos CLI a través de `register_cli(subparser)` en un archivo `cli.py`
+- Obtienen todos los mismos hooks de ciclo de vida y plomería de configuración que los proveedores incluidos en el árbol
+
+Los PRs que añadan un nuevo directorio bajo `plugins/memory/` serán cerrados con un puntero para publicar el proveedor como su propio repositorio. Los proveedores en árbol existentes se mantienen; las correcciones de errores para ellos son bienvenidas.
+
+Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimiento. Los proveedores de memoria son el tipo de plugin más común y no deberían vivir todos en este árbol.
+
+---
+
+## Configuración del Desarrollo
+
+### Prerequisitos
+
+| Requisito | Notas |
+|-----------|-------|
+| **Git** | Con la extensión `git-lfs` instalada |
+| **Python 3.11+** | 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) |
+
+### Clonar e instalar
+
+```bash
+git clone https://github.com/NousResearch/hermes-agent.git
+cd hermes-agent
+
+# Crear venv con Python 3.11
+uv venv venv --python 3.11
+export VIRTUAL_ENV="$(pwd)/venv"
+
+# Instalar con todos los extras (mensajería, cron, menús CLI, herramientas de desarrollo)
+uv pip install -e ".[all,dev]"
+
+# Opcional: herramientas de navegador
+npm install
+```
+
+### Configurar para desarrollo
+
+```bash
+mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills}
+cp cli-config.yaml.example ~/.hermes/config.yaml
+touch ~/.hermes/.env
+
+# Añadir al menos una clave de proveedor LLM:
+echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
+```
+
+### Ejecutar
+
+```bash
+# Enlace simbólico para acceso global
+mkdir -p ~/.local/bin
+ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
+
+# Verificar
+hermes doctor
+hermes chat -q "Hola"
+```
+
+### Ejecutar tests
+
+```bash
+# Preferido — coincide con CI (entorno hermético, 4 workers xdist); ver AGENTS.md
+scripts/run_tests.sh
+
+# Alternativa (activa el venv primero). El wrapper sigue recomendándose
+# para paridad con GitHub Actions antes de abrir un PR:
+pytest tests/ -v
+```
+
+---
+
+## Estructura del Proyecto
+
+```
+hermes-agent/
+├── run_agent.py # Clase AIAgent — bucle de conversación central, despacho de herramientas, persistencia de sesión
+├── cli.py # Clase HermesCLI — TUI interactiva, integración prompt_toolkit
+├── model_tools.py # Orquestación de herramientas (capa delgada sobre tools/registry.py)
+├── toolsets.py # Agrupaciones y presets de herramientas (hermes-cli, hermes-telegram, etc.)
+├── hermes_state.py # Base de datos de sesiones SQLite con búsqueda de texto completo FTS5, títulos de sesión
+├── batch_runner.py # Procesamiento en lote paralelo para generación de trayectorias
+│
+├── agent/ # Internos del agente (módulos extraídos)
+│ ├── prompt_builder.py # Ensamblaje del prompt del sistema (identidad, habilidades, archivos de contexto, memoria)
+│ ├── context_compressor.py # Auto-resumición al acercarse a los límites de contexto
+│ ├── auxiliary_client.py # Resuelve clientes OpenAI auxiliares (resumición, visión)
+│ ├── display.py # KawaiiSpinner, formateo del progreso de herramientas
+│ ├── model_metadata.py # Longitudes de contexto del modelo, estimación de tokens
+│ └── trajectory.py # Ayudantes para guardar trayectorias
+│
+├── hermes_cli/ # Implementaciones de comandos CLI
+│ ├── main.py # Punto de entrada, análisis de argumentos, despacho de comandos
+│ ├── config.py # Gestión de configuración, migración, definiciones de variables de entorno
+│ ├── setup.py # Asistente de configuración interactivo
+│ ├── auth.py # Resolución de proveedor, OAuth, Nous Portal
+│ ├── models.py # Listas de selección de modelos de OpenRouter
+│ ├── banner.py # Banner de bienvenida, arte ASCII
+│ ├── commands.py # Registro central de comandos de barra (CommandDef), autocompletado, ayudantes del gateway
+│ ├── callbacks.py # Callbacks interactivos (aclarar, sudo, aprobación)
+│ ├── doctor.py # Diagnósticos
+│ ├── skills_hub.py # CLI del Skills Hub + comando de barra /skills
+│ └── skin_engine.py # Motor de skins/temas — personalización visual de CLI basada en datos
+│
+├── tools/ # Implementaciones de herramientas (auto-registradas)
+│ ├── registry.py # Registro central de herramientas (esquemas, manejadores, despacho)
+│ ├── approval.py # Detección de comandos peligrosos + aprobación por sesión
+│ ├── terminal_tool.py # Orquestación del terminal (sudo, ciclo de vida del entorno, backends)
+│ ├── file_operations.py # read_file, write_file, búsqueda, patch, etc.
+│ ├── web_tools.py # web_search, web_extract (Paralelo/Firecrawl + resumición Gemini)
+│ ├── vision_tools.py # Análisis de imágenes a través de modelos multimodales
+│ ├── delegate_tool.py # Lanzamiento de subagentes y ejecución paralela de tareas
+│ ├── code_execution_tool.py # Python sandboxado con acceso a herramientas vía RPC
+│ ├── session_search_tool.py # Búsqueda en conversaciones pasadas con FTS5 + ventanas ancladas
+│ ├── cronjob_tools.py # Gestión de tareas programadas
+│ ├── skill_tools.py # Búsqueda, carga y gestión de habilidades
+│ └── environments/ # Backends de ejecución del terminal
+│ ├── base.py # ABC BaseEnvironment
+│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py
+│
+├── gateway/ # Gateway de mensajería
+│ ├── run.py # GatewayRunner — ciclo de vida de plataformas, enrutamiento de mensajes, cron
+│ ├── config.py # Resolución de configuración de plataformas
+│ ├── session.py # Almacén de sesiones, prompts de contexto, políticas de reset
+│ └── platforms/ # Adaptadores de plataformas
+│ ├── telegram.py, discord_adapter.py, slack.py, whatsapp.py
+│
+├── scripts/ # Scripts del instalador y puente
+│ ├── install.sh # Instalador Linux/macOS
+│ ├── install.ps1 # Instalador Windows PowerShell
+│ └── whatsapp-bridge/ # Puente WhatsApp Node.js (Baileys)
+│
+├── skills/ # Habilidades incluidas (copiadas a ~/.hermes/skills/ en la instalación)
+├── optional-skills/ # Habilidades opcionales oficiales (descubribles vía hub, no activadas por defecto)
+├── tests/ # Suite de tests
+├── website/ # Sitio de documentación (hermes-agent.nousresearch.com)
+│
+├── cli-config.yaml.example # Configuración de ejemplo (copiada a ~/.hermes/config.yaml)
+└── AGENTS.md # Guía de desarrollo para asistentes de codificación IA
+```
+
+### Configuración del usuario (almacenada en `~/.hermes/`)
+
+| Ruta | Propósito |
+|------|-----------|
+| `~/.hermes/config.yaml` | Configuración (modelo, terminal, toolsets, compresión, etc.) |
+| `~/.hermes/.env` | Claves API y secretos |
+| `~/.hermes/auth.json` | Credenciales OAuth (Nous Portal) |
+| `~/.hermes/skills/` | Todas las habilidades activas (incluidas + instaladas desde hub + creadas por el agente) |
+| `~/.hermes/memories/` | Memoria persistente (MEMORY.md, USER.md) |
+| `~/.hermes/state.db` | Base de datos de sesiones SQLite |
+| `~/.hermes/sessions/` | Índice de enrutamiento del gateway (`sessions.json`), migas de pan de solicitudes, transcripciones `*.jsonl` del gateway y (opcionalmente) snapshots JSON por sesión cuando `sessions.write_json_snapshots: true` está configurado. Los snapshots por sesión están desactivados por defecto; state.db es canónica. |
+| `~/.hermes/cron/` | Datos de trabajos programados |
+| `~/.hermes/whatsapp/session/` | Credenciales del puente WhatsApp |
+
+---
+
+## Descripción General de la Arquitectura
+
+### Bucle Central
+
+```
+Mensaje del usuario → AIAgent._run_agent_loop()
+ ├── Construir prompt del sistema (prompt_builder.py)
+ ├── Construir kwargs de API (modelo, mensajes, herramientas, configuración de razonamiento)
+ ├── Llamar al LLM (API compatible con OpenAI)
+ ├── Si tool_calls en la respuesta:
+ │ ├── Ejecutar cada herramienta a través del despacho del registro
+ │ ├── Añadir resultados de herramientas a la conversación
+ │ └── Volver a la llamada al LLM
+ ├── Si respuesta de texto:
+ │ ├── Persistir sesión en DB
+ │ └── Devolver final_response
+ └── Compresión de contexto si se acerca al límite de tokens
+```
+
+### Patrones de Diseño Clave
+
+- **Herramientas auto-registradas**: Cada archivo de herramienta llama a `registry.register()` en el momento de importación. `model_tools.py` activa el descubrimiento importando todos los módulos de herramientas.
+- **Agrupación en toolsets**: Las herramientas se agrupan en toolsets (`web`, `terminal`, `file`, `browser`, etc.) que pueden habilitarse/deshabilitarse por plataforma.
+- **Persistencia de sesión**: Todas las conversaciones se almacenan en SQLite (`hermes_state.py`) con búsqueda de texto completo y títulos de sesión únicos.
+- **Inyección efímera**: Los prompts del sistema y los mensajes de relleno se inyectan en el momento de la llamada API, nunca se persisten en la base de datos ni en los logs.
+- **Abstracción de proveedor**: El agente funciona con cualquier API compatible con OpenAI. La resolución del proveedor ocurre en el momento de la inicialización.
+- **Enrutamiento de proveedor**: Al usar OpenRouter, `provider_routing` en config.yaml controla la selección del proveedor.
+
+---
+
+## Estilo de Código
+
+- **PEP 8** con excepciones prácticas (no imponemos longitud de línea estricta)
+- **Comentarios**: Solo cuando se explica la intención no obvia, compromisos o peculiaridades de API. No narres lo que hace el código
+- **Manejo de errores**: Captura excepciones específicas. Registra con `logger.warning()`/`logger.error()` — usa `exc_info=True` para errores inesperados
+- **Multiplataforma**: Nunca asumas Unix. Ver [Compatibilidad Multiplataforma](#compatibilidad-multiplataforma)
+
+---
+
+## Añadir una Nueva Herramienta
+
+Antes de escribir una herramienta, pregúntate: [¿debería ser una habilidad en su lugar?](#debería-ser-una-habilidad-o-una-herramienta)
+
+Las herramientas se auto-registran en el registro central. Cada archivo de herramienta co-localiza su esquema, manejador y registro:
+
+```python
+"""my_tool — Breve descripción de lo que hace esta herramienta."""
+
+import json
+from tools.registry import registry
+
+
+def my_tool(param1: str, param2: int = 10, **kwargs) -> str:
+ """Manejador. Devuelve un resultado en cadena (a menudo JSON)."""
+ result = do_work(param1, param2)
+ return json.dumps(result)
+
+
+MY_TOOL_SCHEMA = {
+ "type": "function",
+ "function": {
+ "name": "my_tool",
+ "description": "Qué hace esta herramienta y cuándo debería usarla el agente.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "param1": {"type": "string", "description": "Qué es param1"},
+ "param2": {"type": "integer", "description": "Qué es param2", "default": 10},
+ },
+ "required": ["param1"],
+ },
+ },
+}
+
+
+def _check_requirements() -> bool:
+ """Devuelve True si las dependencias de esta herramienta están disponibles."""
+ return True
+
+
+registry.register(
+ name="my_tool",
+ toolset="my_toolset",
+ schema=MY_TOOL_SCHEMA,
+ handler=lambda args, **kw: my_tool(**args, **kw),
+ check_fn=_check_requirements,
+)
+```
+
+**Conectar a un toolset (requerido):** Las herramientas integradas se auto-descubren: cualquier
+archivo `tools/*.py` que contenga una llamada de nivel superior `registry.register(...)` es
+importado por `discover_builtin_tools()` en `tools/registry.py` cuando `model_tools`
+se carga. **No** hay una lista de importaciones manual en `model_tools.py` que mantener.
+
+Todavía debes añadir el nombre de la herramienta a la lista apropiada en `toolsets.py`
+(por ejemplo `_HERMES_CORE_TOOLS` o un toolset dedicado); de lo contrario la herramienta
+se registra pero nunca se expone al agente.
+
+Consulta `AGENTS.md` (sección **Adding New Tools**) para rutas conscientes del perfil y
+orientación sobre plugins vs. núcleo.
+
+---
+
+## Añadir una Habilidad
+
+Las habilidades incluidas viven en `skills/` organizadas por categoría. Las habilidades opcionales oficiales usan la misma estructura en `optional-skills/`:
+
+```
+skills/
+├── research/
+│ └── arxiv/
+│ ├── SKILL.md # Requerido: instrucciones principales
+│ └── scripts/ # Opcional: scripts auxiliares
+│ └── search_arxiv.py
+├── productivity/
+│ └── ocr-and-documents/
+│ ├── SKILL.md
+│ ├── scripts/
+│ └── references/
+└── ...
+```
+
+### Formato de SKILL.md
+
+```markdown
+---
+name: my-skill
+description: Breve descripción (mostrada en los resultados de búsqueda de habilidades)
+version: 1.0.0
+author: Tu Nombre
+license: MIT
+platforms: [macos, linux] # Opcional — restringir a plataformas de SO específicas
+required_environment_variables: # Opcional — metadatos de configuración segura al cargar
+ - name: MY_API_KEY
+ prompt: Clave API
+ help: Dónde obtenerla
+ required_for: funcionalidad completa
+prerequisites: # Requisitos de tiempo de ejecución heredados opcionales
+ env_vars: [MY_API_KEY]
+ commands: [curl, jq]
+metadata:
+ hermes:
+ tags: [Categoría, Subcategoría, Palabras clave]
+ related_skills: [other-skill-name]
+ fallback_for_toolsets: [web]
+ requires_toolsets: [terminal]
+---
+
+# Título de la Habilidad
+
+Introducción breve.
+
+## Cuándo Usar
+Condiciones de activación — ¿cuándo debería el agente cargar esta habilidad?
+
+## Referencia Rápida
+Tabla de comandos o llamadas API comunes.
+
+## Procedimiento
+Instrucciones paso a paso que el agente sigue.
+
+## Problemas Conocidos
+Modos de fallo conocidos y cómo manejarlos.
+
+## Verificación
+Cómo confirma el agente que funcionó.
+```
+
+### Estándares de autoría de habilidades (OBLIGATORIOS)
+
+Todo skill nuevo o modernizado — incluido, opcional o contribuido — debe cumplir estos estándares antes del merge:
+
+1. **`description` ≤ 60 caracteres, una oración, termina con punto.** Las descripciones largas saturan la UI de listado de habilidades. Indica la capacidad, no la implementación. Sin palabras de marketing ("potente", "completo", "fluido", "avanzado").
+
+2. **Las herramientas referenciadas en el cuerpo de SKILL.md deben ser herramientas nativas de Hermes o servidores MCP que la habilidad espere explícitamente.** Usa los nombres de herramientas en comillas invertidas: `` `terminal` ``, `` `web_extract` ``, `` `web_search` ``, `` `read_file` ``, `` `write_file` ``, etc.
+
+3. **El campo `platforms:` auditado contra las importaciones reales del script.** Las habilidades que usen primitivos solo de POSIX deben declarar sus plataformas soportadas.
+
+4. **`author` da crédito primero al colaborador humano.**
+
+5. **El cuerpo de SKILL.md usa el orden moderno de secciones:** título, intro de 2-3 oraciones, luego: `## Cuándo Usar`, `## Prerequisitos`, `## Cómo Ejecutar`, `## Referencia Rápida`, `## Procedimiento`, `## Problemas Conocidos`, `## Verificación`.
+
+6. **Los scripts van en `scripts/`, las referencias en `references/`, las plantillas en `templates/`.**
+
+7. **Los tests viven en `tests/skills/test__skill.py`** y usan solo stdlib + pytest + `unittest.mock`. Sin llamadas de red en vivo.
+
+8. **Las adiciones a `.env.example` están aisladas en un bloque claramente delimitado.**
+
+---
+
+## Añadir una Skin / Tema
+
+Hermes usa un sistema de skins basado en datos — no se necesitan cambios de código para añadir una nueva skin.
+
+**Opción A: Skin de usuario (archivo YAML)**
+
+Crea `~/.hermes/skins/.yaml`:
+
+```yaml
+name: mitema
+description: Breve descripción del tema
+
+colors:
+ banner_border: "#HEX"
+ banner_title: "#HEX"
+ banner_accent: "#HEX"
+ banner_dim: "#HEX"
+ banner_text: "#HEX"
+ response_border: "#HEX"
+
+spinner:
+ waiting_faces: ["(⚔)", "(⛨)"]
+ thinking_faces: ["(⚔)", "(⌁)"]
+ thinking_verbs: ["forjando", "planeando"]
+
+branding:
+ agent_name: "Mi Agente"
+ welcome: "Mensaje de bienvenida"
+ response_label: " ⚔ Agente "
+ prompt_symbol: "⚔"
+
+tool_prefix: "╎"
+```
+
+Todos los campos son opcionales — los valores faltantes se heredan de la skin predeterminada.
+
+**Opción B: Skin integrada**
+
+Añade al dict `_BUILTIN_SKINS` en `hermes_cli/skin_engine.py`. Usa el mismo esquema que arriba pero como dict de Python.
+
+**Activar:**
+- CLI: `/skin mitema` o establece `display.skin: mitema` en config.yaml
+
+---
+
+## Compatibilidad Multiplataforma
+
+Hermes se ejecuta en Linux, macOS y Windows nativo (además de WSL2). Al escribir código
+que toca el SO, asume que *cualquier* plataforma puede alcanzar tu ruta de código.
+
+> **Antes de hacer PR:** ejecuta `scripts/check-windows-footguns.py` para detectar
+> los patrones inseguros comunes de Windows en tu diff. Es basado en grep y barato;
+> CI también lo ejecuta en cada PR.
+
+### Reglas críticas
+
+1. **Nunca llames `os.kill(pid, 0)` para comprobaciones de liveness.** En Windows **NO es una operación sin efecto**. Usa `psutil.pid_exists(pid)` en su lugar.
+
+2. **Usa `shutil.which()` antes de hacer shell — no asumas que Windows tiene las herramientas que tiene Linux.** `ps`, `kill`, `grep`, `awk`, etc. simplemente no existen en Windows.
+
+3. **`termios` y `fcntl` son solo de Unix.** Siempre captura tanto `ImportError` como `NotImplementedError`.
+
+4. **Codificación de archivos.** Windows puede guardar archivos `.env` en `cp1252`. Siempre maneja errores de codificación.
+
+5. **Gestión de procesos.** `os.setsid()`, `os.killpg()`, `os.fork()`, `os.getuid()` y el manejo de señales POSIX difieren en Windows.
+
+6. **Señales que no existen en Windows:** `SIGALRM`, `SIGCHLD`, `SIGHUP`, `SIGUSR1`, `SIGUSR2`, etc.
+
+7. **Separadores de ruta.** Usa `pathlib.Path` en lugar de concatenación de cadenas con `/`.
+
+8. **Los enlaces simbólicos necesitan privilegios elevados en Windows** (a menos que el Modo Desarrollador esté activado).
+
+9. **Los modos de archivo POSIX (0o600, 0o644, etc.) NO se aplican en NTFS** por defecto.
+
+10. **Los daemons de fondo desacoplados en Windows necesitan `pythonw.exe`, NO `python.exe`.**
+
+---
+
+## Consideraciones de Seguridad
+
+Hermes tiene acceso al terminal. La seguridad importa.
+
+### Protecciones existentes
+
+| Capa | Implementación |
+|------|---------------|
+| **Piping de contraseña sudo** | Usa `shlex.quote()` para prevenir inyección de shell |
+| **Detección de comandos peligrosos** | Patrones regex en `tools/approval.py` con flujo de aprobación del usuario |
+| **Inyección de prompts en cron** | Escáner en `tools/cronjob_tools.py` bloquea patrones de anulación de instrucciones |
+| **Lista de denegación de escritura** | Rutas protegidas resueltas a través de `os.path.realpath()` para prevenir bypass de enlaces simbólicos |
+| **Skills Guard** | Escáner de seguridad para habilidades instaladas desde el hub (`tools/skills_guard.py`) |
+| **Sandbox de ejecución de código** | El proceso hijo `execute_code` se ejecuta con claves API eliminadas del entorno |
+| **Fortalecimiento de contenedor** | Docker: todas las capacidades eliminadas, sin escalada de privilegios, límites de PID, tmpfs de tamaño limitado |
+
+### Al contribuir código sensible a la seguridad
+
+- **Siempre usa `shlex.quote()`** al interpolar entrada del usuario en comandos de shell
+- **Resuelve enlaces simbólicos** con `os.path.realpath()` antes de comprobaciones de control de acceso basadas en rutas
+- **No registres secretos.** Las claves API, tokens y contraseñas nunca deben aparecer en la salida de log
+- **Captura excepciones amplias** alrededor de la ejecución de herramientas para que un solo fallo no bloquee el bucle del agente
+- **Prueba en todas las plataformas** si tu cambio toca rutas de archivos, gestión de procesos o comandos de shell
+
+### Política de fijación de dependencias (fortalecimiento de la cadena de suministro)
+
+Tras el [compromiso de la cadena de suministro de litellm](https://github.com/BerriAI/litellm/issues/24512) en marzo de 2026 y la [campaña del gusano Mini Shai-Hulud](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) en mayo de 2026, todas las dependencias deben seguir estas reglas:
+
+| Tipo de fuente | Tratamiento requerido | Justificación |
+|---|---|---|
+| **Paquete PyPI** | `>=suelo, # vX.Y.Z` |
+| **Instalaciones pip solo de CI** | `==exacto` | Builds de CI herméticos; el cambio es aceptable. |
+
+**Cada nueva dependencia de PyPI en un PR debe tener un límite superior `=X.Y.Z` sin límite superior serán rechazados.
+
+---
+
+## Proceso de Pull Request
+
+### Nomenclatura de ramas
+
+```
+fix/descripcion # Correcciones de errores
+feat/descripcion # Nuevas funcionalidades
+docs/descripcion # Documentación
+test/descripcion # Tests
+refactor/descripcion # Reestructuración de código
+```
+
+### Antes de enviar
+
+1. **Ejecutar tests**: `scripts/run_tests.sh` (recomendado; igual que CI) o `pytest tests/ -v` con el venv del proyecto activado
+2. **Probar manualmente**: Ejecuta `hermes` y ejercita la ruta de código que cambiaste
+3. **Verificar impacto multiplataforma**: Si tocas E/S de archivos, gestión de procesos o manejo del terminal, considera macOS, Linux y WSL2
+4. **Mantén los PRs enfocados**: Un cambio lógico por PR. No mezcles una corrección de error con una refactorización con una nueva funcionalidad.
+
+### Descripción del PR
+
+Incluye:
+- **Qué** cambió y **por qué**
+- **Cómo probarlo** (pasos de reproducción para errores, ejemplos de uso para funcionalidades)
+- **Qué plataformas** probaste
+- Referencia cualquier issue relacionado
+
+### Mensajes de commit
+
+Usamos [Conventional Commits](https://www.conventionalcommits.org/):
+
+```
+():
+```
+
+| Tipo | Usar para |
+|------|-----------|
+| `fix` | Correcciones de errores |
+| `feat` | Nuevas funcionalidades |
+| `docs` | Documentación |
+| `test` | Tests |
+| `refactor` | Reestructuración de código (sin cambio de comportamiento) |
+| `chore` | Build, CI, actualizaciones de dependencias |
+
+Alcances: `cli`, `gateway`, `tools`, `skills`, `agent`, `install`, `whatsapp`, `security`, etc.
+
+Ejemplos:
+```
+fix(cli): prevenir bloqueo en save_config_value cuando el modelo es una cadena
+feat(gateway): añadir aislamiento de sesión multi-usuario de WhatsApp
+fix(security): prevenir inyección de shell en el piping de contraseña sudo
+test(tools): añadir tests unitarios para file_operations
+```
+
+---
+
+## Reportar Issues
+
+- Usa [GitHub Issues](https://github.com/NousResearch/hermes-agent/issues)
+- Incluye: SO, versión de Python, versión de Hermes (`hermes version`), traza de error completa
+- Incluye pasos para reproducir
+- Verifica los issues existentes antes de crear duplicados
+- Para vulnerabilidades de seguridad, por favor reporta de forma privada
+
+---
+
+## Comunidad
+
+- **Discord**: [discord.gg/NousResearch](https://discord.gg/NousResearch) — para preguntas, mostrar proyectos y compartir habilidades
+- **GitHub Discussions**: Para propuestas de diseño y discusiones de arquitectura
+- **Skills Hub**: Sube habilidades especializadas a un registro y compártelas con la comunidad
+
+---
+
+## Licencia
+
+Al contribuir, aceptas que tus contribuciones serán licenciadas bajo la [Licencia MIT](LICENSE).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1a70116548..7f56b971d1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,6 +18,24 @@ We value contributions in this order:
---
+## Before You Start: Search First
+
+A quick search before you build saves your time and keeps the PR queue clean — duplicates are common here, so it's worth a minute up front.
+
+- **Search both open *and* merged PRs and issues** for your topic or error symptom — the duplicate-check in the PR template fires at review time, after you've already done the work:
+ ```bash
+ gh search issues --repo NousResearch/hermes-agent ""
+ gh search prs --repo NousResearch/hermes-agent --state all ""
+ ```
+ Or use the web UI: [issues](https://github.com/NousResearch/hermes-agent/issues?q=) · [PRs (all states)](https://github.com/NousResearch/hermes-agent/pulls?q=is%3Apr).
+- **The issue tracker can lag the code.** Many requested features are already implemented in-tree, so also search the source (`search_files`, or your editor's grep) for the capability before proposing it.
+- **If an open PR already addresses it**, consider reviewing or improving that one instead of opening a competing duplicate.
+- **For larger work**, comment on the issue to signal you're working on it, so others don't start the same thing.
+
+Related: #38284 covers the agent-side analog — Hermes itself checking existing issues and PRs before deep self-troubleshooting. This section is the human-contributor complement.
+
+---
+
## Should it be a Skill or a Tool?
This is the most common question for new contributors. The answer is almost always **skill**.
@@ -67,6 +85,23 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
---
+## Third-Party Product Integrations: Ship as a Standalone Plugin
+
+The same rule extends to **any plugin that integrates someone else's product or project** — observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins, and similar third-party integrations. **These do not land in this repo.**
+
+The reason is maintenance load, not quality. Every external product absorbed into the core tree becomes ours to keep working against a fast-moving codebase, for a backend we don't own and can't control. Hermes ships a lot and the core moves quickly; coupling third-party products into it creates an open-ended burden on the maintainers.
+
+Publish these as a **standalone plugin repo** instead:
+
+- Implement the relevant ABC and use the existing plugin discovery path (`~/.hermes/plugins/`, project `.hermes/plugins/`, or a pip entry point) — see [Build a Hermes Plugin](https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin)
+- Register lifecycle hooks (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`), tools (`ctx.register_tool`), and CLI subcommands (`ctx.register_cli_command`) through the surface we already expose — no core changes needed
+- If your plugin needs a capability the framework doesn't expose, that's a feature request to **widen the generic plugin surface** (a new hook or `ctx` method) — never special-case your plugin in core
+- Promote it in the [Nous Research Discord](https://discord.gg/NousResearch) `#plugins-skills-and-skins` channel so users can find and install it
+
+A well-built third-party-product plugin can clear automated review and still be closed for this reason — it's a placement decision, not a verdict on the code. PRs that add such a directory under `plugins/` will be closed with a pointer to publish it as its own repo.
+
+---
+
## Development Setup
### Prerequisites
@@ -412,6 +447,12 @@ Brief intro.
## When to Use
Trigger conditions — when should the agent load this skill?
+## Prerequisites
+Env vars, install steps, MCP setup, API key sourcing.
+
+## How to Run
+Canonical invocation through the `terminal` tool.
+
## Quick Reference
Table of common commands or API calls.
diff --git a/Dockerfile b/Dockerfile
index b4ebd09369..6a5f5f1eef 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -189,7 +189,13 @@ RUN cd web && npm run build && \
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
-COPY . .
+# --link decouples this layer from parents for cache purposes; --chmod bakes
+# the final read-only permissions at copy time so we skip the separate
+# `chmod -R` pass that previously walked ~30k files across the venv +
+# node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w`
+# gives the non-root hermes user read + traverse but no write; root retains
+# write so the build steps below don't need chmod u+w dances.
+COPY --link --chmod=a+rX,go-w . .
# ---------- Permissions ----------
# Link hermes-agent itself (editable). Deps are already installed in the
@@ -197,19 +203,15 @@ COPY . .
# resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
-# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
-# instances must not be able to self-edit the installed source or venv; user
-# data, skills, plugins, config, logs, and dashboard uploads live under
-# /opt/data instead. Root can still repair the image during build/boot, but
-# supervised Hermes processes drop to the non-root hermes user.
+# Wire the exec shim and install-method stamp. Files under /opt/hermes are
+# already root-owned (COPY, uv sync, npm install all run as root) and
+# read-only for the hermes user (go-w from the --chmod above).
+
USER root
RUN mkdir -p /opt/hermes/bin && \
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
- printf 'docker\n' > /opt/hermes/.install_method && \
- chown -R root:root /opt/hermes && \
- chmod -R a+rX /opt/hermes && \
- chmod -R a-w /opt/hermes
+ printf 'docker\n' > /opt/hermes/.install_method
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
@@ -236,13 +238,11 @@ RUN mkdir -p /opt/hermes/bin && \
#
# The arg is optional — local `docker build` without --build-arg simply
# omits the file, and the runtime falls back to live-git lookup. CI
-# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
+# (.github/workflows/docker.yml) passes ${{ github.sha }} so
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
- chmod u+w /opt/hermes && \
- printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
- chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
+ printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
@@ -290,6 +290,19 @@ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
+# The published image seals /opt/hermes (root-owned, read-only) so a runtime
+# lazy install can't mutate the agent's own venv and brick it. But opt-in
+# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
+# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
+# policy 2026-05-12: one quarantined release must not break every install).
+# Redirect those lazy installs to a writable dir on the durable data volume.
+# lazy_deps appends this dir to the END of sys.path, so a package installed
+# here can only ADD modules — it can never shadow or downgrade a core module,
+# so the sealed-venv guarantee holds even with installs re-enabled. The dir
+# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
+# on the /opt/data volume, so it persists across container recreates / image
+# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
+ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
# `docker exec` privilege-drop shim. When operators run
# `docker exec hermes ...` they default to root, and any file the
diff --git a/README.es.md b/README.es.md
new file mode 100644
index 0000000000..af8558513c
--- /dev/null
+++ b/README.es.md
@@ -0,0 +1,220 @@
+
+
+**El agente de IA con mejora continua creado por [Nous Research](https://nousresearch.com).** Es el único agente con un bucle de aprendizaje integrado: crea habilidades a partir de la experiencia, las mejora durante el uso, se impulsa a sí mismo a persistir el conocimiento, busca en sus propias conversaciones pasadas y construye un modelo cada vez más profundo de quién eres a lo largo de las sesiones. Ejecútalo en un VPS de $5, un clúster de GPUs o infraestructura sin servidor que cuesta casi nada cuando está inactivo. No está atado a tu laptop — habla con él desde Telegram mientras trabaja en una VM en la nube.
+
+Usa cualquier modelo que quieras — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (más de 200 modelos), [NovitaAI](https://novita.ai), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, o tu propio endpoint. Cambia con `hermes model` — sin cambios de código, sin dependencias.
+
+
+
Una interfaz de terminal real
TUI completa con edición multilínea, autocompletado de comandos, historial de conversaciones, interrupción y redirección, y salida de herramientas en streaming.
+
Vive donde tú vives
Telegram, Discord, Slack, WhatsApp, Signal y CLI — todo desde un único proceso gateway. Transcripción de notas de voz, continuidad de conversación entre plataformas.
+
Un bucle de aprendizaje cerrado
Memoria curada por el agente con recordatorios periódicos. Creación autónoma de habilidades tras tareas complejas. Las habilidades mejoran solas durante el uso. Búsqueda FTS5 de sesiones con resumención por LLM para recuperación entre sesiones. Modelado de usuario dialéctico Honcho. Compatible con el estándar abierto de agentskills.io.
+
Automatizaciones programadas
Planificador cron integrado con entrega a cualquier plataforma. Informes diarios, copias de seguridad nocturnas, auditorías semanales — todo en lenguaje natural, ejecutándose de forma autónoma.
+
Delega y paraleliza
Lanza subagentes aislados para flujos de trabajo paralelos. Escribe scripts de Python que llaman a herramientas vía RPC, convirtiendo pipelines de múltiples pasos en turnos de coste cero de contexto.
+
Funciona en cualquier lugar, no solo en tu laptop
Seis backends de terminal — local, Docker, SSH, Singularity, Modal y Daytona. Daytona y Modal ofrecen persistencia sin servidor — el entorno de tu agente hiberna cuando está inactivo y se activa bajo demanda, costando casi nada entre sesiones. Ejecútalo en un VPS de $5 o un clúster de GPUs.
+
Listo para investigación
Generación de trayectorias en lote, compresión de trayectorias para entrenar la próxima generación de modelos de llamadas a herramientas.
+
+
+---
+
+## Instalación rápida
+
+### Linux, macOS, WSL2, Termux
+
+```bash
+curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
+```
+
+### Windows (nativo, PowerShell)
+
+> **Nota:** En Windows nativo, Hermes funciona sin WSL — la CLI, el gateway, la TUI y las herramientas funcionan de forma nativa. Si prefieres usar WSL2, el comando de Linux/macOS de arriba también funciona allí. ¿Encontraste un error? Por favor [crea un issue](https://github.com/NousResearch/hermes-agent/issues).
+
+Ejecuta esto en PowerShell:
+
+```powershell
+iex (irm https://hermes-agent.nousresearch.com/install.ps1)
+```
+
+El instalador se encarga de todo: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **y un Git Bash portátil** (MinGit, descomprimido en `%LOCALAPPDATA%\hermes\git` — no requiere administrador, completamente aislado de cualquier instalación de Git del sistema). Hermes usa este Git Bash incluido para ejecutar comandos de shell.
+
+Si ya tienes Git instalado, el instalador lo detecta y lo usa en su lugar. De lo contrario, una descarga de ~45MB de MinGit es todo lo que necesitas — no tocará ni interferirá con ningún Git del sistema.
+
+> **Android / Termux:** La ruta manual probada está documentada en la [guía de Termux](https://hermes-agent.nousresearch.com/docs/getting-started/termux). En Termux, Hermes instala el extra `.[termux]` curado porque el extra completo `.[all]` actualmente incluye dependencias de voz incompatibles con Android.
+>
+> **Windows:** Windows nativo es totalmente compatible — el comando de PowerShell de arriba instala todo. Si prefieres usar WSL2, el comando de Linux también funciona allí. La instalación nativa de Windows se encuentra en `%LOCALAPPDATA%\hermes`; WSL2 instala en `~/.hermes` como en Linux.
+
+Después de la instalación:
+
+```bash
+source ~/.bashrc # recargar shell (o: source ~/.zshrc)
+hermes # ¡empieza a chatear!
+```
+
+---
+
+## Primeros pasos
+
+```bash
+hermes # CLI interactiva — inicia una conversación
+hermes model # Elige tu proveedor y modelo LLM
+hermes tools # Configura qué herramientas están habilitadas
+hermes config set # Establece valores de configuración individuales
+hermes gateway # Inicia el gateway de mensajería (Telegram, Discord, etc.)
+hermes setup # Ejecuta el asistente de configuración completo
+hermes claw migrate # Migra desde OpenClaw (si vienes de OpenClaw)
+hermes update # Actualiza a la última versión
+hermes doctor # Diagnostica cualquier problema
+```
+
+📖 **[Documentación completa →](https://hermes-agent.nousresearch.com/docs/)**
+
+---
+
+## Evita la colección de claves API — Nous Portal
+
+Hermes funciona con cualquier proveedor que quieras — eso no cambiará. Pero si prefieres no recopilar cinco claves API separadas para el modelo, búsqueda web, generación de imágenes, TTS y un navegador en la nube, **[Nous Portal](https://portal.nousresearch.com)** las cubre todas bajo una sola suscripción:
+
+- **Más de 300 modelos** — elige cualquiera con `/model `
+- **Tool Gateway** — búsqueda web (Firecrawl), generación de imágenes (FAL), texto a voz (OpenAI), navegador en la nube (Browser Use), todo enrutado a través de tu suscripción. Sin cuentas adicionales.
+
+Un comando desde una instalación nueva:
+
+```bash
+hermes setup --portal
+```
+
+Esto te autentica vía OAuth, establece Nous como tu proveedor y activa el Tool Gateway. Comprueba qué está conectado en cualquier momento con `hermes portal info`. Detalles completos en la [página de documentación del Tool Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
+
+Puedes seguir usando tus propias claves por herramienta cuando quieras — el gateway es por backend, no todo o nada.
+
+---
+
+## Referencia rápida: CLI vs Mensajería
+
+Hermes tiene dos puntos de entrada: inicia la interfaz de terminal con `hermes`, o ejecuta el gateway y habla con él desde Telegram, Discord, Slack, WhatsApp, Signal o Email. Una vez en una conversación, muchos comandos de barra son compartidos entre ambas interfaces.
+
+| Acción | CLI | Plataformas de mensajería |
+| ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------- |
+| Empezar a chatear | `hermes` | Ejecuta `hermes gateway setup` + `hermes gateway start`, luego envía un mensaje al bot |
+| Nueva conversación | `/new` o `/reset` | `/new` o `/reset` |
+| Cambiar modelo | `/model [proveedor:modelo]` | `/model [proveedor:modelo]` |
+| Establecer personalidad | `/personality [nombre]` | `/personality [nombre]` |
+| Reintentar o deshacer último turno | `/retry`, `/undo` | `/retry`, `/undo` |
+| Comprimir contexto / ver uso | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
+| Explorar habilidades | `/skills` o `/` | `/` |
+| Interrumpir trabajo actual | `Ctrl+C` o enviar un nuevo mensaje | `/stop` o enviar un nuevo mensaje |
+| Estado específico de plataforma | `/platforms` | `/status`, `/sethome` |
+
+Para las listas de comandos completas, consulta la [guía de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) y la [guía del Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging).
+
+---
+
+## Documentación
+
+Toda la documentación está en **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
+
+| Sección | Contenido |
+| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
+| [Inicio rápido](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Instalar → configurar → primera conversación en 2 minutos |
+| [Uso de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Comandos, atajos de teclado, personalidades, sesiones |
+| [Configuración](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Archivo de configuración, proveedores, modelos, todas las opciones |
+| [Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
+| [Seguridad](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Aprobación de comandos, emparejamiento por DM, aislamiento en contenedor |
+| [Herramientas y Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | Más de 40 herramientas, sistema de toolsets, backends de terminal |
+| [Sistema de Habilidades](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Memoria procedimental, Skills Hub, creación de habilidades |
+| [Memoria](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Memoria persistente, perfiles de usuario, mejores prácticas |
+| [Integración MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Conecta cualquier servidor MCP para capacidades extendidas |
+| [Programación Cron](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Tareas programadas con entrega a plataforma |
+| [Archivos de Contexto](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Contexto de proyecto que da forma a cada conversación |
+| [Arquitectura](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Estructura del proyecto, bucle del agente, clases principales |
+| [Contribuir](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Configuración de desarrollo, proceso de PR, estilo de código |
+| [Referencia de CLI](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | Todos los comandos y flags |
+| [Variables de Entorno](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Referencia completa de variables de entorno |
+
+---
+
+## Migración desde OpenClaw
+
+Si vienes de OpenClaw, Hermes puede importar automáticamente tu configuración, memorias, habilidades y claves API.
+
+**Durante la configuración inicial:** El asistente de configuración (`hermes setup`) detecta automáticamente `~/.openclaw` y ofrece migrar antes de que comience la configuración.
+
+**En cualquier momento después de instalar:**
+
+```bash
+hermes claw migrate # Migración interactiva (preset completo)
+hermes claw migrate --dry-run # Vista previa de qué se migraría
+hermes claw migrate --preset user-data # Migrar sin secretos
+hermes claw migrate --overwrite # Sobreescribir conflictos existentes
+```
+
+Qué se importa:
+
+- **SOUL.md** — archivo de personalidad
+- **Memorias** — entradas de MEMORY.md y USER.md
+- **Habilidades** — habilidades creadas por el usuario → `~/.hermes/skills/openclaw-imports/`
+- **Lista de comandos permitidos** — patrones de aprobación
+- **Configuración de mensajería** — configuración de plataformas, usuarios permitidos, directorio de trabajo
+- **Claves API** — secretos en lista de permitidos (Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs)
+- **Assets de TTS** — archivos de audio del espacio de trabajo
+- **Instrucciones del espacio de trabajo** — AGENTS.md (con `--workspace-target`)
+
+Consulta `hermes claw migrate --help` para todas las opciones, o usa la habilidad `openclaw-migration` para una migración guiada interactiva por el agente con vistas previas de dry-run.
+
+---
+
+## Contribuir
+
+¡Las contribuciones son bienvenidas! Consulta la [Guía de Contribución](CONTRIBUTING.es.md) para la configuración del desarrollo, el estilo de código y el proceso de PR.
+
+Inicio rápido para colaboradores — clona y comienza con `setup-hermes.sh`:
+
+```bash
+git clone https://github.com/NousResearch/hermes-agent.git
+cd hermes-agent
+./setup-hermes.sh # instala uv, crea venv, instala .[all], enlaza ~/.local/bin/hermes
+./hermes # detecta automáticamente el venv, no necesitas hacer `source` primero
+```
+
+Ruta manual (equivalente a lo anterior):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv venv .venv --python 3.11
+source .venv/bin/activate
+uv pip install -e ".[all,dev]"
+scripts/run_tests.sh
+```
+
+---
+
+## Comunidad
+
+- 💬 [Discord](https://discord.gg/NousResearch)
+- 📚 [Skills Hub](https://agentskills.io)
+- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
+- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Servidor MCP de control de escritorio Linux para Hermes y otros hosts MCP, con árboles de accesibilidad AT-SPI, entrada Wayland/X11, capturas de pantalla y targeting de ventanas del compositor.
+- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Puente WeChat comunitario: Ejecuta Hermes Agent y OpenClaw en la misma cuenta de WeChat.
+
+---
+
+## Licencia
+
+MIT — ver [LICENSE](LICENSE).
+
+Creado por [Nous Research](https://nousresearch.com).
diff --git a/README.md b/README.md
index b7b1b6af9d..238c24dc99 100644
--- a/README.md
+++ b/README.md
@@ -42,11 +42,12 @@ That pairs the agent with the ClawPump MCP — remote `https://mcp.clawpump.tech
+
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
-Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NovitaAI](https://novita.ai) (AI-native cloud for Model API, Agent Sandbox, and GPU Cloud), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
+Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenRouter, OpenAI, your own endpoint, and [many others](https://hermes-agent.nousresearch.com/docs/integrations/providers). Switch with `hermes model` — no code changes, no lock-in.
A real terminal interface
Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.
@@ -93,6 +94,41 @@ source ~/.bashrc # reload shell (or: source ~/.zshrc)
hermes # start chatting!
```
+### Troubleshooting
+
+#### Windows Defender or antivirus flags `uv.exe` as malware
+
+If your antivirus (Bitdefender, Windows Defender, etc.) quarantines `uv.exe` from the Hermes `bin` folder (`%LOCALAPPDATA%\hermes\bin\uv.exe`), this is a **false positive**. The file is Astral's `uv` — the Rust Python package manager Hermes bundles to manage its Python environment. ML-based antivirus engines commonly flag unsigned Rust binaries that download and install packages.
+
+**To verify your copy is authentic:**
+
+```powershell
+# Install GitHub CLI if needed
+winget install --id GitHub.cli
+
+# Login to GitHub
+gh auth login
+
+# Run verification
+$uv = "$env:LOCALAPPDATA\hermes\bin\uv.exe"
+$ver = (& $uv --version).Split(' ')[1]
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$zip = "$env:TEMP\uv.zip"
+Invoke-WebRequest "https://github.com/astral-sh/uv/releases/download/$ver/uv-x86_64-pc-windows-msvc.zip" -OutFile $zip -UseBasicParsing
+gh attestation verify $zip --repo astral-sh/uv
+Expand-Archive $zip "$env:TEMP\uv_x" -Force
+(Get-FileHash "$env:TEMP\uv_x\uv.exe").Hash -eq (Get-FileHash $uv).Hash
+```
+
+If attestation says "Verification succeeded" and the last line prints `True`, you're good.
+
+**To whitelist Hermes:**
+- **Windows Defender:** Run PowerShell as Admin → `Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\hermes\bin"`
+- **Bitdefender:** Add an exception in the Bitdefender console (Protection > Antivirus > Settings > Manage Exceptions)
+- Whitelist the **folder**, not the file hash — Hermes updates `uv` and the hash changes every version
+
+For more context, see the upstream Astral reports: [astral-sh/uv#13553](https://github.com/astral-sh/uv/issues/13553), [astral-sh/uv#15011](https://github.com/astral-sh/uv/issues/15011), [astral-sh/uv#10079](https://github.com/astral-sh/uv/issues/10079).
+
---
## Getting Started
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2453739f91..5ebfe1a7c5 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -39,7 +39,11 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
> **Android / Termux:** 已测试的手动安装路径请参考 [Termux 指南](https://hermes-agent.nousresearch.com/docs/getting-started/termux)。在 Termux 上,Hermes 会安装精选的 `.[termux]` 扩展,因为完整的 `.[all]` 扩展会拉取 Android 不兼容的语音依赖。
>
-> **Windows:** 原生 Windows 不受支持。请安装 [WSL2](https://learn.microsoft.com/zh-cn/windows/wsl/install) 并运行上述命令。
+> **Windows:** 在 PowerShell 中运行:
+> ```powershell
+> iex (irm https://hermes-agent.nousresearch.com/install.ps1)
+> ```
+> 安装完成后,可能需要重启终端,然后运行 `hermes` 开始对话。
安装后:
diff --git a/SECURITY.es.md b/SECURITY.es.md
new file mode 100644
index 0000000000..30b43716eb
--- /dev/null
+++ b/SECURITY.es.md
@@ -0,0 +1,322 @@
+# Política de Seguridad de Hermes Agent
+
+Este documento describe el modelo de confianza de Hermes Agent, identifica el
+único límite de seguridad que el proyecto trata como estructural y define el
+alcance para los informes de vulnerabilidades.
+
+## 1. Reportar una Vulnerabilidad
+
+Reporta de forma privada a través de [GitHub Security Advisories](https://github.com/NousResearch/hermes-agent/security/advisories/new)
+o **security@nousresearch.com**. No abras issues públicos para
+vulnerabilidades de seguridad. **Hermes Agent no opera un programa de
+recompensas por errores.**
+
+Un informe útil incluye:
+
+- Una descripción concisa y evaluación de severidad.
+- El componente afectado, identificado por ruta de archivo y rango de líneas
+ (ej. `path/to/file.py:120-145`).
+- Detalles del entorno (`hermes version`, SHA del commit, SO, versión de Python).
+- Una reproducción contra `main` o el último release.
+- Una declaración de qué límite de confianza del §2 se cruza.
+
+Por favor lee el §2 y el §3 antes de enviar. Los informes que demuestren
+límites de una heurística en proceso que esta política no trate como un
+límite serán cerrados como fuera de alcance bajo el §3 — pero consulta el §3.2:
+siguen siendo bienvenidos como issues o pull requests regulares, simplemente no
+a través del canal de seguridad privado.
+
+---
+
+## 2. Modelo de Confianza
+
+Hermes Agent es un agente personal de un solo inquilino. Su postura es
+por capas, y las capas no tienen el mismo peso. Los reportadores y
+operadores deben razonar sobre ellas en los mismos términos.
+
+### 2.1 Definiciones
+
+- **Proceso del agente.** El intérprete Python que ejecuta Hermes Agent,
+ incluyendo cualquier módulo Python que haya cargado (habilidades, plugins,
+ manejadores de hooks).
+- **Backend de terminal.** Un objetivo de ejecución conectado para la
+ herramienta `terminal()`. El predeterminado ejecuta comandos directamente en el host.
+ Otros backends ejecutan comandos dentro de un contenedor, sandbox en la nube o
+ host remoto.
+- **Superficie de entrada.** Cualquier canal a través del cual el contenido entra en el
+ contexto del agente: entrada del operador, fetches web, email, mensajes del gateway,
+ lecturas de archivos, respuestas del servidor MCP, resultados de herramientas.
+- **Envolvente de confianza.** El conjunto de recursos a los que un operador ha otorgado
+ implícitamente acceso a Hermes Agent al ejecutarlo — típicamente, todo lo que
+ la propia cuenta de usuario del operador puede alcanzar en el host.
+- **Postura.** Una declaración explícita en la documentación o código de Hermes Agent
+ sobre cómo una capa consumidora (adaptador, UI, escritor de archivos,
+ shell) debe tratar la salida del agente — ej. "el dashboard renderiza
+ la salida del agente como HTML inerte."
+
+### 2.2 El Límite: Aislamiento a Nivel de SO
+
+**El único límite de seguridad contra un LLM adversario es el
+sistema operativo.** Nada dentro del proceso del agente constituye
+contención — ni la puerta de aprobación, ni la redacción de salida, ni ningún
+escáner de patrones, ni ninguna lista de herramientas permitidas. Cualquier componente dentro
+del proceso que filtre la salida del LLM es una heurística operando sobre una
+cadena influenciada por el atacante, y esta política lo trata como tal.
+
+Hermes Agent admite dos posturas de aislamiento a nivel de SO. Abordan
+diferentes amenazas y un operador debe elegir deliberadamente.
+
+#### Aislamiento del backend de terminal
+
+Un backend de terminal no predeterminado ejecuta comandos de shell emitidos por el LLM
+dentro de un contenedor, host remoto o sandbox en la nube. Las herramientas de archivos
+(`read_file`, `write_file`, `patch`) también se ejecutan a través de este backend,
+ya que están implementadas sobre el contrato del shell — no pueden
+alcanzar rutas que el backend no exponga.
+
+Lo que confina: todo lo que el agente hace emitiendo operaciones de shell o
+de archivos. Lo que **no** confina: todo lo que el agente hace en su propio
+proceso Python. Eso incluye la herramienta de ejecución de código (lanzada como
+subproceso del host), subprocesos MCP (lanzados desde el entorno del agente),
+carga de plugins, despacho de hooks y carga de habilidades (todos importados en el
+intérprete del agente).
+
+El aislamiento del backend de terminal es la postura correcta cuando la preocupación es
+que el LLM emita comandos de shell destructivos o escrituras de herramientas de archivo no deseadas, y el
+operador es de confianza.
+
+#### Envoltura del proceso completo
+
+La envoltura del proceso completo ejecuta todo el árbol de procesos del agente dentro de un
+sandbox. Cada ruta de código — shell, ejecución de código, MCP, herramientas de archivos,
+plugins, hooks, carga de habilidades — está sujeta a la misma política de sistema de archivos,
+red, proceso e (donde sea aplicable) inferencia.
+
+Hermes Agent admite esto de dos maneras:
+
+- **La propia imagen Docker de Hermes Agent y la configuración de Compose.** Más
+ liviana; el agente se ejecuta en un contenedor estándar con montajes y
+ política de red configurados por el operador.
+- **[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell)**.
+ OpenShell proporciona sandboxes por sesión con política declarativa
+ a través de capas de sistema de archivos, red (egreso L7), proceso/syscall e
+ enrutamiento de inferencia. Las políticas de red e inferencia son
+ recargables en caliente. Las credenciales se inyectan desde un almacén de Proveedor
+ y nunca tocan el sistema de archivos del sandbox.
+
+Bajo una envoltura de proceso completo, las heurísticas en proceso de Hermes Agent
+(§2.4) funcionan como prevención de accidentes en capas sobre un límite real.
+Esta es la postura soportada cuando el agente ingiere contenido de superficies
+que el operador no controla — la web abierta, email entrante, canales de
+múltiples usuarios, servidores MCP no confiables — y para despliegues en
+producción o compartidos.
+
+Los operadores que ejecuten el backend local predeterminado con superficies de entrada
+no confiables, o que ejecuten un sandbox de backend de terminal esperando que contenga
+rutas de código que no pasan por el shell, están operando fuera de la postura de
+seguridad soportada.
+
+### 2.3 Alcance de Credenciales
+
+Hermes Agent filtra el entorno que pasa a sus componentes en proceso de
+menor confianza: subprocesos de shell, subprocesos MCP y el proceso hijo
+de ejecución de código. Las credenciales como las claves API del proveedor y los
+tokens del gateway se eliminan por defecto; las variables declaradas explícitamente
+por el operador o por una habilidad cargada se pasan.
+
+Esto reduce la exfiltración casual. No es contención. Cualquier
+componente que se ejecute dentro del proceso del agente (habilidades, plugins, manejadores
+de hooks) puede leer lo que el agente mismo puede leer, incluidas las
+credenciales en memoria. La mitigación contra un componente en proceso comprometido
+es la revisión del operador antes de instalar (§2.4, §2.5), no el
+saneamiento del entorno.
+
+### 2.4 Heurísticas en Proceso
+
+Los siguientes componentes filtran o advierten sobre el comportamiento del LLM. Son
+útiles. No son límites.
+
+- La **puerta de aprobación** detecta patrones de shell destructivos comunes
+ y le pide al operador confirmación antes de la ejecución. El shell es Turing-
+ completo; una lista de denegación sobre cadenas de shell es estructuralmente
+ incompleta. La puerta detecta errores en modo cooperativo, no salidas
+ adversariales.
+- **La redacción de salida** elimina patrones similares a secretos de la visualización.
+ Un productor de salida motivado la evitará.
+- **Skills Guard** escanea el contenido de habilidades instalables en busca de patrones
+ de inyección. Es una ayuda de revisión; el límite para habilidades de terceros
+ es la revisión del operador antes de instalar. Revisar una habilidad significa
+ leer su código Python y scripts, no solo su descripción SKILL.md —
+ las habilidades ejecutan Python arbitrario en el momento de importación.
+
+### 2.5 Modelo de Confianza de Plugins
+
+Los plugins se cargan en el proceso del agente y se ejecutan con todos los privilegios
+del agente: pueden leer las mismas credenciales, llamar a las mismas
+herramientas, registrar los mismos hooks e importar los mismos módulos que
+cualquier cosa incluida en el árbol. El límite para los plugins de terceros es
+la revisión del operador antes de instalar — la misma regla que las habilidades (§2.4),
+mencionado por separado porque los plugins son arquitectónicamente más pesados
+y a menudo incluyen sus propios servicios en segundo plano, oyentes de red
+y dependencias.
+
+Un plugin malicioso o con errores no es una vulnerabilidad en Hermes Agent
+en sí mismo. Los errores en la ruta de instalación o descubrimiento de plugins de Hermes Agent
+que impidan al operador ver lo que está instalando están en alcance bajo el §3.1.
+
+### 2.6 Superficies Externas
+
+Una **superficie externa** es cualquier canal fuera del proceso del agente local
+a través del cual un llamador puede despachar trabajo del agente, resolver
+aprobaciones o recibir salida del agente. Cada superficie tiene su propio
+modelo de autorización, pero las reglas a continuación se aplican uniformemente.
+
+**Superficies en Hermes Agent:**
+
+- **Adaptadores de plataforma del gateway.** Integraciones de mensajería en
+ `gateway/platforms/` (Telegram, Discord, Slack, email, SMS, etc.)
+ y adaptadores análogos incluidos como plugins.
+- **Superficies HTTP expuestas en red.** El adaptador del servidor API, el
+ plugin del dashboard, los endpoints HTTP del plugin kanban, y cualquier
+ otro plugin que vincule un socket de escucha.
+- **Adaptadores de Editor / IDE.** El adaptador ACP (`acp_adapter/`) e
+ integraciones equivalentes que aceptan solicitudes de un proceso cliente local.
+- **El gateway TUI (`tui_gateway/`).** Backend JSON-RPC para la
+ UI de terminal Ink, alcanzado a través de IPC local.
+
+**Reglas uniformes:**
+
+1. **Se requiere autorización en cada superficie que cruce un límite de confianza.** Para
+ superficies de mensajería y HTTP en red, el límite es la red: la autorización
+ significa una lista de llamadores permitidos configurada por el operador. Para superficies
+ de editor e IPC local (ACP, gateway TUI), el límite es la cuenta de usuario del host:
+ la autorización significa depender del control de acceso a nivel de SO (permisos
+ de archivos, vinculaciones solo a loopback) y no exponer la superficie más allá
+ del usuario local sin una capa de autenticación de red explícita.
+2. **Se requiere una lista de permitidos para cada adaptador de red habilitado.**
+ Los adaptadores deben rechazar despachar trabajo del agente, resolver
+ aprobaciones o transmitir salida hasta que se establezca una lista de permitidos. Las rutas
+ de código que fallan de forma abierta cuando no hay lista de permitidos configurada son errores de código en
+ alcance bajo el §3.1.
+3. **Los identificadores de sesión son manejadores de enrutamiento, no límites de autorización.**
+ Conocer el ID de sesión de otro llamador no otorga acceso a sus aprobaciones o salida;
+ la autorización siempre se vuelve a verificar contra la lista de permitidos (o equivalente
+ a nivel de SO).
+4. **Dentro del conjunto autorizado, todos los llamadores tienen la misma confianza.**
+ Hermes Agent no modela capacidades por llamador dentro de un único adaptador.
+ Los operadores que necesiten separación de capacidades deben ejecutar instancias
+ de agente separadas con listas de permitidos separadas.
+5. **Vincular una superficie solo local a una interfaz no-loopback es una decisión de
+ operador de emergencia (§3.2).** El dashboard y otros servidores HTTP de plugins
+ son predeterminados a loopback; exponerlos a través de `--host 0.0.0.0` o equivalente
+ hace que el fortalecimiento de exposición pública (§4) sea responsabilidad del operador.
+
+---
+
+## 3. Alcance
+
+### 3.1 En Alcance
+
+- Escape de una postura de aislamiento a nivel de SO declarada (§2.2): una
+ ruta de código controlada por el atacante alcanzando estado que la postura
+ afirmó confinar.
+- Acceso no autorizado a superficie externa: un llamador fuera del conjunto de
+ autorización configurado (lista de permitidos, o equivalente a nivel de SO
+ para superficies de IPC local) despachando trabajo, recibiendo salida o
+ resolviendo aprobaciones (§2.6).
+- Exfiltración de credenciales: filtración de credenciales del operador o
+ material de autorización de sesión a un destino fuera del envolvente de
+ confianza, a través de un mecanismo que debería haberlo prevenido
+ (error de saneamiento de entorno, registro del adaptador, error de transporte
+ que vacía credenciales a un upstream, etc.).
+- Violaciones de la documentación del modelo de confianza: código que se comporta
+ contrariamente a lo que esta política, la propia documentación de Hermes Agent o
+ las expectativas razonables del operador predecirían — incluyendo casos donde
+ Hermes Agent ha documentado una postura sobre cómo su salida debe ser
+ renderizada por una capa consumidora (dashboard, adaptador de gateway,
+ escritor de archivos, shell) y una ruta de código rompe esa postura.
+
+### 3.2 Fuera de Alcance
+
+"Fuera de alcance" aquí significa "no es una vulnerabilidad de seguridad bajo esta
+política." No significa "no vale la pena reportarlo." Las mejoras a las
+heurísticas en proceso, ideas de fortalecimiento y correcciones de UX son bienvenidas como
+issues o pull requests regulares — la puerta de aprobación siempre puede detectar
+más patrones, la redacción puede volverse más inteligente, el comportamiento del adaptador
+puede apretarse siempre. Estos elementos simplemente no van a través del canal de
+divulgación privada y no reciben avisos.
+
+- **Bypasses de heurísticas en proceso (§2.4)** — bypasses de regex de la puerta de aprobación,
+ bypasses de redacción, bypasses de patrones de Skills Guard, e informes
+ análogos contra heurísticas futuras. Estos componentes no son límites;
+ vencerlos no es una vulnerabilidad bajo esta política.
+- **Inyección de prompts per se.** Hacer que el LLM emita salida inusual
+ — a través de contenido inyectado, alucinación, artefactos de entrenamiento,
+ o cualquier otra causa — no es en sí mismo una vulnerabilidad. "Logré
+ inyección de prompts" sin un resultado encadenado del §3.1 no es un informe
+ procesable bajo esta política.
+- **Consecuencias de una postura de aislamiento elegida.** Los informes de que
+ una ruta de código que opera dentro del alcance de su postura puede hacer lo que esa
+ postura permite no son vulnerabilidades. Ejemplos: herramientas de shell o archivos
+ que alcanzan estado del host bajo el backend local; subprocesos de ejecución de código
+ o MCP que alcanzan estado del host bajo aislamiento de backend de terminal que solo
+ sandboxea el shell; informes cuyas precondiciones requieren acceso de escritura preexistente
+ a archivos de configuración o credenciales propiedad del operador (esos ya están dentro
+ del envolvente de confianza).
+- **Configuraciones documentadas de emergencia.** Compensaciones seleccionadas por el operador
+ que deshabilitan explícitamente protecciones: `--insecure` y flags equivalentes
+ en el dashboard u otros componentes, aprobaciones deshabilitadas,
+ backend local en producción, perfiles de desarrollo que evitan
+ la seguridad de hermes-home, y similares. Los informes contra esas
+ configuraciones no son vulnerabilidades — eso es el trabajo del flag.
+- **Habilidades y plugins contribuidos por la comunidad.** Las habilidades de terceros
+ (incluyendo el repositorio de habilidades de la comunidad) y los plugins de terceros
+ están en la superficie de revisión del operador, no en la superficie de confianza de Hermes Agent
+ (§2.4, §2.5). Una habilidad o plugin que haga algo
+ malicioso es el modo de falla esperado de uno que no fue
+ revisado, no una vulnerabilidad en Hermes Agent. Los errores en la ruta de
+ instalación de habilidades o plugins de Hermes Agent que impidan al
+ operador ver lo que está instalando están en alcance bajo el §3.1.
+- **Exposición pública sin controles externos.** Exponer el
+ gateway o la API a la internet pública sin autenticación,
+ VPN o firewall.
+- **Restricciones de lectura/escritura a nivel de herramienta en una postura donde el shell está
+ permitido.** Si una ruta es alcanzable a través de la herramienta terminal, los informes
+ de que otras herramientas de archivos pueden alcanzarla no añaden nada.
+
+---
+
+## 4. Fortalecimiento del Despliegue
+
+La decisión de fortalecimiento más importante es hacer coincidir el aislamiento
+(§2.2) con la confianza del contenido que el agente ingerirá. Más allá de eso:
+
+- Ejecuta el agente como usuario no-root. La imagen de contenedor proporcionada
+ hace esto por defecto.
+- Mantén las credenciales en el archivo de credenciales del operador con permisos
+ estrictos, nunca en la configuración principal, nunca en control de versiones.
+ Bajo OpenShell, usa el almacén de Proveedores en lugar de un archivo de
+ credenciales en disco.
+- No expongas el gateway o la API a la internet pública sin
+ VPN, Tailscale o protección de firewall. Bajo OpenShell, usa la
+ capa de política de red para restringir el egreso.
+- Configura una lista de llamadores permitidos para cada adaptador de red expuesto
+ que habilites (§2.6).
+- Revisa las habilidades y plugins de terceros antes de instalar (§2.4,
+ §2.5). Para las habilidades, esto significa leer el Python y los scripts,
+ no solo SKILL.md. Los informes de Skills Guard y el registro de auditoría
+ de instalación son la superficie de revisión.
+- Hermes Agent incluye guardias de cadena de suministro para lanzamientos de servidores
+ MCP y para cambios de dependencias / paquetes incluidos en CI; consulta
+ `CONTRIBUTING.es.md` para más detalles.
+
+---
+
+## 5. Divulgación
+
+- **Ventana de divulgación coordinada:** 90 días desde el informe, o hasta que se
+ publique una corrección, lo que ocurra primero.
+- **Canal:** el hilo GHSA o correspondencia por email con
+ security@nousresearch.com.
+- **Crédito:** los reportadores reciben crédito en las notas de versión a menos que
+ se solicite anonimato.
diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py
index 9ce6281824..5048b70259 100644
--- a/acp_adapter/entry.py
+++ b/acp_adapter/entry.py
@@ -23,6 +23,11 @@
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
+else:
+ # Stop a ``utils/``/``proxy/``/``ui/`` package in the launch directory from
+ # shadowing Hermes's own modules — ``hermes acp`` can be started from any
+ # cwd, including a project that has same-named packages on its path.
+ hermes_bootstrap.harden_import_path()
import argparse
import asyncio
diff --git a/acp_adapter/session.py b/acp_adapter/session.py
index c124229bec..bbe34b0678 100644
--- a/acp_adapter/session.py
+++ b/acp_adapter/session.py
@@ -617,6 +617,10 @@ def _make_agent(
_register_task_cwd(session_id, cwd)
agent = AIAgent(**kwargs)
+ # Codex app-server sessions are spawned lazily on the first turn. Stamp
+ # the ACP workspace onto the agent so the Codex runtime starts from the
+ # editor/session cwd instead of the Hermes daemon's process cwd.
+ agent.session_cwd = cwd
# ACP stdio transport requires stdout to remain protocol-only JSON-RPC.
# Route any incidental human-readable agent output to stderr instead.
agent._print_fn = _acp_stderr_print
diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py
index b913e1043a..2958be0ce0 100644
--- a/acp_adapter/tools.py
+++ b/acp_adapter/tools.py
@@ -74,7 +74,7 @@
"kanban_create", "kanban_show", "kanban_comment", "kanban_complete",
"kanban_block", "kanban_link", "kanban_heartbeat",
"yb_query_group_info", "yb_query_group_members", "yb_search_sticker",
- "yb_send_dm", "yb_send_sticker", "mixture_of_agents",
+ "yb_send_dm", "yb_send_sticker",
}
diff --git a/agent/agent_init.py b/agent/agent_init.py
index 2d44324136..41f7cc11bb 100644
--- a/agent/agent_init.py
+++ b/agent/agent_init.py
@@ -50,7 +50,7 @@
from hermes_cli.config import cfg_get
from hermes_cli.timeouts import get_provider_request_timeout
from hermes_constants import get_hermes_home
-from utils import base_url_host_matches
+from utils import base_url_host_matches, is_truthy_value
# Use the same logger name as run_agent so tests patching ``run_agent.logger``
# capture our warnings. (run_agent.py also does
@@ -106,7 +106,12 @@ def _custom_provider_extra_body_for_agent(
base_url: str,
custom_providers: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
- if (provider or "").strip().lower() != "custom":
+ provider_norm = (provider or "").strip().lower()
+ if provider_norm == "custom":
+ provider_key_filter = ""
+ elif provider_norm.startswith("custom:"):
+ provider_key_filter = provider_norm.split(":", 1)[1].strip()
+ else:
return None
target_url = _normalized_custom_base_url(base_url)
@@ -117,6 +122,13 @@ def _custom_provider_extra_body_for_agent(
for entry in custom_providers or []:
if not isinstance(entry, dict):
continue
+ if provider_key_filter:
+ entry_keys = {
+ str(entry.get("provider_key", "") or "").strip().lower(),
+ str(entry.get("name", "") or "").strip().lower(),
+ }
+ if provider_key_filter not in entry_keys:
+ continue
if _normalized_custom_base_url(entry.get("base_url")) != target_url:
continue
extra_body = entry.get("extra_body")
@@ -265,7 +277,8 @@ def init_agent(
output_config.format instead of a trailing-assistant prefill.
platform (str): The interface platform the user is on (e.g. "cli", "telegram", "discord", "whatsapp").
Used to inject platform-specific formatting hints into the system prompt.
- skip_context_files (bool): If True, skip auto-injection of SOUL.md, AGENTS.md, and .cursorrules
+ skip_context_files (bool): If True, skip auto-injection of project context files
+ (SOUL.md, .hermes.md, AGENTS.md, CLAUDE.md, .cursorrules) from the cwd / HERMES_HOME
into the system prompt. Use this for batch processing and data generation to avoid
polluting trajectories with user-specific persona or project instructions.
load_soul_identity (bool): If True, still use ~/.hermes/SOUL.md as the primary
@@ -706,6 +719,55 @@ def init_agent(
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
+ elif agent.provider == "moa":
+ from agent.moa_loop import MoAClient
+ agent.api_mode = "chat_completions"
+
+ # Route reference-model outputs to the agent's tool_progress_callback so
+ # every surface that already consumes it (CLI spinner/scrollback, TUI,
+ # desktop, gateway) can show each reference's answer as a labelled block
+ # before the aggregator acts. The facade emits "moa.reference" and
+ # "moa.aggregating" events; we forward them through the same callback
+ # the tool lifecycle uses. Best-effort and cache-safe — these are
+ # display-only events, they never touch the message history.
+ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
+ cb = getattr(agent, "tool_progress_callback", None)
+ if cb is None:
+ return
+ try:
+ if event == "moa.reference":
+ label = str(kwargs.get("label") or "")
+ text = str(kwargs.get("text") or "")
+ idx = kwargs.get("index")
+ count = kwargs.get("count")
+ cb(
+ "moa.reference",
+ label,
+ text,
+ None,
+ moa_index=idx,
+ moa_count=count,
+ )
+ elif event == "moa.aggregating":
+ cb(
+ "moa.aggregating",
+ str(kwargs.get("aggregator") or ""),
+ None,
+ None,
+ moa_ref_count=kwargs.get("ref_count"),
+ )
+ except Exception:
+ pass
+
+ agent.client = MoAClient(
+ agent.model or "default",
+ reference_callback=_moa_reference_relay,
+ )
+ agent._client_kwargs = {}
+ agent.api_key = api_key or "moa-virtual-provider"
+ agent.base_url = "moa://local"
+ if not agent.quiet_mode:
+ print(f"🤖 AI Agent initialized with MoA preset: {agent.model}")
elif agent.api_mode == "bedrock_converse":
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
# Region is extracted from the base_url or defaults to us-east-1.
@@ -807,6 +869,8 @@ def init_agent(
# _custom_headers; older/mocked clients may expose
# _default_headers instead.
_routed_headers = getattr(_routed_client, "_custom_headers", None)
+ if not _routed_headers:
+ _routed_headers = getattr(_routed_client, "default_headers", None)
if not _routed_headers:
_routed_headers = getattr(_routed_client, "_default_headers", None)
if _routed_headers:
@@ -860,6 +924,8 @@ def init_agent(
if _provider_timeout is not None:
client_kwargs["timeout"] = _provider_timeout
_fb_headers = getattr(_fb_client, "_custom_headers", None)
+ if not _fb_headers:
+ _fb_headers = getattr(_fb_client, "default_headers", None)
if not _fb_headers:
_fb_headers = getattr(_fb_client, "_default_headers", None)
if _fb_headers:
@@ -1095,6 +1161,12 @@ def init_agent(
agent._parent_session_id = parent_session_id
agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
agent._session_db_created = False # DB row deferred to run_conversation()
+ # Most agents own their session row and should finalize it on close().
+ # Some temporary helper agents (manual compression / session-hygiene /
+ # background-review forks) rotate or share the session forward to a
+ # continuation row that must remain open after the helper is torn down;
+ # those callers explicitly set this flag to False.
+ agent._end_session_on_close = True
agent._session_init_model_config = {
"max_iterations": agent.max_iterations,
"reasoning_config": reasoning_config,
@@ -1235,6 +1307,12 @@ def init_agent(
_agent_section = {}
agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")
+ # Intent-ack continuation config: "auto" (default — codex_responses only,
+ # the historical gate), true (all api_modes), false (never), or a list of
+ # model-name substrings. Resolved against the active api_mode/model in the
+ # conversation loop's intent-ack block.
+ agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto")
+
# Universal task-completion guidance toggle. Default True. Surfaced
# as a separate flag from tool_use_enforcement because the guidance
# applies to ALL models, not just the model families enforcement
@@ -1339,6 +1417,14 @@ def init_agent(
compression_abort_on_summary_failure = str(
_compression_cfg.get("abort_on_summary_failure", False)
).lower() in {"true", "1", "yes"}
+ # In-place compaction: when True, compress_context() rewrites the message
+ # list + rebuilds the system prompt WITHOUT rotating the session id (no
+ # parent_session_id chain, no `name #N` renumber). See #38763 and
+ # agent/conversation_compression.py. Consumed by compress_context(), not the
+ # compressor, so it rides on the agent.
+ compression_in_place = is_truthy_value(
+ _compression_cfg.get("in_place"), default=False
+ )
# Read optional explicit context_length override for the auxiliary
# compression model. Custom endpoints often cannot report this via
@@ -1487,6 +1573,7 @@ def init_agent(
# 3. Check general plugin system (user-installed plugins)
# 4. Fall back to built-in ContextCompressor
_selected_engine = None
+ _copy_failed = False
_engine_name = "compressor" # default
try:
_ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
@@ -1504,15 +1591,35 @@ def init_agent(
# Try general plugin system as fallback
if _selected_engine is None:
+ _candidate = None
try:
from hermes_cli.plugins import get_plugin_context_engine
_candidate = get_plugin_context_engine()
- if _candidate and _candidate.name == _engine_name:
- _selected_engine = _candidate
except Exception:
- pass
+ _candidate = None
+ if _candidate is not None and _candidate.name == _engine_name:
+ # Deep-copy the shared plugin singleton so a child agent's
+ # update_model() can't mutate the parent's compressor (#42449).
+ # Copy can fail for engines holding uncopyable state (locks, DB
+ # connections, clients); in that case fall back to the built-in
+ # compressor with an ACCURATE message rather than silently
+ # mislabelling it "not found".
+ import copy
+ try:
+ _selected_engine = copy.deepcopy(_candidate)
+ except Exception as _copy_err:
+ _copy_failed = True
+ _ra().logger.warning(
+ "Context engine '%s' could not be safely copied for this "
+ "agent (%s) — falling back to built-in compressor. Plugin "
+ "engines that hold uncopyable state (locks, DB connections) "
+ "should implement __deepcopy__ to copy only mutable budget "
+ "state.",
+ _engine_name, _copy_err,
+ )
+ _selected_engine = None
- if _selected_engine is None:
+ if _selected_engine is None and not _copy_failed:
_ra().logger.warning(
"Context engine '%s' not found — falling back to built-in compressor",
_engine_name,
@@ -1556,8 +1663,10 @@ def init_agent(
provider=agent.provider,
api_mode=agent.api_mode,
abort_on_summary_failure=compression_abort_on_summary_failure,
+ max_tokens=agent.max_tokens,
)
agent.compression_enabled = compression_enabled
+ agent.compression_in_place = compression_in_place
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@@ -1567,8 +1676,10 @@ def init_agent(
f"Model {agent.model} has a context window of {_ctx:,} tokens, "
f"which is below the minimum {MINIMUM_CONTEXT_LENGTH:,} required "
f"by Hermes Agent. Choose a model with at least "
- f"{MINIMUM_CONTEXT_LENGTH // 1000}K context, or set "
- f"model.context_length in config.yaml to override."
+ f"{MINIMUM_CONTEXT_LENGTH // 1000}K context. If your server "
+ f"reports a window smaller than the model's true window, set "
+ f"model.context_length in config.yaml to the real value "
+ f"(this must be at least {MINIMUM_CONTEXT_LENGTH // 1000}K)."
)
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand).
@@ -1600,16 +1711,27 @@ def init_agent(
for t in agent.tools
if isinstance(t, dict)
}
- for _schema in agent.context_compressor.get_tool_schemas():
- _tname = _schema.get("name", "")
- if _tname and _tname in _existing_tool_names:
+ from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema
+ for _raw_schema in agent.context_compressor.get_tool_schemas():
+ _schema = _normalize_tool_schema(_raw_schema)
+ if _schema is None:
+ # A schema with no resolvable name (e.g. an already-wrapped
+ # entry) would append a nameless tool that strict providers
+ # 400 on, disabling the whole toolset (#47707). Skip it.
+ _ra().logger.warning(
+ "Context engine returned a tool schema with no resolvable "
+ "name; skipping to avoid poisoning the request (%r)",
+ _raw_schema,
+ )
+ continue
+ _tname = _schema["name"]
+ if _tname in _existing_tool_names:
continue # already registered via plugin/cache path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
- if _tname:
- agent.valid_tool_names.add(_tname)
- agent._context_engine_tool_names.add(_tname)
- _existing_tool_names.add(_tname)
+ agent.valid_tool_names.add(_tname)
+ agent._context_engine_tool_names.add(_tname)
+ _existing_tool_names.add(_tname)
# Notify context engine of session start
if hasattr(agent, "context_compressor") and agent.context_compressor:
diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py
index 4a267f9559..21a14c9770 100644
--- a/agent/agent_runtime_helpers.py
+++ b/agent/agent_runtime_helpers.py
@@ -42,6 +42,14 @@
logger = logging.getLogger(__name__)
+# Max consecutive successful credential-pool token refreshes of the SAME entry
+# on a persistent auth failure before we give up and let the fallback chain
+# activate. A single-entry OAuth pool can re-mint a fresh token indefinitely
+# even when the upstream keeps rejecting it, so without this cap the retry loop
+# spins forever and never reaches ``_try_activate_fallback``. See #26080.
+_MAX_AUTH_REFRESH_ATTEMPTS = 2
+
+
def _ra():
"""Lazy ``run_agent`` reference for test-patch routing."""
import run_agent
@@ -775,6 +783,30 @@ def recover_with_credential_pool(
return False, has_retried_429
refreshed = pool.try_refresh_current()
if refreshed is not None:
+ # ``try_refresh_current()`` re-mints a fresh OAuth token and reports
+ # success even when the upstream keeps rejecting it — a single-entry
+ # pool (common for OAuth/Max subscribers) has nothing to rotate to,
+ # so a bare "refreshed → retry" loop spins forever on the same dead
+ # token and the configured fallback never activates. Cap consecutive
+ # same-entry refreshes and fall through to fallback once exceeded.
+ # See #26080.
+ refreshed_id = getattr(refreshed, "id", None)
+ if refreshed_id is not None:
+ refresh_counts = getattr(agent, "_auth_pool_refresh_counts", None)
+ if refresh_counts is None:
+ refresh_counts = {}
+ agent._auth_pool_refresh_counts = refresh_counts
+ refresh_key = (agent.provider, refreshed_id)
+ refresh_counts[refresh_key] = refresh_counts.get(refresh_key, 0) + 1
+ if refresh_counts[refresh_key] > _MAX_AUTH_REFRESH_ATTEMPTS:
+ _ra().logger.warning(
+ "Credential auth failure persists after %s refreshes for "
+ "pool entry %s — treating as unrecoverable and allowing "
+ "fallback to activate.",
+ refresh_counts[refresh_key] - 1,
+ refreshed_id,
+ )
+ return False, has_retried_429
_ra().logger.info(f"Credential auth failure — refreshed pool entry {getattr(refreshed, 'id', '?')}")
agent._swap_credential(refreshed)
return True, has_retried_429
@@ -1046,10 +1078,43 @@ def restore_primary_runtime(agent) -> bool:
api_mode=rt.get("compressor_api_mode", ""),
)
+ # ── Re-select from the credential pool if one is available ──
+ # The snapshot's api_key was captured at construction time. Across
+ # turns the pool may have rotated (token revocation, billing/rate-limit
+ # exhaustion, cooldown), leaving the snapshot key stale. Restoring it
+ # blindly re-fails on the first request and burns through the remaining
+ # pool entries before cross-provider fallback even gets a chance. Ask
+ # the pool for its current best entry and swap the live credential in.
+ # When the pool is absent, empty, or the entry has no usable key, we
+ # keep the snapshot key (the existing behavior). Fixes #25205.
+ pool = getattr(agent, "_credential_pool", None)
+ if pool is not None and pool.has_available():
+ entry = pool.select()
+ if entry is not None:
+ entry_key = (
+ getattr(entry, "runtime_api_key", None)
+ or getattr(entry, "access_token", "")
+ )
+ if entry_key:
+ # ``_swap_credential`` rebuilds the OpenAI/Anthropic client,
+ # reapplies base-url-scoped headers, and carries the
+ # accumulated base_url / OAuth-detection fixes (#33163).
+ agent._swap_credential(entry)
+ logger.info(
+ "Restore re-selected pool entry %s (%s)",
+ getattr(entry, "id", "?"),
+ getattr(entry, "label", "?"),
+ )
+
# ── Reset fallback chain for the new turn ──
agent._fallback_activated = False
agent._fallback_index = 0
+ # 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
+ rewrite_prompt_model_identity(agent, rt["model"], rt["provider"])
+
logger.info(
"Primary runtime restored for new turn: %s (%s)",
agent.model, agent.provider,
@@ -1373,22 +1438,6 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
agent._client_log_context(),
)
return client
- if agent.provider == "google-gemini-cli" or str(client_kwargs.get("base_url", "")).startswith("cloudcode-pa://"):
- from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient
-
- # Strip OpenAI-specific kwargs the Gemini client doesn't accept
- safe_kwargs = {
- k: v for k, v in client_kwargs.items()
- if k in {"api_key", "base_url", "default_headers", "project_id", "timeout"}
- }
- client = GeminiCloudCodeClient(**safe_kwargs)
- _ra().logger.info(
- "Gemini Cloud Code Assist client created (%s, shared=%s) %s",
- reason,
- shared,
- agent._client_log_context(),
- )
- return client
if agent.provider == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
@@ -1431,6 +1480,15 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
keepalive_http = agent._build_keepalive_http_client(client_kwargs.get("base_url", ""))
if keepalive_http is not None:
client_kwargs["http_client"] = keepalive_http
+ # Delegate all rate-limit / 5xx retry to hermes's outer conversation loop,
+ # which honors Retry-After and applies adaptive/jittered backoff. The OpenAI
+ # SDK default (max_retries=2) uses its own 1-2s backoff that ignores
+ # Retry-After and double-retries inside our loop — the same deadlock the
+ # Anthropic clients hit (#26293). This is the single chokepoint every primary
+ # OpenAI/aggregator client passes through (init, switch_model, recovery,
+ # restore, request-scoped); auxiliary_client builds its own clients and keeps
+ # SDK retries because it is NOT wrapped by the conversation loop.
+ client_kwargs.setdefault("max_retries", 0)
# Uses the module-level `OpenAI` name, resolved lazily on first
# access via __getattr__ below. Tests patch via `run_agent.OpenAI`.
client = _ra().OpenAI(**client_kwargs)
@@ -1510,6 +1568,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
# live dict doesn't poison the rollback target.
_snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {})
+ # Snapshot the credential pool reference so a failed client rebuild can
+ # restore the original pool (issue #52727: pool reload is part of this
+ # switch and must be reversible on rollback).
+ _snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING)
try:
# Clear the per-config context_length override so the new model's
@@ -1534,8 +1596,36 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
if api_key:
agent.api_key = api_key
+ # ── Reload credential pool for the new provider (issue #52727) ──
+ # Without this, ``recover_with_credential_pool`` sees a
+ # ``pool.provider != agent.provider`` mismatch and short-circuits,
+ # leaving the new provider with no rotation/recovery on 401/429 and
+ # burning the original pool's entries. Only reload when the provider
+ # actually changed (or the pool was missing) — re-selecting the same
+ # provider must not churn the pool reference. A reload failure is
+ # logged + swallowed: the switch itself must still complete.
+ old_norm = (old_provider or "").strip().lower()
+ new_norm = (new_provider or "").strip().lower()
+ if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None:
+ try:
+ from agent.credential_pool import load_pool
+ agent._credential_pool = load_pool(new_provider)
+ except Exception as _pool_exc: # noqa: BLE001
+ logger.warning(
+ "switch_model: credential pool reload failed for %s (%s); "
+ "continuing without pool rotation this turn",
+ new_provider, _pool_exc,
+ )
+
# ── Build new client ──
- if api_mode == "anthropic_messages":
+ if (new_provider or "").strip().lower() == "moa":
+ from agent.moa_loop import MoAClient
+
+ agent.api_key = api_key or "moa-virtual-provider"
+ agent.base_url = "moa://local"
+ agent._client_kwargs = {}
+ agent.client = MoAClient(agent.model or "default")
+ elif api_mode == "anthropic_messages":
from agent.anthropic_adapter import (
build_anthropic_client,
resolve_anthropic_token,
@@ -1708,6 +1798,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
old_model, old_provider, new_model, new_provider,
)
+ # ── Persist billing route to session DB ──
+ # The agent's _session_db / session_id may not be set in all contexts
+ # (tests, bare agents without a session DB, etc.). This ensures the
+ # dashboard Model cards show the actual provider after a mid-session
+ # /model switch instead of the stale session-creation provider.
+ # See #48248 for the full bug description.
+ _session_db = getattr(agent, "_session_db", None)
+ _session_id = getattr(agent, "session_id", None)
+ if _session_db is not None and _session_id:
+ try:
+ _session_db.update_session_billing_route(
+ _session_id,
+ provider=agent.provider,
+ base_url=agent.base_url,
+ billing_mode=getattr(agent, "api_mode", None),
+ )
+ except Exception:
+ logger.warning(
+ "Failed to persist billing route after model switch",
+ exc_info=True,
+ )
def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,
@@ -1849,32 +1960,18 @@ def _execute(next_args: dict) -> Any:
operations=operations,
store=agent._memory_store,
)
- # Bridge: notify external memory provider of built-in memory writes.
- # Covers both the single-op shape and each add/replace inside a batch.
+ # Mirror successful built-in memory writes to external providers.
+ # All gating/op-expansion lives behind the manager interface
+ # (MemoryManager.notify_memory_tool_write).
if agent._memory_manager:
- if operations:
- _mem_ops = [
- op for op in operations
- if isinstance(op, dict) and op.get("action") in {"add", "replace"}
- ]
- else:
- _mem_ops = (
- [{"action": next_args.get("action"), "content": next_args.get("content")}]
- if next_args.get("action") in {"add", "replace"} else []
- )
- for _op in _mem_ops:
- try:
- agent._memory_manager.on_memory_write(
- _op.get("action", ""),
- target,
- _op.get("content", "") or "",
- metadata=agent._build_memory_write_metadata(
- task_id=effective_task_id,
- tool_call_id=tool_call_id,
- ),
- )
- except Exception:
- pass
+ agent._memory_manager.notify_memory_tool_write(
+ result,
+ next_args,
+ build_metadata=lambda: agent._build_memory_write_metadata(
+ task_id=effective_task_id,
+ tool_call_id=tool_call_id,
+ ),
+ )
return _finish_agent_tool(result, next_args)
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
def _execute(next_args: dict) -> Any:
@@ -2108,8 +2205,21 @@ def looks_like_codex_intermediate_ack(
user_message: str,
assistant_content: str,
messages: List[Dict[str, Any]],
+ require_workspace: bool = True,
) -> bool:
- """Detect a planning/ack message that should continue instead of ending the turn."""
+ """Detect a planning/ack message that should continue instead of ending the turn.
+
+ ``require_workspace`` (default True) keeps the original codex-coding scope:
+ the ack must reference a filesystem/repo workspace. The conversation loop
+ passes ``require_workspace=False`` when the user has explicitly opted into
+ intent-ack continuation for all api_modes (``agent.intent_ack_continuation``
+ is ``true`` or a model-list), so general autonomous workflows ("I'll run a
+ health check on the server", "I'll start the deployment") — which carry a
+ future-ack and an action verb but no filesystem reference — are caught too.
+ The future-ack + short-content + no-prior-tools + action-verb requirements
+ always apply, which is what keeps conversational "I'll help you brainstorm"
+ replies from tripping it.
+ """
if any(isinstance(msg, dict) and msg.get("role") == "tool" for msg in messages):
return False
@@ -2162,17 +2272,67 @@ def looks_like_codex_intermediate_ack(
"path",
)
+ assistant_mentions_action = any(marker in assistant_text for marker in action_markers)
+ if not assistant_mentions_action:
+ return False
+
+ # Opted-in (all-api_mode) path: a future-ack + action verb + no prior tool
+ # call is enough — the user asked us to keep going when the model only
+ # announces intent, regardless of whether a filesystem is involved.
+ if not require_workspace:
+ return True
+
user_text = (user_message or "").strip().lower()
user_targets_workspace = (
any(marker in user_text for marker in workspace_markers)
or "~/" in user_text
or "/" in user_text
)
- assistant_mentions_action = any(marker in assistant_text for marker in action_markers)
assistant_targets_workspace = any(
marker in assistant_text for marker in workspace_markers
)
- return (user_targets_workspace or assistant_targets_workspace) and assistant_mentions_action
+ return user_targets_workspace or assistant_targets_workspace
+
+
+def intent_ack_continuation_mode(agent) -> str:
+ """Classify the resolved intent-ack continuation mode for this turn.
+
+ Returns one of:
+ * ``"off"`` — never continue.
+ * ``"codex_only"`` — historical scope: continue only on the
+ ``codex_responses`` api_mode, and only for codebase/workspace acks
+ (``require_workspace=True``).
+ * ``"all"`` — user opted in for every api_mode; continue on any
+ future-ack + action verb (``require_workspace=False``).
+
+ Mirrors the four-mode shape of ``agent.tool_use_enforcement``: ``"auto"``
+ (default) → codex_only; ``True``/"true"/"always"/"yes"/"on" → all;
+ ``False``/"false"/"never"/"no"/"off" → off; ``list`` → all when a substring
+ matches the active model name, else off.
+ """
+ mode = getattr(agent, "_intent_ack_continuation", "auto")
+
+ if mode is True or (isinstance(mode, str) and mode.lower() in {"true", "always", "yes", "on"}):
+ return "all"
+ if mode is False or (isinstance(mode, str) and mode.lower() in {"false", "never", "no", "off"}):
+ return "off"
+ if isinstance(mode, list):
+ model_lower = (agent.model or "").lower()
+ return "all" if any(p.lower() in model_lower for p in mode if isinstance(p, str)) else "off"
+ # "auto" or any unrecognised value — historical codex-only behavior.
+ return "codex_only" if agent.api_mode == "codex_responses" else "off"
+
+
+def intent_ack_continuation_enabled(agent) -> bool:
+ """Whether intent-ack continuation should fire at all for this turn.
+
+ The ``codex_ack_continuations < 2`` per-turn cap and the
+ ``looks_like_codex_intermediate_ack`` detector are applied by the caller;
+ this only decides the on/off gate. Callers that also need to know whether
+ the workspace requirement applies should use ``intent_ack_continuation_mode``
+ directly (``"codex_only"`` ⇒ require_workspace=True, ``"all"`` ⇒ False).
+ """
+ return intent_ack_continuation_mode(agent) != "off"
@@ -2182,25 +2342,36 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
if source_msg.get("role") != "assistant":
return
- # 1. Explicit reasoning_content already set — preserve it verbatim
- # (includes DeepSeek/Kimi's own space-placeholder written at creation
- # time, and any valid reasoning content from the same provider).
+ needs_thinking_pad = agent._needs_thinking_reasoning_pad()
+
+ # 1. Explicit reasoning_content already set.
+ #
+ # When the active provider enforces the thinking-mode echo-back
+ # (DeepSeek / Kimi / MiMo), preserve it verbatim — that includes their
+ # own space-placeholder written at creation time and any valid reasoning
+ # from the same provider. Sessions persisted BEFORE #17341 have
+ # empty-string placeholders pinned at creation time; DeepSeek V4 Pro
+ # rejects those with HTTP 400, so upgrade "" → " " on replay.
#
- # Exception: sessions persisted BEFORE #17341 have empty-string
- # placeholders pinned at creation time. DeepSeek V4 Pro rejects
- # those with HTTP 400. When the active provider enforces the
- # thinking-mode echo, upgrade "" → " " on replay so stale history
- # doesn't 400 the user on the next turn.
+ # When the active provider does NOT enforce echo-back, strip the field
+ # entirely. Strict OpenAI-compatible providers (Mistral, Cerebras, Groq,
+ # SambaNova, …) reject ANY reasoning_content key in input messages with
+ # HTTP 400/422 ("Extra inputs are not permitted"), even an empty string
+ # or a single-space pad. This is the cross-provider fallback case: a
+ # reasoning primary (DeepSeek/Kimi/MiMo) pads history with " ", then a
+ # fallback to a strict provider replays that pad and 422s. Stripping
+ # here covers the rebuild path; reapply_reasoning_echo_for_provider()
+ # covers the already-built api_messages path. Refs #45655.
existing = source_msg.get("reasoning_content")
if isinstance(existing, str):
- if existing == "" and agent._needs_thinking_reasoning_pad():
+ if not needs_thinking_pad:
+ api_msg.pop("reasoning_content", None)
+ elif existing == "":
api_msg["reasoning_content"] = " "
else:
api_msg["reasoning_content"] = existing
return
- needs_thinking_pad = agent._needs_thinking_reasoning_pad()
-
# 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,
# if the source turn has tool_calls AND a 'reasoning' field but no
# 'reasoning_content' key, the 'reasoning' text was written by a
@@ -2226,9 +2397,13 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
# for providers that use the internal 'reasoning' key.
# This must happen before the unconditional empty-string fallback so
# genuine reasoning content is not overwritten (#15812 regression in
- # PR #15478).
+ # PR #15478). Only promote for providers that enforce echo-back —
+ # strict providers reject the field (refs #45655).
if isinstance(normalized_reasoning, str) and normalized_reasoning:
- api_msg["reasoning_content"] = normalized_reasoning
+ if needs_thinking_pad:
+ api_msg["reasoning_content"] = normalized_reasoning
+ else:
+ api_msg.pop("reasoning_content", None)
return
# 4. DeepSeek / Kimi thinking mode: all assistant messages need
@@ -2249,34 +2424,53 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
- """Re-pad assistant turns with reasoning_content for the active provider.
+ """Re-pad (or strip) assistant turns' reasoning_content for the active provider.
``api_messages`` is built once, before the retry loop, while the *primary*
- provider is active. If a mid-conversation fallback then switches to a
- require-side provider (DeepSeek / Kimi / MiMo thinking mode), assistant
- turns that were built when the prior provider did NOT need the echo-back go
- out without ``reasoning_content`` and the new provider rejects them with
- HTTP 400 ("The reasoning_content in the thinking mode must be passed back").
-
- Calling this immediately before building the request kwargs re-applies the
- pad against the *current* provider. It is idempotent and a no-op unless
- ``_needs_thinking_reasoning_pad()`` is True for the active provider, so it
- is safe to call every iteration and covers every fallback path.
-
- Returns the number of assistant turns that gained reasoning_content.
+ provider is active. A mid-conversation fallback can then switch providers,
+ so the reasoning fields baked into ``api_messages`` are shaped for the
+ *prior* provider and must be reconciled against the *current* one:
+
+ * Switching TO a require-side provider (DeepSeek / Kimi / MiMo thinking
+ mode): assistant turns built when the prior provider did NOT need the
+ echo-back go out without ``reasoning_content`` and the new provider
+ rejects them with HTTP 400 ("The reasoning_content in the thinking mode
+ must be passed back"). Re-apply the pad.
+
+ * Switching TO a strict provider that rejects the field (Mistral,
+ Cerebras, Groq, SambaNova, …): assistant turns built under a reasoning
+ primary carry a ``reasoning_content`` pad (often a single space ``" "``),
+ and the strict provider rejects it with HTTP 400/422 ("Extra inputs are
+ not permitted"). Strip the field. This is the exact cross-provider
+ fallback bug from #45655 — a DeepSeek primary pads history with ``" "``,
+ the request falls back to Mistral, and Mistral 422s on the stale pad.
+
+ Calling this immediately before building the request kwargs reconciles the
+ fields against the *current* provider. It is idempotent and safe to call
+ every iteration; it covers every fallback path.
+
+ Returns the number of assistant turns whose reasoning_content was added or
+ removed.
"""
- if not agent._needs_thinking_reasoning_pad():
- return 0
- padded = 0
+ needs_pad = agent._needs_thinking_reasoning_pad()
+ changed = 0
for api_msg in api_messages:
if api_msg.get("role") != "assistant":
continue
- if api_msg.get("reasoning_content"):
- continue
- copy_reasoning_content_for_api(agent, api_msg, api_msg)
- if api_msg.get("reasoning_content"):
- padded += 1
- return padded
+ if needs_pad:
+ if api_msg.get("reasoning_content"):
+ continue
+ copy_reasoning_content_for_api(agent, api_msg, api_msg)
+ if api_msg.get("reasoning_content"):
+ changed += 1
+ else:
+ # Strict provider — strip any stale reasoning_content pad left
+ # over from a reasoning primary so the fallback request doesn't
+ # 400/422 on it.
+ if "reasoning_content" in api_msg:
+ api_msg.pop("reasoning_content", None)
+ changed += 1
+ return changed
def _iter_pool_sockets(client: Any):
diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py
index 03e8b58e16..e4d1d5ac12 100644
--- a/agent/anthropic_adapter.py
+++ b/agent/anthropic_adapter.py
@@ -673,6 +673,9 @@ def _build_anthropic_client_with_bearer_hook(
kwargs = {
"timeout": timeout_obj,
"http_client": http_client,
+ # Delegate retry to hermes's outer loop (honors Retry-After); the SDK
+ # default max_retries=2 ignores it and double-retries. (#26293)
+ "max_retries": 0,
# The SDK requires *something* for api_key/auth_token. Our
# event hook overrides Authorization per request so this value
# is never sent. The sentinel string makes accidental leaks
@@ -757,6 +760,12 @@ def build_anthropic_client(
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
kwargs = {
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
+ # Delegate all rate-limit / 5xx retry to hermes's outer conversation
+ # loop, which honors Retry-After. The SDK default (max_retries=2) uses
+ # its own 1-2s backoff that ignores Retry-After and double-retries
+ # inside our loop — burning request slots against a bucket that won't
+ # refill for minutes. (#26293)
+ "max_retries": 0,
}
if normalized_base_url:
# Azure Anthropic endpoints require an ``api-version`` query parameter.
@@ -852,6 +861,9 @@ def build_anthropic_bedrock_client(region: str):
return _anthropic_sdk.AnthropicBedrock(
aws_region=region,
timeout=Timeout(timeout=900.0, connect=10.0),
+ # Delegate retry to hermes's outer loop (honors Retry-After); the SDK
+ # default max_retries=2 ignores it and double-retries. (#26293)
+ max_retries=0,
default_headers={"anthropic-beta": ",".join([*_COMMON_BETAS, _CONTEXT_1M_BETA])},
)
@@ -914,44 +926,72 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
return None
+def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
+ """Read Claude Code OAuth credentials from ~/.claude/.credentials.json.
+
+ Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
+ """
+ cred_path = Path.home() / ".claude" / ".credentials.json"
+ if not cred_path.exists():
+ return None
+ try:
+ data = json.loads(cred_path.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError, IOError) as e:
+ logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
+ return None
+
+ oauth_data = data.get("claudeAiOauth")
+ if not (oauth_data and isinstance(oauth_data, dict)):
+ return None
+ access_token = oauth_data.get("accessToken", "")
+ if not access_token:
+ return None
+ return {
+ "accessToken": access_token,
+ "refreshToken": oauth_data.get("refreshToken", ""),
+ "expiresAt": oauth_data.get("expiresAt", 0),
+ "source": "claude_code_credentials_file",
+ }
+
+
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
"""Read refreshable Claude Code OAuth credentials.
- Checks two sources in order:
+ Reads from two possible sources and reconciles them:
1. macOS Keychain (Darwin only) — "Claude Code-credentials" entry
2. ~/.claude/.credentials.json file
+ Selection rules when both are present:
+ - If exactly one is non-expired, prefer that one. (Handles the case
+ where Claude Code refreshes one source but not the other — observed
+ in the wild on Claude Code 2.1.x.)
+ - Otherwise, prefer the source with the later ``expiresAt`` so that
+ any subsequent refresh uses the most recent ``refreshToken``.
+
This intentionally excludes ~/.claude.json primaryApiKey. Opencode's
subscription flow is OAuth/setup-token based with refreshable credentials,
and native direct Anthropic provider usage should follow that path rather
than auto-detecting Claude's first-party managed key.
- Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
+ Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
"""
- # Try macOS Keychain first (covers Claude Code >=2.1.114)
kc_creds = _read_claude_code_credentials_from_keychain()
- if kc_creds:
- return kc_creds
+ file_creds = _read_claude_code_credentials_from_file()
- # Fall back to JSON file
- cred_path = Path.home() / ".claude" / ".credentials.json"
- if cred_path.exists():
- try:
- data = json.loads(cred_path.read_text(encoding="utf-8"))
- oauth_data = data.get("claudeAiOauth")
- if oauth_data and isinstance(oauth_data, dict):
- access_token = oauth_data.get("accessToken", "")
- if access_token:
- return {
- "accessToken": access_token,
- "refreshToken": oauth_data.get("refreshToken", ""),
- "expiresAt": oauth_data.get("expiresAt", 0),
- "source": "claude_code_credentials_file",
- }
- except (json.JSONDecodeError, OSError, IOError) as e:
- logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
+ if kc_creds and file_creds:
+ kc_valid = is_claude_code_token_valid(kc_creds)
+ file_valid = is_claude_code_token_valid(file_creds)
+ if kc_valid and not file_valid:
+ return kc_creds
+ if file_valid and not kc_valid:
+ return file_creds
+ # Both valid or both expired: prefer the later expiresAt so the
+ # downstream refresh path uses the freshest refresh_token.
+ kc_exp = kc_creds.get("expiresAt", 0) or 0
+ file_exp = file_creds.get("expiresAt", 0) or 0
+ return kc_creds if kc_exp >= file_exp else file_creds
- return None
+ return kc_creds or file_creds
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
@@ -1034,8 +1074,40 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
- """Attempt to refresh an expired Claude Code OAuth token."""
- refresh_token = creds.get("refreshToken", "")
+ """Attempt to refresh an expired Claude Code OAuth token.
+
+ Claude Code's OAuth refresh tokens are single-use: a successful refresh
+ rotates the pair and invalidates the old refresh token. Claude Code itself
+ also refreshes on its own schedule (IDE/CLI activity), so by the time
+ Hermes notices an expired token, Claude Code may have already rotated it.
+ POSTing our now-stale refresh token in that window races Claude Code and
+ fails with ``invalid_grant``.
+
+ So before refreshing, re-read the live credential sources. If Claude Code
+ has already produced a valid token, adopt it and skip the POST entirely.
+ Only fall back to refreshing ourselves when no fresh credential is found.
+ """
+ # Claude Code may have already refreshed — adopt its token rather than
+ # racing it with our (possibly already-rotated) refresh token. Only adopt
+ # when the live re-read produced a DIFFERENT token with a real future
+ # expiry: re-adopting the same credential we were just handed would be a
+ # no-op, and a 0/absent ``expiresAt`` means "managed key / unknown expiry"
+ # (see is_claude_code_token_valid) which must NOT be treated as a fresh
+ # refresh here.
+ current = read_claude_code_credentials()
+ if current:
+ current_token = current.get("accessToken", "")
+ current_exp = current.get("expiresAt", 0) or 0
+ if (
+ current_token
+ and current_token != creds.get("accessToken", "")
+ and current_exp > 0
+ and is_claude_code_token_valid(current)
+ ):
+ logger.debug("Adopted Claude Code's already-refreshed OAuth token")
+ return current_token
+
+ refresh_token = (current or {}).get("refreshToken", "") or creds.get("refreshToken", "")
if not refresh_token:
logger.debug("No refresh token available — cannot refresh")
return None
@@ -1159,6 +1231,46 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s
return None
+def _resolve_anthropic_pool_token() -> Optional[str]:
+ """Return the first available Anthropic OAuth token from credential_pool.
+
+ Read-only: enumerates with ``clear_expired=False, refresh=False`` so a bare
+ token *resolve* (which runs from diagnostic/read-only call sites such as
+ ``account_usage`` and ``hermes models``) never mutates ``~/.hermes/auth.json``
+ or makes a network refresh call. Refresh-on-expiry is owned by the API call
+ path's pool recovery, not the resolver.
+ """
+ try:
+ from agent.credential_pool import AUTH_TYPE_OAUTH, load_pool
+ except Exception:
+ return None
+
+ try:
+ pool = load_pool("anthropic")
+ # Enumerate read-only (clear_expired=False, refresh=False): never persist
+ # to auth.json or trigger a network refresh from a bare resolve. select()
+ # is deliberately NOT used — it runs clear_expired=True, refresh=True,
+ # which would violate this read-only contract.
+ entries = pool._available_entries(clear_expired=False, refresh=False)
+ except Exception:
+ logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
+ return None
+
+ for entry in entries:
+ if getattr(entry, "auth_type", None) != AUTH_TYPE_OAUTH:
+ continue
+ # access_token is a declared field but a persisted entry can carry an
+ # explicit null (or a partially-written OAuth entry), so coerce before
+ # strip — a bare None.strip() here would escape the try/excepts above
+ # and crash the whole resolver, taking down the source #5 fallback too.
+ # Matches the aux-client analog (auxiliary_client.py: str(key or "")).
+ token = (getattr(entry, "access_token", None) or "").strip()
+ if token:
+ return token
+
+ return None
+
+
def resolve_anthropic_token() -> Optional[str]:
"""Resolve an Anthropic token from all available sources.
@@ -1167,7 +1279,8 @@ def resolve_anthropic_token() -> Optional[str]:
2. CLAUDE_CODE_OAUTH_TOKEN env var
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
— with automatic refresh if expired and a refresh token is available
- 4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
+ 4. Anthropic credential_pool OAuth entry (~/.hermes/auth.json)
+ 5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
Returns the token string or None.
"""
@@ -1194,7 +1307,12 @@ def resolve_anthropic_token() -> Optional[str]:
if resolved_claude_token:
return resolved_claude_token
- # 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
+ # 4. Hermes credential_pool OAuth entry.
+ resolved_pool_token = _resolve_anthropic_pool_token()
+ if resolved_pool_token:
+ return resolved_pool_token
+
+ # 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
# This remains as a compatibility fallback for pre-migration Hermes configs.
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
if api_key:
@@ -1251,7 +1369,15 @@ def run_oauth_setup_token() -> Optional[str]:
# Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
-_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
+# Anthropic migrated the OAuth token endpoint to platform.claude.com;
+# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS
+# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary
+# for backward compatibility with existing imports and now points at the live host.
+_OAUTH_TOKEN_URLS = [
+ "https://platform.claude.com/v1/oauth/token",
+ "https://console.anthropic.com/v1/oauth/token",
+]
+_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
_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"
@@ -1349,18 +1475,34 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
"code_verifier": verifier,
}).encode()
- req = urllib.request.Request(
- _OAUTH_TOKEN_URL,
- data=exchange_data,
- headers={
- "Content-Type": "application/json",
- "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
- },
- method="POST",
- )
+ # 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).
+ result = None
+ last_error = None
+ for endpoint in _OAUTH_TOKEN_URLS:
+ req = urllib.request.Request(
+ endpoint,
+ data=exchange_data,
+ headers={
+ "Content-Type": "application/json",
+ "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
+ },
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ result = json.loads(resp.read().decode())
+ break
+ except Exception as exc:
+ last_error = exc
+ logger.debug("Anthropic token exchange failed at %s: %s", endpoint, exc)
+ continue
- with urllib.request.urlopen(req, timeout=15) as resp:
- result = json.loads(resp.read().decode())
+ if result is None:
+ raise last_error if last_error is not None else ValueError(
+ "Anthropic token exchange failed"
+ )
except Exception as e:
print(f"Token exchange failed: {e}")
return None
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index f28b5f6015..dfeec87e12 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -40,6 +40,7 @@
their OpenRouter balance but has Codex OAuth or another provider available.
"""
+import contextlib
import json
import logging
import os
@@ -100,13 +101,65 @@ def __repr__(self):
OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
+from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
+from agent.process_bootstrap import build_keepalive_http_client
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
-from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
+from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
logger = logging.getLogger(__name__)
+def _openai_http_client_kwargs(
+ base_url: Optional[str],
+ *,
+ async_mode: bool = False,
+) -> Dict[str, Any]:
+ """Inject keepalive httpx client with env-only proxy (not macOS system proxy)."""
+ client = build_keepalive_http_client(str(base_url or ""), async_mode=async_mode)
+ if client is None:
+ return {}
+ return {"http_client": client}
+
+
+def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
+ kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
+ return OpenAI(api_key=api_key, base_url=base_url, **kwargs)
+
+
+# ── Interrupt protection for atomic auxiliary tasks ──────────────────────
+# Some auxiliary tasks must NOT be aborted mid-flight by a gateway interrupt
+# (e.g. an incoming user message while the agent is busy). Context
+# compression is the prime case: if the summary LLM call is interrupted
+# part-way, compression falls back to a static "summary unavailable" marker
+# and the real handoff is lost (#23975). A thread-local flag lets such a
+# task mark its in-flight LLM call as interrupt-protected; the Codex
+# Responses stream's cancellation check honors it. TIMEOUTS still fire
+# (a hung call must die), and all OTHER aux tasks (vision, web_extract,
+# title_generation, …) remain freely interruptible.
+_aux_interrupt_protection = threading.local()
+
+
+def _aux_interrupt_protected() -> bool:
+ return bool(getattr(_aux_interrupt_protection, "active", False))
+
+
+@contextlib.contextmanager
+def aux_interrupt_protection(active: bool = True):
+ """Mark the current thread's auxiliary LLM call as interrupt-protected.
+
+ Used by atomic aux tasks (compression) so a mid-flight gateway interrupt
+ doesn't abort the call and trigger a degraded fallback. Re-entrant-safe:
+ restores the previous value on exit.
+ """
+ prev = getattr(_aux_interrupt_protection, "active", False)
+ _aux_interrupt_protection.active = active
+ try:
+ yield
+ finally:
+ _aux_interrupt_protection.active = prev
+
+
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
"""Return False instead of raising when a patched symbol is not a type."""
try:
@@ -631,6 +684,35 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
return str(url or "").strip().rstrip("/")
+# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to
+# be pointed at via config.yaml model.base_url. Anything else falls back to the
+# Anthropic default — operators routing main-session traffic through a
+# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config
+# must NOT have that foreign host leak into the auxiliary client. See #52608.
+_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
+ "api.anthropic.com",
+})
+
+
+def _is_anthropic_compatible_host(url: str) -> bool:
+ """Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
+ if not url:
+ return False
+ try:
+ from urllib.parse import urlparse
+ host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
+ return host in _ANTHROPIC_COMPATIBLE_HOSTS
+ except Exception:
+ return False
+
+
+def _nous_min_key_ttl_seconds() -> int:
+ try:
+ return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
+ except (TypeError, ValueError):
+ return 1800
+
+
# ── Codex Responses → chat.completions adapter ─────────────────────────────
# All auxiliary consumers call client.chat.completions.create(**kwargs) and
# read response.choices[0].message.content. This adapter translates those
@@ -805,7 +887,11 @@ def _check_cancelled() -> None:
raise TimeoutError(_timeout_message())
try:
from tools.interrupt import is_interrupted
- if is_interrupted():
+ # Honor interrupt protection for atomic aux tasks (compression):
+ # a mid-flight gateway interrupt must NOT abort the summary call
+ # and trigger a degraded fallback marker (#23975). Timeouts above
+ # still fire; other aux tasks remain interruptible.
+ if is_interrupted() and not _aux_interrupt_protected():
raise InterruptedError("Codex auxiliary Responses stream interrupted")
except InterruptedError:
raise
@@ -1300,6 +1386,57 @@ def _nous_base_url() -> str:
return os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL)
+def _resolve_nous_pool_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[str, str]]:
+ """Resolve Nous auxiliary credentials from the selected pool entry."""
+ try:
+ from hermes_cli.auth import _agent_key_is_usable
+
+ pool = load_pool("nous")
+ except Exception as exc:
+ logger.debug("Auxiliary Nous pool credential resolution failed: %s", exc)
+ return None
+
+ if not pool or not pool.has_credentials():
+ return None
+
+ try:
+ entry = pool.select()
+ except Exception as exc:
+ logger.debug("Auxiliary Nous pool selection failed: %s", exc)
+ return None
+
+ if entry is None:
+ return None
+
+ state = {
+ "agent_key": getattr(entry, "agent_key", None),
+ "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
+ "scope": getattr(entry, "scope", None),
+ }
+ if force_refresh or not _agent_key_is_usable(state, _nous_min_key_ttl_seconds()):
+ try:
+ refreshed = pool.try_refresh_current()
+ except Exception as exc:
+ logger.debug("Auxiliary Nous pool refresh failed: %s", exc)
+ refreshed = None
+ if refreshed is None:
+ return None
+ entry = refreshed
+
+ provider = {
+ "agent_key": getattr(entry, "agent_key", None),
+ "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
+ "access_token": getattr(entry, "access_token", None),
+ "expires_at": getattr(entry, "expires_at", None),
+ "scope": getattr(entry, "scope", None),
+ }
+ api_key = _nous_api_key(provider)
+ base_url = _pool_runtime_base_url(entry, _NOUS_DEFAULT_BASE_URL)
+ if not api_key or not base_url:
+ return None
+ return api_key, base_url
+
+
def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[str, str]]:
"""Return fresh Nous runtime credentials when available.
@@ -1308,11 +1445,15 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[
relying only on whatever raw tokens happen to be sitting in auth.json
or the credential pool.
"""
+ pooled = _resolve_nous_pool_runtime_api(force_refresh=force_refresh)
+ if pooled is not None:
+ return pooled
+
try:
from hermes_cli.auth import resolve_nous_runtime_credentials
creds = resolve_nous_runtime_credentials(
- timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
+ timeout_seconds=env_float("HERMES_NOUS_TIMEOUT_SECONDS", 15),
force_refresh=force_refresh,
)
except Exception as exc:
@@ -1491,7 +1632,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
_merged_aux = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux:
extra["default_headers"] = _merged_aux
- _client = OpenAI(api_key=api_key, base_url=base_url, **extra)
+ _client = _create_openai_client(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1531,7 +1672,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
_merged_aux2 = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux2:
extra["default_headers"] = _merged_aux2
- _client = OpenAI(api_key=api_key, base_url=base_url, **extra)
+ _client = _create_openai_client(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1546,20 +1687,21 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op
pool_present, entry = _select_pool_entry("openrouter")
if pool_present:
or_key = explicit_api_key or _pool_runtime_api_key(entry)
- if not or_key:
- _mark_provider_unhealthy("openrouter", ttl=60)
- return None, None
- base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
- logger.debug("Auxiliary client: OpenRouter via pool")
- return OpenAI(api_key=or_key, base_url=base_url,
- default_headers=build_or_headers()), model or _OPENROUTER_MODEL
+ if or_key:
+ base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
+ logger.debug("Auxiliary client: OpenRouter via pool")
+ return _create_openai_client(api_key=or_key, base_url=base_url,
+ default_headers=build_or_headers()), model or _OPENROUTER_MODEL
+ # Pool exists but is exhausted (no usable runtime key) — fall through to
+ # the OPENROUTER_API_KEY env-var path rather than failing outright.
+ logger.debug("Auxiliary client: OpenRouter pool exhausted, trying OPENROUTER_API_KEY")
or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY")
if not or_key:
_mark_provider_unhealthy("openrouter", ttl=60)
return None, None
logger.debug("Auxiliary client: OpenRouter")
- return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL,
+ return _create_openai_client(api_key=or_key, base_url=OPENROUTER_BASE_URL,
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
@@ -1652,7 +1794,7 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
return None, None
base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/")
return (
- OpenAI(
+ _create_openai_client(
api_key=api_key,
base_url=base_url,
),
@@ -1929,7 +2071,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
if _custom_headers:
_extra["default_headers"] = _custom_headers
if custom_mode == "codex_responses":
- real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
+ real_client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **_extra)
return CodexAuxiliaryClient(real_client, model), model
if custom_mode == "anthropic_messages":
# Third-party Anthropic-compatible gateway (MiniMax, Zhipu GLM,
@@ -1943,14 +2085,14 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
"Custom endpoint declares api_mode=anthropic_messages but the "
"anthropic SDK is not installed — falling back to OpenAI-wire."
)
- return OpenAI(api_key=custom_key, base_url=_clean_base, **_extra), model
+ return _create_openai_client(api_key=custom_key, base_url=_clean_base, **_extra), model
return (
AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False),
model,
)
# URL-based anthropic detection for custom endpoints that didn't set
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
- _fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
+ _fallback_client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **_extra)
_fallback_client = _maybe_wrap_anthropic(
_fallback_client, model, custom_key, custom_base, custom_mode,
)
@@ -1979,7 +2121,7 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str
return None, None
api_key, base_url = resolved
logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model)
- real_client = OpenAI(api_key=api_key, base_url=base_url)
+ real_client = _create_openai_client(api_key=api_key, base_url=base_url)
return CodexAuxiliaryClient(real_client, model), model
@@ -2016,7 +2158,7 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
return None, None
base_url = _CODEX_AUX_BASE_URL
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", model)
- real_client = OpenAI(
+ real_client = _create_openai_client(
api_key=codex_token,
base_url=base_url,
default_headers=_codex_cloudflare_headers(codex_token),
@@ -2116,7 +2258,7 @@ def _try_azure_foundry(
if _dq:
extra["default_query"] = _dq
- client = OpenAI(api_key=api_key, base_url=_clean_base, **extra)
+ client = _create_openai_client(api_key=api_key, base_url=_clean_base, **extra)
if runtime_api_mode == "codex_responses":
# GPT-5.x / o-series / codex models on Azure Foundry are
@@ -2155,9 +2297,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
if not token:
return None, None
- # Allow base URL override from config.yaml model.base_url, but only
- # when the configured provider is anthropic — otherwise a non-Anthropic
- # base_url (e.g. Codex endpoint) would leak into Anthropic requests.
+ # Allow base URL override from config.yaml model.base_url, but only when:
+ # 1. the configured provider is anthropic (otherwise a non-Anthropic
+ # base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND
+ # 2. the override URL actually points at an Anthropic-compatible endpoint.
+ # Without gate (2), operators who route main-session traffic through a
+ # non-Anthropic provider that accepts Anthropic-format requests (e.g.
+ # OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)
+ # would have every auxiliary side-channel call (memory extractors,
+ # reflection, vision, title generation) 401 from the foreign host —
+ # see issue #52608.
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
try:
from hermes_cli.config import load_config
@@ -2167,7 +2316,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider == "anthropic":
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
- if cfg_base_url:
+ if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
base_url = cfg_base_url
except Exception:
pass
@@ -2370,7 +2519,7 @@ def _is_payment_error(exc: Exception) -> bool:
# but sometimes wrap them in 429 or other codes.
# Daily quota exhaustion from Bedrock, Vertex AI, and similar providers
# uses different language but is semantically identical to credit exhaustion.
- if status in {402, 404, 429, None}:
+ if status in {402, 403, 404, 429, None}:
if any(kw in err_lower for kw in (
"credits", "insufficient funds",
"can only afford", "billing",
@@ -2379,6 +2528,8 @@ def _is_payment_error(exc: Exception) -> bool:
"balance_depleted", "no usable credits",
"model_not_supported_on_free_tier",
"not available on the free tier",
+ "requires a subscription", "upgrade for access",
+ "upgrade for higher limits", "reached your session usage limit",
# Daily / monthly / weekly quota exhaustion keywords
"quota exceeded", "quota_exceeded",
"too many tokens per day", "daily limit",
@@ -2597,6 +2748,79 @@ def _is_model_not_found_error(exc: Exception) -> bool:
))
+def _is_model_incompatible_error(exc: Exception) -> bool:
+ """Detect "this route cannot serve this model" 400s (capability mismatch).
+
+ Distinct from :func:`_is_model_not_found_error` (the model does not exist
+ anywhere): here the model name is valid but the *current provider/account*
+ is structurally unable to run it. The canonical case is a configured
+ fallback that cannot run the main model — e.g. an ``openai-codex`` /
+ ChatGPT-account fallback asked to compress a ``glm-5.2`` conversation::
+
+ Error code: 400 - {'detail': "The 'glm-5.2' model is not supported
+ when using Codex with a ChatGPT account."}
+
+ The candidate authenticates fine and builds a client, so the auth and
+ payment predicates don't fire and the call would otherwise raise and
+ abort the whole auxiliary task (commonly compression — which then drops
+ middle turns and churns the session, destroying the prompt cache).
+ Treating it as a fallback-worthy capability error lets the chain skip the
+ incapable route and continue to the next candidate, mirroring the
+ context-window feasibility screen (#52392).
+
+ Billing/quota 400s belong to :func:`_is_payment_error`; "model does not
+ exist" 400s belong to :func:`_is_model_not_found_error`. This predicate
+ explicitly excludes both so the three don't overlap.
+ """
+ status = getattr(exc, "status_code", None)
+ if status not in {400, None}:
+ return False
+ err_lower = str(exc).lower()
+ # Not-found 400s ("invalid model ID", "model does not exist") are owned by
+ # _is_model_not_found_error. Billing/free-tier 400s are owned by the
+ # payment path — key on the billing keywords directly here rather than
+ # calling _is_payment_error(), because that predicate is status-gated
+ # ({402,403,404,429,None}) and would not recognise a 400-coded billing
+ # body, letting it leak into this capability bucket.
+ if _is_model_not_found_error(exc):
+ return False
+ if any(kw in err_lower for kw in (
+ "credits", "insufficient funds", "billing", "out of funds",
+ "balance_depleted", "no usable credits", "payment required",
+ "free tier", "free-tier", "not available on the free tier",
+ "model_not_supported_on_free_tier", "quota",
+ )):
+ return False
+ return any(kw in err_lower for kw in (
+ "is not supported when using", # codex/ChatGPT-account model gating
+ "model is not supported",
+ "not supported with this",
+ "not supported for this account",
+ "model_not_supported",
+ "does not support this model",
+ "unsupported model",
+ ))
+
+
+def _is_invalid_aux_response_error(exc: Exception) -> bool:
+ """Detect provider responses that authenticated but cannot serve aux shape.
+
+ Some OpenAI-compatible routes return HTTP 200 with an empty/malformed
+ ChatCompletion instead of a normal provider error. That is still a
+ provider/model capability failure for auxiliary tasks: downstream callers
+ need ``choices[0].message`` and should be able to continue through the
+ same fallback path as explicit model-incompatibility errors.
+ """
+ if not isinstance(exc, RuntimeError):
+ return False
+ msg = str(exc).lower()
+ return (
+ "auxiliary " in msg
+ and "llm returned invalid response" in msg
+ and "choices[0].message" in msg
+ )
+
+
def _evict_cached_clients(provider: str) -> None:
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
normalized = _normalize_aux_provider(provider)
@@ -2905,7 +3129,7 @@ def _refresh_provider_credentials(provider: str) -> bool:
from hermes_cli.auth import resolve_nous_runtime_credentials
creds = resolve_nous_runtime_credentials(
- timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
+ timeout_seconds=env_float("HERMES_NOUS_TIMEOUT_SECONDS", 15),
force_refresh=True,
)
if not str(creds.get("api_key", "") or "").strip():
@@ -3047,6 +3271,88 @@ def _try_main_agent_model_fallback(
return client, resolved_model or main_model, label
+# ── Context-window screening for runtime fallback chains (issue #52392) ──
+#
+# When the runtime auxiliary fallback chain selects a candidate that is
+# reachable but has a context window smaller than the compression task
+# requires, the call errors out instead of continuing to the next, viable
+# candidate. The startup feasibility check in
+# ``agent.conversation_compression.check_compression_model_feasibility``
+# already filters too-small auxiliary models at startup, but the runtime
+# fallback chain (``_try_configured_fallback_chain`` and
+# ``_try_main_fallback_chain``) does not apply the same filter, so
+# compression can stop at the first alive door even if the room behind it
+# is too small.
+#
+# The helpers below screen each candidate by its effective context window
+# before it is returned. ``None`` results from ``get_model_context_length``
+# are passed through (we cannot prove a model is too small, so we do not
+# block it). This preserves the existing fallback surface for
+# unrecognised/custom models while closing the gap on the well-known ones.
+
+def _task_minimum_context_length(task: Optional[str]) -> Optional[int]:
+ """Return the minimum context length required for an auxiliary task.
+
+ Only ``compression`` carries an explicit minimum today (the same
+ ``MINIMUM_CONTEXT_LENGTH`` (64K) floor that
+ ``check_compression_model_feasibility`` already enforces at startup).
+ Other tasks (``vision``, ``title_generation``, ``web_extract``,
+ ``skills_hub``, ``mcp``, ``session_search``) return ``None`` — they
+ have no per-task context floor and the runtime chain must remain
+ permissive for them.
+
+ Returns ``None`` for an empty/``None`` task name so the helper is a
+ safe no-op when called from generic sites.
+ """
+ if not task:
+ return None
+ if task == "compression":
+ return MINIMUM_CONTEXT_LENGTH
+ return None
+
+
+def _candidate_context_window(
+ provider: str,
+ model: str,
+ base_url: str = "",
+ api_key: str = "",
+) -> Optional[int]:
+ """Resolve the effective context window for a fallback candidate.
+
+ Thin wrapper around :func:`agent.model_metadata.get_model_context_length`
+ that swallows probe failures (returns ``None``). Callers treat
+ ``None`` as "unknown — pass through" so the existing fallback
+ surface is preserved when the context-length resolver chain cannot
+ determine a value (custom endpoints, models not in the registry,
+ offline endpoints).
+
+ Best-effort, never raises — the runtime fallback chain must keep
+ moving even if the resolver hits a probe error.
+ """
+ if not model:
+ return None
+ try:
+ ctx = get_model_context_length(
+ model,
+ base_url=base_url,
+ api_key=api_key,
+ provider=provider,
+ )
+ except Exception as exc:
+ logger.debug(
+ "Auxiliary fallback: could not resolve context window for %s/%s: %s",
+ provider, model, exc,
+ )
+ return None
+ # ``get_model_context_length`` returns an int (with a 256K default
+ # fallback when nothing else matches). We still propagate ``None`` if
+ # a future change returns ``Optional[int]`` — being explicit is
+ # cheap and the test suite covers both shapes.
+ if isinstance(ctx, int) and ctx > 0:
+ return ctx
+ return None
+
+
def _try_configured_fallback_chain(
task: str,
failed_provider: str,
@@ -3071,6 +3377,7 @@ def _try_configured_fallback_chain(
skip = failed_provider.lower().strip()
tried = []
+ min_ctx = _task_minimum_context_length(task)
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
@@ -3088,6 +3395,20 @@ def _try_configured_fallback_chain(
fb_client, resolved_model = None, None
if fb_client is not None:
+ if min_ctx is not None and resolved_model:
+ fb_ctx = _candidate_context_window(
+ fb_provider,
+ resolved_model,
+ base_url=str(entry.get("base_url") or ""),
+ api_key=_fallback_entry_api_key(entry) or "",
+ )
+ if fb_ctx is not None and fb_ctx < min_ctx:
+ logger.info(
+ "Auxiliary %s: skipping %s (%s context=%d < min=%d), continuing chain",
+ task, label, resolved_model, fb_ctx, min_ctx,
+ )
+ tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
+ continue
logger.info(
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
task, reason, failed_provider, label, resolved_model or fb_model or "default",
@@ -3103,6 +3424,28 @@ def _try_configured_fallback_chain(
return None, None, ""
+def _try_configured_fallback_for_unavailable_client(
+ task: Optional[str],
+ failed_provider: str,
+) -> Tuple[Optional[Any], Optional[str], str]:
+ """Try task fallback_chain when an explicit aux provider cannot build.
+
+ This covers the "no client" case before any request is sent: missing
+ raw env key, unavailable OAuth/pool credentials, or provider resolver
+ returning ``(None, None)``. It deliberately stops at the configured
+ per-task fallback chain; the main-agent model remains the last-resort
+ runtime fallback for request-time capacity errors.
+ """
+ explicit = (failed_provider or "").strip().lower()
+ if not task or not explicit or explicit in {"auto"}:
+ return None, None, ""
+ return _try_configured_fallback_chain(
+ task,
+ explicit,
+ reason="provider unavailable",
+ )
+
+
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
@@ -3161,6 +3504,7 @@ def _try_main_fallback_chain(
main_norm = (_read_main_provider() or "").strip().lower()
skip = {p for p in (failed_norm, main_norm, "auto") if p}
tried: List[str] = []
+ min_ctx = _task_minimum_context_length(task)
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
@@ -3184,6 +3528,20 @@ def _try_main_fallback_chain(
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
fb_client, resolved_model = None, None
if fb_client is not None:
+ if min_ctx is not None:
+ fb_ctx = _candidate_context_window(
+ fb_provider,
+ resolved_model or fb_model,
+ base_url=str(entry.get("base_url") or ""),
+ api_key=_fallback_entry_api_key(entry) or "",
+ )
+ if fb_ctx is not None and fb_ctx < min_ctx:
+ logger.info(
+ "Auxiliary %s: skipping %s (context=%d < min=%d), continuing chain",
+ task or "call", label, fb_ctx, min_ctx,
+ )
+ tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
+ continue
logger.info(
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
task or "call", reason, failed_provider or "auto", label,
@@ -3285,6 +3643,37 @@ def _resolve_auto(
# config.yaml (auxiliary..provider) still win over this.
main_provider = str(runtime_provider or _read_main_provider() or "")
main_model = str(runtime_model or _read_main_model() or "")
+
+ # MoA virtual provider: the "model" is a preset name (e.g. "opus-gpt") and
+ # there is no real "moa" HTTP endpoint, so resolving an aux client against
+ # provider="moa"/model= sends the preset name as the model id and
+ # the provider 400s ("opus-gpt is not a valid model ID"). Auxiliary tasks
+ # (title generation, compression, vision, …) don't need the reference
+ # fan-out — they should run on the aggregator, which is the preset's acting
+ # model. Resolve the MoA preset to its aggregator slot and continue Step 1
+ # with that real provider+model. Mirrors the MoA context-length resolution.
+ if main_provider == "moa":
+ try:
+ from hermes_cli.config import load_config
+ from hermes_cli.moa_config import resolve_moa_preset
+
+ _preset = resolve_moa_preset(load_config().get("moa") or {}, main_model)
+ _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 and _agg_provider.lower() != "moa":
+ main_provider = _agg_provider
+ main_model = _agg_model
+ # The MoA virtual runtime carries a non-HTTP base_url
+ # ("moa://local") and a placeholder api_key; they belong to the
+ # facade, not the aggregator's real provider. Drop them so the
+ # aggregator resolves through its own provider credentials.
+ runtime_base_url = ""
+ runtime_api_key = ""
+ runtime_api_mode = ""
+ except Exception:
+ logger.debug("MoA aux resolution to aggregator failed", exc_info=True)
+
if (main_provider and main_model
and main_provider not in {"auto", ""}):
resolved_provider = main_provider
@@ -3431,6 +3820,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
_merged_async = _apply_user_default_headers(async_kwargs.get("default_headers"))
if _merged_async:
async_kwargs["default_headers"] = _merged_async
+ async_kwargs = {
+ **_openai_http_client_kwargs(sync_base_url, async_mode=True),
+ **async_kwargs,
+ }
return AsyncOpenAI(**async_kwargs), model
@@ -3641,7 +4034,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
"but no Codex OAuth token found (run: hermes model)")
return None, None
final_model = _normalize_resolved_model(model, provider)
- raw_client = OpenAI(
+ raw_client = _create_openai_client(
api_key=codex_token,
base_url=_CODEX_AUX_BASE_URL,
default_headers=_codex_cloudflare_headers(codex_token),
@@ -3722,7 +4115,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
_merged_custom = _apply_user_default_headers(extra.get("default_headers"))
if _merged_custom:
extra["default_headers"] = _merged_custom
- client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
+ client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **extra)
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
@@ -3826,7 +4219,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
_fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers"))
if _fb_headers:
_fb_extra["default_headers"] = _fb_headers
- client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
+ client = _create_openai_client(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
sync_anthropic = AnthropicAuxiliaryClient(
@@ -3835,7 +4228,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
if async_mode:
return AsyncAnthropicAuxiliaryClient(sync_anthropic), final_model
return sync_anthropic, final_model
- client = OpenAI(api_key=custom_key, base_url=_clean_base2, **_extra2)
+ client = _create_openai_client(api_key=custom_key, base_url=_clean_base2, **_extra2)
# codex_responses or inherited auto-detect (via _wrap_if_needed).
# _wrap_if_needed reads the closed-over `api_mode` (the task-level
# override). Named-provider entry api_mode=codex_responses also
@@ -3977,7 +4370,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
_merged_main = _apply_user_default_headers(headers)
if _merged_main:
headers = _merged_main
- client = OpenAI(api_key=api_key, base_url=base_url,
+ client = _create_openai_client(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))
# Copilot GPT-5+ models (except gpt-5-mini) require the Responses
@@ -4513,7 +4906,7 @@ def _refresh_nous_auxiliary_client(
return None, model
fresh_key, fresh_base_url = runtime
- sync_client = OpenAI(api_key=fresh_key, base_url=fresh_base_url)
+ sync_client = _create_openai_client(api_key=fresh_key, base_url=fresh_base_url)
final_model = model
current_loop = None
@@ -5154,6 +5547,9 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
if not choices or not hasattr(choices[0], "message"):
raise AttributeError("missing choices[0].message")
except (AttributeError, TypeError, IndexError) as exc:
+ recovered = _recover_aux_response_message(response)
+ if recovered is not None:
+ return recovered
response_type = type(response).__name__
response_preview = str(response)[:120]
raise RuntimeError(
@@ -5165,6 +5561,64 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
return response
+def _recover_aux_response_message(response: Any) -> Optional[Any]:
+ """Synthesize chat-completions shape from Responses-style text fields.
+
+ Auxiliary callers consume ``choices[0].message``. Some compatible
+ endpoints return text outside ``choices`` (for example ``output_text`` or
+ ``output`` items). Preserve that response before declaring it malformed.
+ """
+ text = _extract_aux_response_text(response)
+ if not text:
+ return None
+
+ choice = SimpleNamespace(
+ message=SimpleNamespace(content=text),
+ finish_reason=getattr(response, "finish_reason", None) or "stop",
+ )
+ try:
+ response.choices = [choice]
+ return response
+ except Exception:
+ return SimpleNamespace(
+ id=getattr(response, "id", ""),
+ model=getattr(response, "model", ""),
+ object=getattr(response, "object", "chat.completion"),
+ choices=[choice],
+ usage=getattr(response, "usage", None),
+ )
+
+
+def _extract_aux_response_text(response: Any) -> str:
+ output_text = _obj_get(response, "output_text")
+ if isinstance(output_text, str) and output_text.strip():
+ return output_text.strip()
+
+ output = _obj_get(response, "output")
+ if not isinstance(output, list):
+ return ""
+
+ parts: List[str] = []
+ for item in output:
+ item_type = _obj_get(item, "type")
+ if item_type and item_type != "message":
+ continue
+ for part in (_obj_get(item, "content") or []):
+ part_type = _obj_get(part, "type")
+ if part_type in {"output_text", "text", None}:
+ text = _obj_get(part, "text")
+ if isinstance(text, str) and text.strip():
+ parts.append(text.strip())
+ return "\n".join(parts).strip()
+
+
+def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
+ value = getattr(obj, key, default)
+ if value is default and isinstance(obj, dict):
+ value = obj.get(key, default)
+ return value
+
+
def call_llm(
task: str = None,
*,
@@ -5244,21 +5698,30 @@ def call_llm(
)
if client is None:
# When the user explicitly chose a non-OpenRouter provider but no
- # credentials were found, fail fast instead of silently routing
- # through OpenRouter (which causes confusing 404s).
+ # credentials were found, honor the task fallback_chain before
+ # raising. Missing raw env keys are recoverable for auxiliary
+ # tasks because fallback entries may use OAuth / credential-pool
+ # auth (for example openai-codex).
_explicit = (resolved_provider or "").strip().lower()
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
- raise RuntimeError(
- f"Provider '{_explicit}' is set in config.yaml but no API key "
- f"was found. Set the {_explicit.upper()}_API_KEY environment "
- f"variable, or switch to a different provider with `hermes model`."
+ fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
+ task, _explicit,
)
+ if fb_client is not None:
+ client, final_model = fb_client, fb_model
+ resolved_provider = fb_label or resolved_provider
+ else:
+ raise RuntimeError(
+ f"Provider '{_explicit}' is set in config.yaml but no API key "
+ f"was found. Set the {_explicit.upper()}_API_KEY environment "
+ f"variable, or switch to a different provider with `hermes model`."
+ )
# For auto/custom with no credentials, try the full auto chain
# rather than hardcoding OpenRouter (which may be depleted).
# Pass model=None so each provider uses its own default —
# resolved_model may be an OpenRouter-format slug that doesn't
# work on other providers.
- if not resolved_base_url:
+ if client is None and not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
@@ -5553,10 +6016,21 @@ def call_llm(
# When the provider returns a 429 rate-limit (not billing), fall
# back to an alternative provider instead of exhausting retries
# against the same rate-limited endpoint.
+ #
+ # ── Auth error fallback (#21165) ─────────────────────────────
+ # When the resolved provider returns 401 and neither the Nous
+ # refresh path nor explicit provider credential refresh applies,
+ # fall back to an alternative provider instead of dropping the
+ # auxiliary task on the floor (silent compression failure /
+ # message loss). Auth is NOT a capacity error: it only bypasses
+ # the explicit-provider gate when the user is in auto mode.
should_fallback = (
- _is_payment_error(first_err)
+ _is_auth_error(first_err)
+ or _is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
+ or _is_model_incompatible_error(first_err)
+ or _is_invalid_aux_response_error(first_err)
)
# Respect explicit provider choice for transient errors (auth, request
# validation, etc.) but allow fallback when the provider clearly cannot
@@ -5567,9 +6041,24 @@ def call_llm(
is_auto = resolved_provider in {"auto", "", None}
# Capacity errors bypass the explicit-provider gate: the provider
# literally cannot serve this request regardless of user intent.
- is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
+ # Rate limits are included: after retries are exhausted, a 429 means
+ # the provider cannot serve this request — fall back. See #52228.
+ # Model-incompatibility 400s are also a hard capability mismatch (the
+ # route cannot run this model at all — e.g. a codex/ChatGPT-account
+ # fallback asked to compress a glm-5.2 conversation), so they bypass
+ # the explicit-provider gate and continue to the next candidate
+ # instead of aborting the auxiliary task and churning the session.
+ is_capacity_error = (
+ _is_payment_error(first_err)
+ or _is_connection_error(first_err)
+ or _is_rate_limit_error(first_err)
+ or _is_model_incompatible_error(first_err)
+ or _is_invalid_aux_response_error(first_err)
+ )
if should_fallback and (is_auto or is_capacity_error):
- if _is_payment_error(first_err):
+ if _is_auth_error(first_err):
+ reason = "auth error"
+ elif _is_payment_error(first_err):
reason = "payment error"
# Resolve the actual provider label (resolved_provider may be
# "auto"; the client's base_url tells us which backend got the
@@ -5580,6 +6069,10 @@ def call_llm(
)
elif _is_rate_limit_error(first_err):
reason = "rate limit"
+ elif _is_model_incompatible_error(first_err):
+ reason = "model incompatible with route"
+ elif _is_invalid_aux_response_error(first_err):
+ reason = "invalid provider response"
else:
reason = "connection error"
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
@@ -5754,12 +6247,21 @@ async def async_call_llm(
if client is None:
_explicit = (resolved_provider or "").strip().lower()
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
- raise RuntimeError(
- f"Provider '{_explicit}' is set in config.yaml but no API key "
- f"was found. Set the {_explicit.upper()}_API_KEY environment "
- f"variable, or switch to a different provider with `hermes model`."
+ fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
+ task, _explicit,
)
- if not resolved_base_url:
+ if fb_client is not None:
+ client, final_model = _to_async_client(
+ fb_client, fb_model or "", is_vision=(task == "vision")
+ )
+ resolved_provider = fb_label or resolved_provider
+ else:
+ raise RuntimeError(
+ f"Provider '{_explicit}' is set in config.yaml but no API key "
+ f"was found. Set the {_explicit.upper()}_API_KEY environment "
+ f"variable, or switch to a different provider with `hermes model`."
+ )
+ if client is None and not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
@@ -6005,24 +6507,47 @@ async def async_call_llm(
raise
# ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
+ # Auth error fallback (#21165): a 401 that survived the refresh path
+ # falls back in auto mode just like the sync call_llm() path. Auth is
+ # NOT a capacity error, so on an explicit provider it still respects
+ # the user's choice (handled by the is_auto/is_capacity_error gate).
should_fallback = (
- _is_payment_error(first_err)
+ _is_auth_error(first_err)
+ or _is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
+ or _is_model_incompatible_error(first_err)
+ or _is_invalid_aux_response_error(first_err)
)
- # Capacity errors (payment/quota/connection) bypass the explicit-provider
- # gate — the provider cannot serve the request regardless of user intent.
+ # Capacity errors (payment/quota/connection/rate-limit) bypass the
+ # explicit-provider gate — the provider cannot serve the request
+ # regardless of user intent. Rate limits are included: after retries
+ # are exhausted, a 429 means the provider is at capacity. See #52228.
# See #26803: daily token quota must fall back like a 402 credit error.
+ # Model-incompatibility 400s (route cannot run this model at all)
+ # bypass the gate too — see the sync call_llm() path for rationale.
is_auto = resolved_provider in {"auto", "", None}
- is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
+ is_capacity_error = (
+ _is_payment_error(first_err)
+ or _is_connection_error(first_err)
+ or _is_rate_limit_error(first_err)
+ or _is_model_incompatible_error(first_err)
+ or _is_invalid_aux_response_error(first_err)
+ )
if should_fallback and (is_auto or is_capacity_error):
- if _is_payment_error(first_err):
+ if _is_auth_error(first_err):
+ reason = "auth error"
+ elif _is_payment_error(first_err):
reason = "payment error"
_mark_provider_unhealthy(
_recoverable_pool_provider(resolved_provider, client) or resolved_provider
)
elif _is_rate_limit_error(first_err):
reason = "rate limit"
+ elif _is_model_incompatible_error(first_err):
+ reason = "model incompatible with route"
+ elif _is_invalid_aux_response_error(first_err):
+ reason = "invalid provider response"
else:
reason = "connection error"
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
diff --git a/agent/background_review.py b/agent/background_review.py
index c809b49606..564c544199 100644
--- a/agent/background_review.py
+++ b/agent/background_review.py
@@ -27,6 +27,131 @@
logger = logging.getLogger(__name__)
+# ---------------------------------------------------------------------------
+# Background-review aux-model selector + routed digest.
+#
+# The review fork runs on the MAIN model by default ("auto"), replaying the
+# full conversation — already warm in the prompt cache, so cheap cache reads.
+# Optimal and unchanged. A user can route the review to a different, cheaper
+# model via auxiliary.background_review.{provider,model}. A different model
+# cannot reuse the parent's cache (different key), so the fork is cold
+# regardless — replaying the full transcript would just cold-write it. So when
+# (and only when) routed to a different model, we replay a compact DIGEST to
+# minimise cold-written tokens. Same model -> full replay; different model ->
+# digest. That's the whole policy.
+# ---------------------------------------------------------------------------
+
+
+def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
+ """Resolve provider/model/credentials for the review fork.
+
+ Default (auto / unset / same as parent): inherit the parent's live runtime
+ (with codex_app_server -> codex_responses downgrade). ``routed`` is False —
+ the fork uses the main model and the warm cache, exactly as before. When
+ ``auxiliary.background_review.{provider,model}`` names a concrete model
+ different from the parent's, resolve that runtime and set ``routed=True``.
+ """
+ parent_runtime = agent._current_main_runtime()
+ parent_api_mode = parent_runtime.get("api_mode") or None
+ if parent_api_mode == "codex_app_server":
+ parent_api_mode = "codex_responses"
+ parent = {
+ "provider": agent.provider,
+ "model": agent.model,
+ "api_key": parent_runtime.get("api_key") or None,
+ "base_url": parent_runtime.get("base_url") or None,
+ "api_mode": parent_api_mode,
+ "routed": False,
+ }
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ except Exception:
+ return parent
+ aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
+ task = aux.get("background_review", {}) if isinstance(aux.get("background_review"), dict) else {}
+ task_provider = (str(task.get("provider", "")).strip() or None)
+ task_model = (str(task.get("model", "")).strip() or None)
+ task_base_url = (str(task.get("base_url", "")).strip() or None)
+ task_api_key = (str(task.get("api_key", "")).strip() or None)
+ if not (task_provider and task_provider != "auto" and task_model):
+ return parent
+ if task_provider == (agent.provider or "") and task_model == (agent.model or ""):
+ return parent # same model/provider as parent -> not routed
+ try:
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ rp = resolve_runtime_provider(
+ requested=task_provider,
+ target_model=task_model,
+ explicit_api_key=task_api_key,
+ explicit_base_url=task_base_url,
+ )
+ return {
+ "provider": rp.get("provider") or task_provider,
+ "model": task_model,
+ "api_key": rp.get("api_key"),
+ "base_url": rp.get("base_url"),
+ "api_mode": rp.get("api_mode"),
+ "routed": True,
+ }
+ except Exception as e:
+ logger.debug("background-review aux routing failed (%s); using main model", e)
+ return parent
+
+
+def _msg_text(m: Dict) -> str:
+ c = m.get("content")
+ if isinstance(c, str):
+ return c.strip()
+ if isinstance(c, list):
+ return " ".join(b.get("text", "") for b in c if isinstance(b, dict)).strip()
+ return ""
+
+
+def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]:
+ """Compact replay for the routed (different-model) path only.
+
+ Keeps the recent ``tail`` messages verbatim, collapses older turns into one
+ synthetic user-role digest, preserving role alternation. Used ONLY when
+ routed to a different model (cache cold regardless, so fewer cold-written
+ tokens is a pure win). Never on the main-model path (full replay stays warm).
+ """
+ msgs = list(messages_snapshot or [])
+ if len(msgs) <= tail:
+ return msgs
+ keep = msgs[-tail:]
+ while keep and isinstance(keep[0], dict) and keep[0].get("role") == "tool":
+ tail += 1
+ if len(msgs) <= tail:
+ return msgs
+ keep = msgs[-tail:]
+ old = msgs[:-len(keep)]
+ lines: List[str] = []
+ for m in old:
+ if not isinstance(m, dict):
+ continue
+ role = m.get("role")
+ text = _msg_text(m).replace("\n", " ")
+ if role == "user" and text:
+ lines.append(f"USER: {text[:300]}")
+ elif role == "assistant":
+ tcs = m.get("tool_calls") or []
+ if tcs:
+ names = [(tc.get("function") or {}).get("name", "?") for tc in tcs if isinstance(tc, dict)]
+ lines.append(f"ASSISTANT[tools: {', '.join(names)}]")
+ if text:
+ lines.append(f"ASSISTANT: {text[:200]}")
+ digest = {
+ "role": "user",
+ "content": (
+ "[Earlier conversation digest — older turns summarised to bound the "
+ "review's cold-write cost on the routed aux model. Recent turns "
+ "follow verbatim below.]\n" + "\n".join(lines)
+ ),
+ }
+ return [digest] + keep
+
+
# Review-prompt strings — used by ``spawn_background_review_thread`` to build
# the user-message that the forked review agent receives. AIAgent exposes
# them as class attributes (``_MEMORY_REVIEW_PROMPT`` etc.) for back-compat;
@@ -488,18 +613,13 @@ def _bg_review_auto_deny(command, description, **kwargs):
# creds, or credential-pool setups where the resolver can't
# reconstruct auth from scratch -- producing the spurious
# "No LLM provider configured" warning at end of turn.
- _parent_runtime = agent._current_main_runtime()
- _parent_api_mode = _parent_runtime.get("api_mode") or None
- # The review fork needs to call agent-loop tools (memory,
- # skill_manage). Those tools require Hermes' own dispatch,
- # which the codex_app_server runtime bypasses entirely
- # (it runs the turn inside codex's subprocess). So when
- # the parent is on codex_app_server, downgrade the review
- # fork to codex_responses — same auth/credentials, but
- # talks to the OpenAI Responses API directly so Hermes
- # owns the loop and the agent-loop tools dispatch.
- if _parent_api_mode == "codex_app_server":
- _parent_api_mode = "codex_responses"
+ # _resolve_review_runtime() returns the parent's live runtime by
+ # default (routed=False; main model, warm cache), or — when the user
+ # set auxiliary.background_review.{provider,model} to a different
+ # model — that model's runtime (routed=True). The codex_app_server
+ # -> codex_responses downgrade is applied inside the resolver.
+ _rt = _resolve_review_runtime(agent)
+ _routed = bool(_rt.get("routed"))
# skip_memory=True keeps the review fork from
# touching external memory plugins (honcho, mem0,
# supermemory, etc.). Without it, the fork's
@@ -519,14 +639,14 @@ def _bg_review_auto_deny(command, description, **kwargs):
# in the request body — Anthropic's cache key includes it.
# (The runtime whitelist below still restricts dispatch.)
review_agent = AIAgent(
- model=agent.model,
+ model=_rt.get("model") or agent.model,
max_iterations=16,
quiet_mode=True,
platform=agent.platform,
- provider=agent.provider,
- api_mode=_parent_api_mode,
- base_url=_parent_runtime.get("base_url") or None,
- api_key=_parent_runtime.get("api_key") or None,
+ provider=_rt.get("provider") or agent.provider,
+ api_mode=_rt.get("api_mode"),
+ base_url=_rt.get("base_url") or None,
+ api_key=_rt.get("api_key") or None,
credential_pool=getattr(agent, "_credential_pool", None),
parent_session_id=agent.session_id,
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
@@ -565,16 +685,28 @@ def _bg_review_auto_deny(command, description, **kwargs):
# issue #25322 and PR #17276 for the full analysis +
# measured impact (~26% end-to-end cost reduction on
# Sonnet 4.5).
- review_agent._cached_system_prompt = agent._cached_system_prompt
- # Defensive: pin session_start + session_id to the
- # parent's so any code path that re-renders parts of
- # the system prompt (compression, plugin hooks) still
- # produces byte-identical output. The cached-prompt
- # assignment above already short-circuits the normal
- # rebuild path, but these pins guarantee parity even
- # if a future code path bypasses the cache.
- review_agent.session_start = agent.session_start
+ # Share the parent's warm cached system prompt ONLY when the review
+ # runs on the SAME model (not routed). When routed to a different
+ # model the parent's cached prompt is for the wrong model/cache key
+ # and would miss anyway, so let the routed fork build its own.
+ if not _routed:
+ review_agent._cached_system_prompt = agent._cached_system_prompt
+ # Defensive: pin session_start + session_id to the
+ # parent's so any code path that re-renders parts of
+ # the system prompt (compression, plugin hooks) still
+ # produces byte-identical output. The cached-prompt
+ # assignment above already short-circuits the normal
+ # rebuild path, but these pins guarantee parity even
+ # if a future code path bypasses the cache.
+ review_agent.session_start = agent.session_start
review_agent.session_id = agent.session_id
+ # The fork shares the parent's live session_id (pinned above for
+ # prefix-cache parity). It is single-lifecycle and calls close()
+ # right after this run_conversation(); without opting out, close()
+ # would finalize the parent's still-active session row mid
+ # conversation (the review fires every ~10 turns). Leave session
+ # finalization to the real owner (CLI close / gateway reset / cron).
+ review_agent._end_session_on_close = False
# Never let the review fork compress. It shares the parent's
# session_id, so if it won a compression race it would rotate the
# parent into a NEW child that the gateway never adopts (the fork
@@ -608,6 +740,13 @@ def _bg_review_auto_deny(command, description, **kwargs):
),
)
try:
+ # Routed to a different model -> replay a digest (cache is cold
+ # on that model anyway, so minimise cold-written tokens). Same
+ # model -> replay the full snapshot (warm cache reads).
+ _review_history = (
+ _digest_history(messages_snapshot) if _routed
+ else messages_snapshot
+ )
review_agent.run_conversation(
user_message=(
prompt
@@ -615,7 +754,7 @@ def _bg_review_auto_deny(command, description, **kwargs):
"management tools. Other tools will be denied "
"at runtime — do not attempt them."
),
- conversation_history=messages_snapshot,
+ conversation_history=_review_history,
)
finally:
clear_thread_tool_whitelist()
diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py
index 1ee1702b45..6c6ba9e12b 100644
--- a/agent/chat_completion_helpers.py
+++ b/agent/chat_completion_helpers.py
@@ -34,9 +34,21 @@
_repair_tool_call_arguments,
)
from tools.terminal_tool import is_persistent_env
-from utils import base_url_host_matches, base_url_hostname, env_int
+from utils import base_url_host_matches, base_url_hostname, env_float, env_int
logger = logging.getLogger(__name__)
+_OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"}
+
+# When the fallback chain is fully exhausted on a non-rate-limit failure
+# (e.g. every provider returns a non-retryable client error like HTTP 400),
+# arm a short cooldown so the NEXT turn's restore_primary_runtime stays gated
+# and does not reset _fallback_index=0 to replay the entire chain again.
+# Without this, a client/gateway that re-submits immediately would re-marshal
+# the full (potentially 80k-token) context once per provider every turn and
+# can drive a constrained host into memory/swap exhaustion. Rate-limit /
+# billing reasons keep their own 60s cooldown (set above); this is the
+# narrower non-rate-limit case. See issue #24996.
+_FALLBACK_EXHAUSTED_COOLDOWN_S = 5.0
def _ra():
@@ -115,6 +127,23 @@ def _is_openai_codex_backend(agent) -> bool:
)
+def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
+ """Return a normalized OpenRouter provider.sort value or None."""
+ if not isinstance(raw_sort, str):
+ return None
+ sort_value = raw_sort.strip().lower()
+ if not sort_value:
+ return None
+ if sort_value in _OPENROUTER_PROVIDER_SORT_VALUES:
+ return sort_value
+ logger.warning(
+ "Ignoring invalid OpenRouter provider.sort value %r (allowed: %s)",
+ raw_sort,
+ ", ".join(sorted(_OPENROUTER_PROVIDER_SORT_VALUES)),
+ )
+ return None
+
+
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
@@ -229,6 +258,11 @@ def _call():
invalidate_runtime_client(region)
raise
result["response"] = normalize_converse_response(raw_response)
+ elif agent.provider == "moa":
+ # MoA is a virtual chat-completions provider backed by the
+ # in-process MoAClient facade. Do not rebuild a request-local
+ # OpenAI client from the virtual runtime metadata.
+ result["response"] = agent.client.chat.completions.create(**api_kwargs)
else:
request_client = _set_request_client(
agent._create_request_openai_client(
@@ -698,8 +732,9 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_prefs["ignore"] = agent.providers_ignored
if agent.providers_order:
_prefs["order"] = agent.providers_order
- if agent.provider_sort:
- _prefs["sort"] = agent.provider_sort
+ _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
+ if _provider_sort:
+ _prefs["sort"] = _provider_sort
if agent.provider_require_parameters:
_prefs["require_parameters"] = True
if agent.provider_data_collection:
@@ -1015,18 +1050,23 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
"arguments": tool_call.function.arguments
},
}
- # Defence-in-depth: redact credentials from tool call arguments
- # before they enter conversation history. Tool execution uses the
- # raw API response object, not this dict, so redacting the
- # persisted shape is safe and only affects storage. Catches the
- # case where a model accidentally inlines a secret into a tool
- # call (e.g. `terminal(command="curl -H 'Authorization: Bearer
- # sk-...'")`). (#19798)
- if isinstance(tc_dict["function"]["arguments"], str):
- from agent.redact import redact_sensitive_text
- tc_dict["function"]["arguments"] = redact_sensitive_text(
- tc_dict["function"]["arguments"]
- )
+ # Tool-call arguments are intentionally NOT redacted here. This
+ # dict enters the in-memory conversation history that is replayed
+ # to the model on every subsequent turn AND persisted to state.db,
+ # which is itself replayed verbatim on session resume
+ # (get_messages_as_conversation). Masking a credential to `***`
+ # here poisons that replay: the model reads back its own
+ # `PGPASSWORD='***' psql ...` call and copies the placeholder into
+ # the next tool call, breaking every credential-dependent command
+ # on the second turn (#43083). The masking also provided no real
+ # protection — the same secret still leaks verbatim through tool
+ # OUTPUT (file contents, command output, diffs, the compaction
+ # block), none of which this pass ever touched. Keeping secrets
+ # out of the replayable store is a separate tokenization/vault
+ # concern, not something arg-redaction can deliver without
+ # breaking replay. Storage-time redaction remains governed by the
+ # `security.redact_secrets` toggle. (#19798 introduced this;
+ # #43083 removed it.)
# Preserve extra_content (e.g. Gemini thought_signature) so it
# is sent back on subsequent API calls. Without this, Gemini 3
# thinking models reject the request with a 400 error.
@@ -1042,6 +1082,35 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
+def rewrite_prompt_model_identity(agent, model: str, provider: str) -> None:
+ """Point the cached system prompt's ``Model:``/``Provider:`` lines at
+ the active runtime after a provider switch.
+
+ The system prompt is session-stable and replayed verbatim for prefix-cache
+ warmth, but after a failover the new backend's cache is cold anyway —
+ while a stale identity line makes the agent misreport which model it is
+ when asked. Rewrite the lines in place WITHOUT persisting to the session
+ DB: the stored row keeps the primary's labels, so when the primary is
+ restored the prompt is byte-identical to the stored copy again and its
+ prefix cache still matches.
+
+ Only the LAST occurrence of each line is touched — the identity lines
+ live in the volatile tail of the prompt, and earlier matches could be
+ user content (memory snapshots, context files).
+ """
+ sp = getattr(agent, "_cached_system_prompt", None)
+ if not isinstance(sp, str) or not sp:
+ return
+ for label, value in (("Model", model), ("Provider", provider)):
+ if not value:
+ continue
+ matches = list(re.finditer(rf"(?m)^{label}: .*$", sp))
+ if matches:
+ last = matches[-1]
+ sp = f"{sp[:last.start()]}{label}: {value}{sp[last.end():]}"
+ agent._cached_system_prompt = sp
+
+
def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool:
"""Switch to the next fallback model/provider in the chain.
@@ -1064,8 +1133,22 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
if (not fallback_already_active) or (primary_provider and current_provider == primary_provider):
agent._rate_limited_until = time.monotonic() + 60
if agent._fallback_index >= len(agent._fallback_chain):
+ # Chain exhausted. If we actually walked a non-empty chain and the
+ # failure was NOT a rate-limit/billing event (those already armed
+ # their own 60s cooldown above), arm a short cooldown so the next
+ # turn's restore_primary_runtime stays gated instead of resetting
+ # _fallback_index=0 and re-marshaling the whole context across every
+ # provider again. Guards the cross-turn replay storm in #24996.
+ if (
+ len(agent._fallback_chain) > 0
+ and reason not in {FailoverReason.rate_limit, FailoverReason.billing}
+ ):
+ _existing_cooldown = getattr(agent, "_rate_limited_until", 0) or 0
+ agent._rate_limited_until = max(
+ _existing_cooldown,
+ time.monotonic() + _FALLBACK_EXHAUSTED_COOLDOWN_S,
+ )
return False
-
fb = agent._fallback_chain[agent._fallback_index]
agent._fallback_index += 1
fb_provider = (fb.get("provider") or "").strip().lower()
@@ -1181,14 +1264,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent._transport_cache.clear()
agent._fallback_activated = True
- # Clear the credential pool when the fallback provider doesn't match
- # the pool's provider. The pool was seeded for the primary provider;
- # leaving it attached means downstream recovery (rate_limit / billing /
- # auth) calls ``_swap_credential`` with a primary entry which overwrites
- # the agent's ``base_url`` back to the primary's endpoint — every
- # fallback request then 404s against the wrong host. See #33163.
+ # Rebind the credential pool to the fallback provider when the provider
+ # changes. Keeping the primary pool attached would make downstream
+ # recovery (rate_limit / billing / auth) mutate the wrong credential
+ # set and can overwrite the fallback's base_url back to the primary
+ # endpoint. See #33163.
+ #
# When the fallback shares the pool's provider (e.g. both openrouter
- # entries with different routing) the pool is preserved.
+ # entries with different routing) the pool is preserved. When the
+ # providers differ, load the fallback provider's own pool if one exists
+ # so provider-specific rotation continues to work after the switch.
_existing_pool = getattr(agent, "_credential_pool", None)
if _existing_pool is not None:
_pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower()
@@ -1199,6 +1284,22 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model, _pool_provider,
)
agent._credential_pool = None
+ if getattr(agent, "_credential_pool", None) is None:
+ try:
+ from agent.credential_pool import load_pool
+
+ fallback_pool = load_pool(fb_provider)
+ if fallback_pool and fallback_pool.has_credentials():
+ agent._credential_pool = fallback_pool
+ logger.info(
+ "Fallback to %s/%s: attached fallback credential pool",
+ fb_provider, fb_model,
+ )
+ except Exception as exc:
+ logger.debug(
+ "Fallback to %s/%s: could not attach credential pool: %s",
+ fb_provider, fb_model, exc,
+ )
# Honor per-provider / per-model request_timeout_seconds for the
# fallback target (same knob the primary client uses). None = use
@@ -1287,6 +1388,10 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
api_mode=agent.api_mode,
)
+ # Keep the prompt's self-identity in sync with the model actually
+ # answering, so "what model are you?" doesn't report the primary.
+ rewrite_prompt_model_identity(agent, fb_model, fb_provider)
+
agent._buffer_status(
f"🔄 Primary model failed — switching to fallback: "
f"{fb_model} via {fb_provider}"
@@ -1425,8 +1530,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
provider_preferences["ignore"] = agent.providers_ignored
if agent.providers_order:
provider_preferences["order"] = agent.providers_order
- if agent.provider_sort:
- provider_preferences["sort"] = agent.provider_sort
+ _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
+ if _provider_sort:
+ provider_preferences["sort"] = _provider_sort
if provider_preferences and (
(agent.provider or "").strip().lower() == "openrouter"
or agent._is_openrouter_url()
@@ -1761,14 +1867,14 @@ def _call_chat_completions():
_base_timeout = (
_provider_timeout_cfg
if _provider_timeout_cfg is not None
- else float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
+ else env_float("HERMES_API_TIMEOUT", 1800.0)
)
# Read timeout: config wins here too. Otherwise use
# HERMES_STREAM_READ_TIMEOUT (default 120s) for cloud providers.
if _provider_timeout_cfg is not None:
_stream_read_timeout = _provider_timeout_cfg
else:
- _stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
+ _stream_read_timeout = env_float("HERMES_STREAM_READ_TIMEOUT", 120.0)
# Local providers (Ollama, llama.cpp, vLLM) can take minutes for
# prefill on large contexts before producing the first token.
# Auto-increase the httpx read timeout unless the user explicitly
@@ -2358,12 +2464,19 @@ def _call():
diag=request_client_holder.get("diag"),
)
_close_request_client_once("stream_mid_tool_retry_cleanup")
- try:
- agent._replace_primary_openai_client(
- reason="stream_mid_tool_retry_pool_cleanup"
- )
- except Exception:
- pass
+ if agent.api_mode == "anthropic_messages":
+ try:
+ agent._anthropic_client.close()
+ agent._rebuild_anthropic_client()
+ except Exception:
+ pass
+ else:
+ try:
+ agent._replace_primary_openai_client(
+ reason="stream_mid_tool_retry_pool_cleanup"
+ )
+ except Exception:
+ pass
continue
# SSE error events from proxies (e.g. OpenRouter sends
@@ -2411,12 +2524,19 @@ def _call():
_close_request_client_once("stream_retry_cleanup")
# Also rebuild the primary client to purge
# any dead connections from the pool.
- try:
- agent._replace_primary_openai_client(
- reason="stream_retry_pool_cleanup"
- )
- except Exception:
- pass
+ if agent.api_mode == "anthropic_messages":
+ try:
+ agent._anthropic_client.close()
+ agent._rebuild_anthropic_client()
+ except Exception:
+ pass
+ else:
+ try:
+ agent._replace_primary_openai_client(
+ reason="stream_retry_pool_cleanup"
+ )
+ except Exception:
+ pass
continue
# Retries exhausted. Log the final failure with
# full diagnostic detail (chain, headers,
@@ -2508,7 +2628,7 @@ def _call():
if _cfg_stale is not None:
_stream_stale_timeout_base = _cfg_stale
else:
- _stream_stale_timeout_base = float(os.getenv("HERMES_STREAM_STALE_TIMEOUT", 180.0))
+ _stream_stale_timeout_base = env_float("HERMES_STREAM_STALE_TIMEOUT", 180.0)
# Local providers (Ollama, oMLX, llama-cpp) can take 300+ seconds
# for prefill on large contexts. Disable the stale detector unless
# the user explicitly set HERMES_STREAM_STALE_TIMEOUT.
@@ -2528,6 +2648,17 @@ def _call():
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)
else:
_stream_stale_timeout = _stream_stale_timeout_base
+ # Reasoning-model floor: known reasoning models (Nemotron 3 Ultra,
+ # OpenAI o1/o3, Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ,
+ # xAI Grok reasoning, etc.) routinely exceed the default 180s chat-
+ # model threshold during their thinking phase. The cloud gateway
+ # upstream kills the socket first, surfacing as BrokenPipeError.
+ # Raises the floor only — never overrides explicit user config
+ # (handled by get_provider_stale_timeout above).
+ from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
+ _reasoning_floor = get_reasoning_stale_timeout_floor(api_kwargs.get("model"))
+ if _reasoning_floor is not None:
+ _stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
t = threading.Thread(target=_call, daemon=True)
t.start()
@@ -2576,10 +2707,17 @@ def _call():
pass
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
- try:
- agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
- except Exception:
- pass
+ if agent.api_mode == "anthropic_messages":
+ try:
+ agent._anthropic_client.close()
+ agent._rebuild_anthropic_client()
+ except Exception:
+ pass
+ else:
+ try:
+ agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
+ except Exception:
+ pass
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
@@ -2655,7 +2793,30 @@ def _call():
role="assistant", content=_partial_text, tool_calls=None,
reasoning_content=None,
)
- return SimpleNamespace(
+ # Detect provider output-layer content filtering (e.g. MiniMax
+ # "output new_sensitive (1027)", Azure/OpenAI content_filter,
+ # Anthropic safety refusal). The raw error is about to be
+ # swallowed into a finish_reason=length stub, so classify it HERE
+ # while we still have it and stamp the stub. Retrying such a
+ # content-deterministic filter on the same primary just re-hits
+ # the filter — the conversation loop reads this tag and activates
+ # the fallback chain instead of burning continuation retries.
+ # error_classifier is the single source of truth for "what counts
+ # as a content filter" (#32421).
+ _content_filter_terminated = False
+ try:
+ from agent.error_classifier import classify_api_error, FailoverReason
+ _cls = classify_api_error(
+ result["error"],
+ provider=str(getattr(agent, "provider", "") or ""),
+ model=str(getattr(agent, "model", "") or ""),
+ )
+ _content_filter_terminated = (
+ _cls.reason == FailoverReason.content_policy_blocked
+ )
+ except Exception:
+ _content_filter_terminated = False
+ _stub = SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model=getattr(agent, "model", "unknown"),
choices=[SimpleNamespace(
@@ -2664,6 +2825,9 @@ def _call():
usage=None,
_dropped_tool_names=_partial_names or None,
)
+ if _content_filter_terminated:
+ _stub._content_filter_terminated = True
+ return _stub
raise result["error"]
return result["response"]
diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py
index 4ff6787193..e638a19415 100644
--- a/agent/codex_runtime.py
+++ b/agent/codex_runtime.py
@@ -25,6 +25,61 @@
logger = logging.getLogger(__name__)
+def _codex_note_to_tool_progress(note: dict) -> tuple[str, str, dict] | None:
+ """Map a Codex app-server ``item/started`` notification to a Hermes
+ tool-progress event ``(tool_name, preview, args)``.
+
+ The Codex app-server runtime processes ``item/started`` notifications for
+ command execution, file changes, and MCP/dynamic tool calls, but never
+ surfaced them as Hermes tool-progress events — so gateways (Telegram, etc.)
+ showed no verbose "running X" breadcrumbs on this route while every other
+ provider did (#38835). Returns None for items that aren't tool-shaped.
+ """
+ if not isinstance(note, dict) or note.get("method") != "item/started":
+ return None
+ params = note.get("params") or {}
+ item = params.get("item") or {}
+ if not isinstance(item, dict):
+ return None
+
+ item_type = item.get("type") or ""
+ if item_type == "commandExecution":
+ command = item.get("command") or ""
+ return "exec_command", command, {"command": command, "cwd": item.get("cwd") or ""}
+
+ if item_type == "fileChange":
+ changes = item.get("changes") or []
+ preview = "file changes"
+ if isinstance(changes, list) and changes:
+ paths = [
+ str(change.get("path"))
+ for change in changes
+ if isinstance(change, dict) and change.get("path")
+ ]
+ if paths:
+ preview = ", ".join(paths[:3])
+ if len(paths) > 3:
+ preview += f", +{len(paths) - 3} more"
+ return "apply_patch", preview, {"changes": changes}
+
+ if item_type == "mcpToolCall":
+ server = item.get("server") or "mcp"
+ tool = item.get("tool") or "unknown"
+ args = item.get("arguments") or {}
+ if not isinstance(args, dict):
+ args = {"arguments": args}
+ return f"mcp.{server}.{tool}", tool, args
+
+ if item_type == "dynamicToolCall":
+ tool = item.get("tool") or "unknown"
+ args = item.get("arguments") or {}
+ if not isinstance(args, dict):
+ args = {"arguments": args}
+ return tool, tool, args
+
+ return None
+
+
def _coerce_usage_int(value: Any) -> int:
if isinstance(value, bool):
return 0
@@ -195,7 +250,9 @@ def run_codex_app_server_turn(
# Spawned on first turn, reused across turns, closed at AIAgent
# shutdown (see _cleanup hook).
if not hasattr(agent, "_codex_session") or agent._codex_session is None:
- cwd = getattr(agent, "session_cwd", None) or os.getcwd()
+ from agent.runtime_cwd import resolve_agent_cwd
+
+ cwd = getattr(agent, "session_cwd", None) or str(resolve_agent_cwd())
# Approval callback: defer to Hermes' standard prompt flow if a
# CLI thread has installed one. Gateway / cron contexts get the
# codex-side fail-closed default.
@@ -204,9 +261,27 @@ def run_codex_app_server_turn(
approval_callback = _get_approval_callback()
except Exception:
approval_callback = None
+
+ def _on_codex_event(note: dict) -> None:
+ # Bridge Codex app-server item/started notifications to Hermes
+ # tool-progress so gateways show verbose "running X" breadcrumbs
+ # on this route too (#38835).
+ progress_callback = getattr(agent, "tool_progress_callback", None)
+ if progress_callback is None:
+ return
+ mapped = _codex_note_to_tool_progress(note)
+ if mapped is None:
+ return
+ tool_name, preview, args = mapped
+ try:
+ progress_callback("tool.started", tool_name, preview, args)
+ except Exception:
+ logger.debug("codex tool-progress callback raised", exc_info=True)
+
agent._codex_session = CodexAppServerSession(
cwd=cwd,
approval_callback=approval_callback,
+ on_event=_on_codex_event,
)
# NOTE: the user message is ALREADY appended to messages by the
diff --git a/agent/coding_context.py b/agent/coding_context.py
index ede0dc1528..8fb51a0b04 100644
--- a/agent/coding_context.py
+++ b/agent/coding_context.py
@@ -60,6 +60,8 @@
from pathlib import Path
from typing import Any, Optional
+from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
+
logger = logging.getLogger("hermes.coding_context")
CODING_TOOLSET = "coding"
@@ -83,6 +85,59 @@
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
+# Source-file extensions that make a git repo a *code* workspace even with no
+# manifest. Without this, `git init` on a notes/writing/research folder (a huge
+# non-coding use case) would flip the whole session into the coding posture just
+# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
+_CODE_EXTENSIONS = frozenset({
+ ".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
+ ".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
+ ".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
+ ".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
+ ".hs", ".clj", ".erl", ".pl",
+})
+
+# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
+_CODE_SCAN_SKIP_DIRS = frozenset({
+ ".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
+ "target", ".next", ".turbo", "vendor",
+})
+
+# Bounded sweep: a code workspace reveals itself in the first handful of entries.
+_CODE_SCAN_MAX_ENTRIES = 500
+
+
+def _has_code_files(root: Path) -> bool:
+ """Cheap, bounded check for source files in a repo's top two levels.
+
+ Lets a git repo of loose scripts (no manifest) still read as a code
+ workspace while a bare notes/writing repo does not. Scans the root and its
+ immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
+ a handful of readdirs at session start, not a full walk.
+ """
+ seen = 0
+ stack = [(root, True)]
+ while stack:
+ directory, is_root = stack.pop()
+ try:
+ with os.scandir(directory) as entries:
+ for entry in entries:
+ seen += 1
+ if seen > _CODE_SCAN_MAX_ENTRIES:
+ return False
+ name = entry.name
+ try:
+ if entry.is_file():
+ if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
+ return True
+ elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
+ stack.append((Path(entry.path), False))
+ except OSError:
+ continue
+ except OSError:
+ continue
+ return False
+
# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
@@ -368,10 +423,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
+ # A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
+ # workspace on its own — cheap stat checks, no scan.
+ if _marker_root(cwd) is not None:
+ return CODING_PROFILE.name
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
- if git_root is not None or _marker_root(cwd) is not None:
+ # A bare git repo only counts when it actually holds code, so `git init` on a
+ # notes/writing/research folder stays in the general posture.
+ if git_root is not None and _has_code_files(git_root):
return CODING_PROFILE.name
return GENERAL_PROFILE.name
@@ -588,12 +649,14 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
def _git(cwd: Path, *args: str) -> str:
+ _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
+ **_popen_kwargs,
)
except (OSError, subprocess.SubprocessError):
return ""
@@ -635,25 +698,32 @@ def _read_small(path: Path) -> str:
return ""
-def _project_facts(root: Path) -> list[str]:
- """Detected project facts for the workspace snapshot.
+@dataclass(frozen=True)
+class ProjectFacts:
+ """Structured project facts — the model's verify loop, detected once.
- The point is to hand the model its *verify loop* up front — which manifest,
- which package manager, and the exact test/lint/build commands — instead of
- making it rediscover them every session. Cheap: stat calls plus reads of a
- couple of small files; built once at prompt-build time (cache-safe).
+ The same data that feeds the workspace snapshot, exposed structurally so
+ non-prompt consumers (e.g. the desktop verify UI) read it instead of
+ re-detecting and drifting from the prompt.
"""
- facts: list[str] = []
+ manifests: list[str]
+ package_managers: list[str]
+ verify_commands: list[str]
+ context_files: list[str]
+
+
+def detect_project_facts(root: Path) -> ProjectFacts:
+ """Detect manifests, package manager(s), verify commands, and context files.
+
+ Cheap: stat calls plus reads of a couple of small files. The single source
+ of truth for both the prompt snapshot (:func:`_project_facts`) and the
+ gateway's ``project.facts`` — so the UI never re-sniffs verify commands.
+ """
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
- package_managers = [
- pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
- ]
- if manifests:
- line = f"- Project: {', '.join(manifests[:6])}"
- if package_managers:
- line += f" ({'/'.join(dict.fromkeys(package_managers))})"
- facts.append(line)
+ package_managers = list(
+ dict.fromkeys(pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file())
+ )
verify: list[str] = []
if (root / "scripts" / "run_tests.sh").is_file():
@@ -673,17 +743,61 @@ def _project_facts(root: Path) -> list[str]:
f"make {name}" for name in _VERIFY_TARGETS
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
)
- if verify:
- deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
- facts.append(f"- Verify: {'; '.join(deduped)}")
- context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
- if context_files:
- facts.append(f"- Context files: {', '.join(context_files)}")
+ return ProjectFacts(
+ manifests=manifests,
+ package_managers=package_managers,
+ verify_commands=list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS],
+ context_files=[c for c in _CONTEXT_FILES if (root / c).is_file()],
+ )
+
+
+def _project_facts(root: Path) -> list[str]:
+ """Render :func:`detect_project_facts` as workspace-snapshot lines.
+
+ Hands the model its *verify loop* up front — which manifest, which package
+ manager, and the exact test/lint/build commands — instead of making it
+ rediscover them every session. Built once at prompt-build time; the string
+ output must stay byte-stable to preserve the prompt cache.
+ """
+ f = detect_project_facts(root)
+ facts: list[str] = []
+
+ if f.manifests:
+ line = f"- Project: {', '.join(f.manifests[:6])}"
+ if f.package_managers:
+ line += f" ({'/'.join(f.package_managers)})"
+ facts.append(line)
+ if f.verify_commands:
+ facts.append(f"- Verify: {'; '.join(f.verify_commands)}")
+ if f.context_files:
+ facts.append(f"- Context files: {', '.join(f.context_files)}")
return facts
+def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, Any]]:
+ """Structured project facts for ``cwd`` — ``None`` outside a workspace.
+
+ Same detection the system-prompt snapshot uses (git root, else marker root),
+ exposed for non-prompt consumers (the desktop verify UI) so they never
+ re-derive "are we coding?" or duplicate the verify-command sniffing.
+ """
+ resolved = _resolve_cwd(cwd)
+ root = _git_root(resolved) or _marker_root(resolved)
+ if root is None:
+ return None
+
+ f = detect_project_facts(root)
+ return {
+ "root": str(root),
+ "manifests": f.manifests,
+ "packageManagers": f.package_managers,
+ "verifyCommands": f.verify_commands,
+ "contextFiles": f.context_files,
+ }
+
+
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
"""Workspace snapshot for the system prompt (empty outside a workspace).
diff --git a/agent/context_compressor.py b/agent/context_compressor.py
index 16db1bedc3..fbde99bda5 100644
--- a/agent/context_compressor.py
+++ b/agent/context_compressor.py
@@ -23,7 +23,7 @@
import time
from typing import Any, Dict, List, Optional
-from agent.auxiliary_client import call_llm, _is_connection_error
+from agent.auxiliary_client import call_llm, _is_connection_error, aux_interrupt_protection
from agent.context_engine import ContextEngine
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH,
@@ -248,6 +248,25 @@ def _content_length_for_budget(raw_content: Any) -> int:
return total
+def _estimate_msg_budget_tokens(msg: dict) -> int:
+ """Token estimate for one message in the tail-protection budget walks.
+
+ Counts the message content plus the **full** ``tool_call`` envelope —
+ ``id``, ``type``, ``function.name`` and JSON structure — not just
+ ``function.arguments``. Counting only the arguments string undercounted
+ assistant turns that fan out into parallel tool calls by 2-15x (a
+ 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.
+ """
+ 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
+ return tokens
+
+
def _content_text_for_contains(content: Any) -> str:
"""Return a best-effort text view of message content.
@@ -648,6 +667,7 @@ def update_model(
api_key: Any = "",
provider: str = "",
api_mode: str = "",
+ max_tokens: int | None = None,
) -> None:
"""Update model info after a model switch or fallback activation."""
self.model = model
@@ -656,9 +676,13 @@ def update_model(
self.provider = provider
self.api_mode = api_mode
self.context_length = context_length
- self.threshold_tokens = max(
- int(context_length * self.threshold_percent),
- MINIMUM_CONTEXT_LENGTH,
+ # max_tokens=None here means "caller didn't specify" → keep the existing
+ # output reservation. A switch that genuinely changes the output budget
+ # passes the new value explicitly. (#43547)
+ if max_tokens is not None:
+ self.max_tokens = self._coerce_max_tokens(max_tokens)
+ self.threshold_tokens = self._compute_threshold_tokens(
+ context_length, self.threshold_percent, self.max_tokens,
)
# Recalculate token budgets for the new context length so the
# compressor stays calibrated after a model switch (e.g. 200K → 32K).
@@ -668,6 +692,94 @@ def update_model(
int(context_length * 0.05), _SUMMARY_TOKENS_CEILING,
)
+ # Reset cross-call calibration state captured under the PREVIOUS model.
+ # These fields encode "the provider proved this prompt fit" / "preflight
+ # can be deferred" decisions that are only valid for the model that
+ # produced them. Carrying them across a switch to a smaller-context
+ # model would let should_defer_preflight_to_real_usage() suppress a
+ # preflight compression the new model actually needs — the exact
+ # oversized-send-after-switch failure in #23767. The new model's first
+ # response repopulates them via update_from_response(). Setting
+ # last_prompt_tokens to 0 (NOT -1) is deliberate: 0 is the documented
+ # "no real usage yet -> use the rough estimate" state, so the post-
+ # response should_compress path falls back to estimate_request_tokens_rough
+ # rather than skipping compression. -1 is a different sentinel
+ # (#36718, "compression just ran, await real usage") and must not be set here.
+ self.last_prompt_tokens = 0
+ self.last_completion_tokens = 0
+ self.last_total_tokens = 0
+ self.last_real_prompt_tokens = 0
+ self.last_rough_tokens_when_real_prompt_fit = 0
+ self.last_compression_rough_tokens = 0
+ self.awaiting_real_usage_after_compression = False
+ self._ineffective_compression_count = 0
+
+ # When the MINIMUM_CONTEXT_LENGTH floor meets/exceeds a small context
+ # window, compacting at the percentage (50% → 32K of a 64K window) wastes
+ # half the usable context. Trigger near the top of the window instead so a
+ # minimum-context model uses most of its budget before compacting — same
+ # rationale as the gpt-5.5/Codex 85% autoraise.
+ _MIN_CTX_TRIGGER_RATIO = 0.85
+
+ @staticmethod
+ def _coerce_max_tokens(value: Any) -> int | None:
+ """Normalize a max_tokens value to a positive int or None.
+
+ Only a positive integer is a real output reservation. None (provider
+ default), non-numeric values, or <= 0 all mean "no reservation" — this
+ keeps the threshold arithmetic safe from non-int inputs (e.g. a test
+ MagicMock reaching ContextCompressor via a mocked parent agent).
+ """
+ if value is None:
+ return None
+ try:
+ ivalue = int(value)
+ except (TypeError, ValueError):
+ return None
+ return ivalue if ivalue > 0 else None
+
+ @staticmethod
+ def _compute_threshold_tokens(
+ context_length: int, threshold_percent: float, max_tokens: int | None = None,
+ ) -> int:
+ """Compute the compaction trigger threshold in tokens.
+
+ The base value is ``effective_input_budget * threshold_percent``, floored
+ at ``MINIMUM_CONTEXT_LENGTH`` so large-context models don't compress
+ prematurely at 50%. BUT that floor degenerates at small windows: for a
+ model whose ``context_length`` is at/below the minimum (e.g. a 64K
+ local model), ``max(0.5*64000, 64000) == 64000`` makes the threshold
+ equal the ENTIRE window — auto-compression can never fire because the
+ provider rejects the request before usage reaches 100% (#14690).
+
+ When the floor would meet or exceed the context window, trigger at
+ ``_MIN_CTX_TRIGGER_RATIO`` (85%) of the window — high enough that a
+ small model uses most of its context before compacting, but below
+ 100% so compaction fires before the provider rejects the request.
+
+ The provider reserves ``max_tokens`` of output space out of the same
+ window, so the usable INPUT budget is ``context_length - max_tokens``.
+ With a large ``max_tokens`` (e.g. 65536 on a custom provider) the input
+ budget is materially smaller than the raw window, and a threshold based
+ on the full window lets the session hit a provider 400 before compaction
+ fires (#43547). The percentage and the degenerate-window check below both
+ operate on the effective input budget. ``max_tokens=None`` (provider
+ default) conservatively assumes no reservation (full window).
+ """
+ effective_window = context_length - (max_tokens or 0)
+ if effective_window <= 0:
+ effective_window = context_length
+ pct_value = int(effective_window * threshold_percent)
+ floored = max(pct_value, MINIMUM_CONTEXT_LENGTH)
+ # If flooring pushed the threshold to/over the effective window it can
+ # never be reached. Trigger at 85% of the effective input budget so a
+ # minimum-context model rides most of its budget before compacting
+ # instead of wasting half.
+ if effective_window > 0 and floored >= effective_window:
+ return max(1, min(int(effective_window * ContextCompressor._MIN_CTX_TRIGGER_RATIO),
+ effective_window - 1))
+ return floored
+
def __init__(
self,
model: str,
@@ -683,6 +795,7 @@ def __init__(
provider: str = "",
api_mode: str = "",
abort_on_summary_failure: bool = False,
+ max_tokens: int | None = None,
):
self.model = model
self.base_url = base_url
@@ -694,6 +807,13 @@ def __init__(
self.protect_last_n = protect_last_n
self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80))
self.quiet_mode = quiet_mode
+ # Output-token reservation: the provider carves max_tokens out of the
+ # context window, so the usable input budget is context_length -
+ # max_tokens. None = provider default => assume no reservation. (#43547)
+ # Coerce defensively: only a positive int is a real reservation; any
+ # other value (None, non-numeric, <=0) means "no reservation" so the
+ # threshold arithmetic never sees a non-int (e.g. a test MagicMock).
+ self.max_tokens = self._coerce_max_tokens(max_tokens)
# When True, summary-generation failure aborts compression entirely
# (returns messages unchanged, sets _last_compress_aborted=True).
# When False (default = historical behavior), insert a
@@ -708,10 +828,11 @@ def __init__(
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
# the percentage would suggest a lower value. This prevents premature
# compression on large-context models at 50% while keeping the % sane
- # for models right at the minimum.
- self.threshold_tokens = max(
- int(self.context_length * threshold_percent),
- MINIMUM_CONTEXT_LENGTH,
+ # for models right at the minimum. _compute_threshold_tokens also
+ # guards the degenerate case where the floor would equal/exceed the
+ # window (small models), so auto-compression can still fire (#14690).
+ self.threshold_tokens = self._compute_threshold_tokens(
+ self.context_length, threshold_percent, self.max_tokens,
)
self.compression_count = 0
@@ -761,7 +882,23 @@ def __init__(
# this flag to know "compression was attempted but aborted, freeze
# the chat until the user manually retries via /compress".
self._last_compress_aborted: bool = False
- # When a user-configured summary model fails and we recover by
+ # Set True when the summary call failed with an authentication /
+ # permission error (HTTP 401/403). Auth failures are non-recoverable
+ # at the request level — the credential or endpoint is broken — so
+ # compress() must ABORT (preserve the session unchanged) rather than
+ # rotate into a degraded child session with a placeholder summary.
+ # This is independent of the abort_on_summary_failure config flag:
+ # rotating on a broken credential is never the right behavior.
+ self._last_summary_auth_failure: bool = False
+ # Set when summary generation ultimately fails due to a transient
+ # network/connection error (httpx/httpcore connection drop, premature
+ # stream close, etc.) — distinct from auth failures but treated the
+ # same way by compress(): ABORT and preserve the session unchanged
+ # rather than destroy the middle window for a deterministic
+ # "summary unavailable" marker. Retrying once the network recovers is
+ # strictly better than discarding context for a transient blip
+ # (#29559, #25585). Independent of abort_on_summary_failure.
+ self._last_summary_network_failure: bool = False
# retrying on the main model, record the failure so gateway /
# CLI callers can still warn the user even though compression
# succeeded. Silent recovery would hide the broken config.
@@ -795,6 +932,18 @@ def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
"""
if rough_tokens < self.threshold_tokens:
return False
+ # Immediately after a compaction the post-compression path sets
+ # ``awaiting_real_usage_after_compression`` and parks
+ # ``last_prompt_tokens = -1``, but ``last_real_prompt_tokens`` still
+ # holds the STALE pre-compression value (above threshold — that's why
+ # compaction fired). Without this guard that stale value defeats the
+ # ``last_real_prompt_tokens >= threshold_tokens`` check below, so
+ # preflight fires a SECOND compaction before the provider has reported
+ # real token usage for the now-shorter conversation. Defer for exactly
+ # one turn; update_from_response() clears the flag when real usage
+ # arrives. (#36718)
+ if self.awaiting_real_usage_after_compression:
+ return True
if self.last_real_prompt_tokens <= 0:
return False
if self.last_real_prompt_tokens >= self.threshold_tokens:
@@ -891,13 +1040,7 @@ def _prune_old_tool_results(
min_protect = min(protect_tail_count, len(result))
for i in range(len(result) - 1, -1, -1):
msg = result[i]
- raw_content = msg.get("content") or ""
- content_len = _content_length_for_budget(raw_content)
- msg_tokens = content_len // _CHARS_PER_TOKEN + 10
- for tc in msg.get("tool_calls") or []:
- if isinstance(tc, dict):
- args = tc.get("function", {}).get("arguments", "")
- msg_tokens += len(args) // _CHARS_PER_TOKEN
+ msg_tokens = _estimate_msg_budget_tokens(msg)
if accumulated + msg_tokens > protect_tail_tokens and (len(result) - i) >= min_protect:
boundary = i
break
@@ -1245,7 +1388,10 @@ def _bullets(items: list[str], limit: int = 8) -> str:
Unknown from deterministic fallback. Inspect current repository/session state if needed.
{HISTORICAL_IN_PROGRESS_HEADING}
-{active_task}
+Unknown from deterministic fallback — the latest user ask is recorded once under
+"{HISTORICAL_TASK_HEADING}" above as historical context only. Do NOT treat it as an
+unfulfilled instruction to re-answer; verify current state and continue from the
+protected recent messages after this summary.
## Blocked
{_bullets(blockers, limit=5)}
@@ -1257,7 +1403,9 @@ def _bullets(items: list[str], limit: int = 8) -> str:
None recoverable from deterministic fallback.
{HISTORICAL_PENDING_ASKS_HEADING}
-{active_task}
+None recoverable from deterministic fallback. (The latest user ask is preserved once
+under "{HISTORICAL_TASK_HEADING}" as historical context — it is NOT necessarily
+outstanding.)
## Relevant Files
{_bullets(relevant_files, limit=12)}
@@ -1511,11 +1659,33 @@ def _generate_summary(
}
if self.summary_model:
call_kwargs["model"] = self.summary_model
- response = call_llm(**call_kwargs)
+ # Compression is atomic: protect the in-flight summary call from a
+ # mid-turn gateway interrupt. Without this, an incoming user message
+ # aborts the summary and compression falls back to a degraded static
+ # marker, losing the real handoff (#23975). Re-entrant: a main-model
+ # retry (_generate_summary recursion) re-enters harmlessly.
+ with aux_interrupt_protection():
+ response = call_llm(**call_kwargs)
content = response.choices[0].message.content
# Handle cases where content is not a string (e.g., dict from llama.cpp)
if not isinstance(content, str):
content = str(content) if content else ""
+ # Some OpenAI-compatible proxies (e.g. cmkey.cn, one-api channels)
+ # return a well-formed HTTP 200 with an empty or whitespace-only
+ # ``content`` instead of an error or empty ``choices``. That payload
+ # passes ``_validate_llm_response`` (a ``message`` exists), so it
+ # reaches here and would otherwise be stored as a prefix-only
+ # summary with no body — silently wiping the compacted turns and
+ # making the model forget the in-progress task (#11978, #11914).
+ # Treat empty content as a failure so it routes through the same
+ # main-model fallback + cooldown machinery as a transport error,
+ # rather than replacing real context with an empty summary.
+ if not content.strip():
+ raise RuntimeError(
+ "Context compression LLM returned empty content "
+ f"(provider={self.provider or 'auto'} "
+ f"model={self.summary_model or self.model})"
+ )
# Redact the summary output as well — the summarizer LLM may
# ignore prompt instructions and echo back secrets verbatim.
summary = redact_sensitive_text(content.strip())
@@ -1524,17 +1694,30 @@ def _generate_summary(
self._summary_failure_cooldown_until = 0.0
self._summary_model_fallen_back = False
self._last_summary_error = None
+ self._last_summary_auth_failure = False
+ self._last_summary_network_failure = False
return self._with_summary_prefix(summary)
- except RuntimeError:
- # No provider configured — long cooldown, unlikely to self-resolve
- self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
- self._last_summary_error = "no auxiliary LLM provider configured"
- logger.warning("Context compression: no provider available for "
- "summary. Middle turns will be dropped without summary "
- "for %d seconds.",
- _SUMMARY_FAILURE_COOLDOWN_SECONDS)
- return None
except Exception as e:
+ # ``call_llm`` raises ``RuntimeError`` for two very different cases:
+ # 1. No provider configured ("No LLM provider configured ...") —
+ # a permanent misconfiguration, long cooldown is correct.
+ # 2. An empty/invalid response from a configured provider
+ # (``_validate_llm_response`` empty-``choices``/``None``, or our
+ # empty-``content`` guard above) — a transient/proxy fault that
+ # should fall back to the main model first, exactly like the
+ # transport errors handled below.
+ # Only (1) belongs in the long no-provider cooldown; (2) and every
+ # other exception flow into the generic fallback logic so they get
+ # a main-model retry before any cooldown. (#11978, #11914)
+ if isinstance(e, RuntimeError) and "no llm provider configured" in str(e).lower():
+ # No provider configured — long cooldown, unlikely to self-resolve
+ self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
+ self._last_summary_error = "no auxiliary LLM provider configured"
+ logger.warning("Context compression: no provider available for "
+ "summary. Middle turns will be dropped without summary "
+ "for %d seconds.",
+ _SUMMARY_FAILURE_COOLDOWN_SECONDS)
+ return None
# If the summary model is different from the main model and the
# error looks permanent (model not found, 503, 404), fall back to
# using the main model instead of entering cooldown that leaves
@@ -1571,6 +1754,26 @@ def _generate_summary(
# back to the main model instead of entering a 60-second cooldown.
# See issue #18458.
_is_streaming_closed = _is_connection_error(e)
+ # Authentication / permission failures (401/403) are NOT transient
+ # and NOT fixable by retrying the same request: the credential is
+ # invalid/blocked/expired or the endpoint is wrong (e.g. a prod
+ # token sent to a staging inference URL). Flag them so compress()
+ # aborts and preserves the session instead of rotating into a
+ # degraded child with a placeholder summary. We still allow the
+ # one-shot fallback to the MAIN model below when the failure came
+ # from a distinct auxiliary summary_model (its dedicated creds may
+ # be the only broken thing); only a failure on the main model — or
+ # a fallback that also auth-fails — makes the abort stick.
+ _is_auth_error = (
+ _status in {401, 403}
+ or "invalid api key" in _err_str
+ or "invalid x-api-key" in _err_str
+ or ("api key" in _err_str and ("invalid" in _err_str or "blocked" in _err_str))
+ or "unauthorized" in _err_str
+ or "authentication" in _err_str
+ )
+ if _is_auth_error:
+ self._last_summary_auth_failure = True
if _is_json_decode and not _is_model_not_found and not _is_timeout:
logger.error(
"Context compression failed: auxiliary LLM returned a "
@@ -1625,6 +1828,15 @@ def _generate_summary(
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
self._last_summary_error = err_text
+ # A terminal connection/network failure (we reach this branch only
+ # after any main-model fallback has already been tried or is
+ # unavailable). Flag it so compress() ABORTS and preserves the
+ # session unchanged instead of destroying the middle window for a
+ # placeholder marker — retrying once the network recovers is
+ # strictly better than dropping context (#29559, #25585). Mirrors
+ # the auth-failure carve-out; independent of abort_on_summary_failure.
+ if _is_streaming_closed:
+ self._last_summary_network_failure = True
logger.warning(
"Failed to generate context summary: %s. "
"Further summary attempts paused for %d seconds.",
@@ -1809,6 +2021,23 @@ def _align_boundary_forward(self, messages: List[Dict[str, Any]], idx: int) -> i
idx += 1
return idx
+ def _effective_protect_first_n(self) -> int:
+ """``protect_first_n`` decayed across compression cycles.
+
+ ``protect_first_n`` keeps the first N non-system messages verbatim so
+ the original task framing survives the FIRST compaction. But applying
+ it on every subsequent pass fossilizes those early turns — they're
+ re-copied into each child session and never summarized away, so old
+ user messages become immortal and grow the head unboundedly across a
+ long session (#11996). Once the session has been compressed at least
+ once, the early turns are already captured in the handoff summary, so
+ there's no need to keep re-protecting them: decay to 0 (the system
+ prompt is still always protected separately by _protect_head_size).
+ """
+ if self.compression_count >= 1 or self._previous_summary:
+ return 0
+ return self.protect_first_n
+
def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:
"""Total count of head messages to protect.
@@ -1820,14 +2049,19 @@ def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:
the ``messages`` list (e.g. the gateway ``/compress`` handler
strips it before calling compress()).
- Examples:
+ The ``protect_first_n`` portion DECAYS after the first compression
+ (see _effective_protect_first_n) so early user turns don't fossilize
+ across repeated compactions (#11996).
+
+ Examples (first compaction):
protect_first_n=0 → system prompt only (or nothing if no system msg)
protect_first_n=3 → system + first 3 non-system messages
+ After the first compaction: system prompt only.
"""
head = 0
if messages and messages[0].get("role") == "system":
head = 1
- return head + self.protect_first_n
+ return head + self._effective_protect_first_n()
def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int:
"""Pull a compress-end boundary backward to avoid splitting a
@@ -2055,14 +2289,7 @@ def _find_tail_cut_by_tokens(
for i in range(n - 1, head_end - 1, -1):
msg = messages[i]
- raw_content = msg.get("content") or ""
- content_len = _content_length_for_budget(raw_content)
- msg_tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/metadata
- # Include tool call arguments in estimate
- for tc in msg.get("tool_calls") or []:
- if isinstance(tc, dict):
- args = tc.get("function", {}).get("arguments", "")
- msg_tokens += len(args) // _CHARS_PER_TOKEN
+ msg_tokens = _estimate_msg_budget_tokens(msg)
# Stop once we exceed the soft ceiling (unless we haven't hit min_tail yet)
if accumulated + msg_tokens > soft_ceiling and (n - i) >= min_tail:
break
@@ -2088,13 +2315,7 @@ def _find_tail_cut_by_tokens(
raw_accumulated = 0
for j in range(n - 1, head_end - 1, -1):
raw_msg = messages[j]
- raw_content = raw_msg.get("content") or ""
- raw_len = _content_length_for_budget(raw_content)
- raw_tok = raw_len // _CHARS_PER_TOKEN + 10
- for tc in raw_msg.get("tool_calls") or []:
- if isinstance(tc, dict):
- args = tc.get("function", {}).get("arguments", "")
- raw_tok += len(args) // _CHARS_PER_TOKEN
+ raw_tok = _estimate_msg_budget_tokens(raw_msg)
if raw_accumulated + raw_tok > raw_budget and (n - j) >= min_tail:
cut_idx = j
break
@@ -2178,6 +2399,8 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
self._last_aux_model_failure_error = None
self._last_aux_model_failure_model = None
self._last_compress_aborted = False
+ self._last_summary_auth_failure = False
+ self._last_summary_network_failure = False
# Manual /compress (force=True) bypasses the failure cooldown so the
# user can retry immediately after an auto-compress abort. Without
@@ -2293,19 +2516,53 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
# _last_summary_dropped_count for gateway hygiene to
# surface a warning.
# Default is False (historical behavior).
- if not summary and self.abort_on_summary_failure:
+ #
+ # EXCEPTION — auth AND transient network failures always abort. A
+ # 401/403 from the summary call means the credential or endpoint is
+ # broken (invalid/blocked key, or a token pointed at the wrong
+ # inference host). A connection/stream-close error means the network
+ # blipped at the compaction moment (#29559). In BOTH cases rotating into
+ # a child session with a placeholder summary on a broken credential
+ # strands the user on a degraded session for zero benefit — every
+ # subsequent call fails the same way. So when the failure was an auth
+ # error we abort regardless of abort_on_summary_failure, preserving
+ # the conversation unchanged until the credential is fixed.
+ if not summary and (
+ self.abort_on_summary_failure
+ or self._last_summary_auth_failure
+ or self._last_summary_network_failure
+ ):
n_skipped = compress_end - compress_start
self._last_summary_dropped_count = 0 # nothing actually dropped
self._last_summary_fallback_used = False
self._last_compress_aborted = True
if not self.quiet_mode:
- logger.warning(
- "Summary generation failed — aborting compression "
- "(compression.abort_on_summary_failure=true). "
- "%d message(s) preserved unchanged. Conversation is "
- "frozen until the next /compress or /new.",
- n_skipped,
- )
+ if self._last_summary_auth_failure:
+ logger.warning(
+ "Summary generation failed with an authentication "
+ "error — aborting compression. %d message(s) preserved "
+ "unchanged; the session was NOT rotated. Check your "
+ "provider credential / inference endpoint, then retry "
+ "with /compress or start fresh with /new.",
+ n_skipped,
+ )
+ elif self._last_summary_network_failure:
+ logger.warning(
+ "Summary generation failed with a network/connection "
+ "error — aborting compression. %d message(s) preserved "
+ "unchanged; the session was NOT rotated. This is "
+ "transient: retry with /compress once connectivity "
+ "recovers, or continue the conversation as-is.",
+ n_skipped,
+ )
+ else:
+ logger.warning(
+ "Summary generation failed — aborting compression "
+ "(compression.abort_on_summary_failure=true). "
+ "%d message(s) preserved unchanged. Conversation is "
+ "frozen until the next /compress or /new.",
+ n_skipped,
+ )
return messages
# Phase 4: Assemble compressed message list
diff --git a/agent/context_references.py b/agent/context_references.py
index 6307033d27..fad1ff0015 100644
--- a/agent/context_references.py
+++ b/agent/context_references.py
@@ -12,6 +12,7 @@
from typing import Awaitable, Callable
from agent.model_metadata import estimate_tokens_rough
+from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
_QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
REFERENCE_PATTERN = re.compile(
@@ -290,6 +291,7 @@ def _expand_git_reference(
args: list[str],
label: str,
) -> tuple[str | None, str | None]:
+ _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try:
result = subprocess.run(
["git", *args],
@@ -298,6 +300,7 @@ def _expand_git_reference(
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
+ **_popen_kwargs,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -483,6 +486,7 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
+ _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try:
result = subprocess.run(
["rg", "--files", str(path.relative_to(cwd))],
@@ -491,6 +495,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
+ **_popen_kwargs,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py
index 89bb4ceb55..b16765ea9b 100644
--- a/agent/conversation_compression.py
+++ b/agent/conversation_compression.py
@@ -90,6 +90,7 @@ def check_compression_model_feasibility(agent: Any) -> None:
try:
from agent.auxiliary_client import (
_resolve_task_provider_model,
+ _try_configured_fallback_for_unavailable_client,
get_text_auxiliary_client,
)
from agent.model_metadata import (
@@ -97,10 +98,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
get_model_context_length,
)
- client, aux_model = get_text_auxiliary_client(
- "compression",
- main_runtime=agent._current_main_runtime(),
- )
# Best-effort aux provider label for the warning message. The
# configured provider may be "auto", in which case we fall back
# to the client's base_url hostname so the user can still tell
@@ -109,6 +106,19 @@ def check_compression_model_feasibility(agent: Any) -> None:
_aux_cfg_provider, _, _, _, _ = _resolve_task_provider_model("compression")
except Exception:
_aux_cfg_provider = ""
+ client, aux_model = get_text_auxiliary_client(
+ "compression",
+ main_runtime=agent._current_main_runtime(),
+ )
+ if client is None or not aux_model:
+ fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
+ "compression",
+ _aux_cfg_provider,
+ )
+ if fb_client is not None and fb_model:
+ client, aux_model = fb_client, fb_model
+ if "(" in fb_label and fb_label.endswith(")"):
+ _aux_cfg_provider = fb_label.rsplit("(", 1)[1][:-1]
if client is None or not aux_model:
if _aux_cfg_provider and _aux_cfg_provider != "auto":
msg = (
@@ -278,6 +288,29 @@ def replay_compression_warning(agent: Any) -> None:
pass
+def conversation_history_after_compression(agent: Any, messages: list) -> Optional[list]:
+ """Return the correct flush baseline after a compression boundary.
+
+ Legacy compression rotates to a fresh child session. That child has not
+ seen the compacted transcript through the normal same-turn flush path yet,
+ so callers must clear ``conversation_history`` to ``None`` and let the next
+ persistence call write the whole compacted list.
+
+ In-place compaction is different: ``archive_and_compact()`` has already
+ soft-archived the previous active rows and inserted ``messages`` as the new
+ active live transcript under the same session id. If the same agent turn
+ continues with ``conversation_history=None``, the identity-based flush path
+ treats those already-persisted compacted dicts as new and appends them a
+ second time, doubling the active context and retriggering compression.
+
+ A shallow copy is intentional: it captures the current compacted dict
+ identities as history while allowing later same-turn appends to remain new.
+ """
+ if bool(getattr(agent, "_last_compaction_in_place", False)):
+ return list(messages)
+ return None
+
+
def compress_context(
agent: Any,
messages: list,
@@ -328,6 +361,16 @@ def compress_context(
agent._compression_feasibility_checked = True
_pre_msg_count = len(messages)
+ # In-place compaction (config: compression.in_place, see #38763). When True,
+ # this compaction rewrites the message list + rebuilds the system prompt but
+ # keeps the SAME session_id — no end_session, no parent_session_id child, no
+ # `name #N` renumber, no contextvar/env/logging re-sync, no memory/context-
+ # engine session-switch. The conversation keeps one durable id for life,
+ # eliminating the session-rotation bug cluster. Default False during rollout.
+ in_place = bool(getattr(agent, "compression_in_place", False))
+ # Set True once the in-place DB write actually completes (the DB block can
+ # raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place.
+ compacted_in_place = False
logger.info(
"context compression started: session=%s messages=%d tokens=~%s model=%s focus=%r",
agent.session_id or "none", _pre_msg_count,
@@ -508,125 +551,244 @@ def _release_lock() -> None:
if agent._session_db:
try:
- # Propagate title to the new session with auto-numbering
- old_title = agent._session_db.get_session_title(agent.session_id)
- # Trigger memory extraction on the old session before it rotates.
+ # Trigger memory extraction on the current session before the
+ # transcript is rewritten (runs in BOTH modes — the logical
+ # conversation's pre-compaction turns are about to be summarized
+ # away regardless of whether the id rotates).
agent.commit_memory_session(messages)
- # Flush any un-persisted messages from the current turn to the
- # old session *before* rotating. compress_context() can be
- # called mid-turn (auto-compress when context exceeds threshold)
- # at a point when _flush_messages_to_session_db() has not yet
- # run. Without this, messages generated during the current turn
- # are silently lost on session rotation (#47202).
- try:
- agent._flush_messages_to_session_db(messages)
- except Exception:
- pass # best-effort — don't block compression on a flush error
- agent._session_db.end_session(agent.session_id, "compression")
- old_session_id = agent.session_id
- agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
- # Ordering contract: the agent thread updates the contextvar here;
- # the gateway propagates to SessionEntry after run_in_executor returns.
- try:
- from gateway.session_context import set_current_session_id
- set_current_session_id(agent.session_id)
- except Exception:
- os.environ["HERMES_SESSION_ID"] = agent.session_id
- # The gateway/tools session context (ContextVar + env) and the
- # logging session context are SEPARATE mechanisms. The call above
- # moves the former; the ``[session_id]`` tag on log lines comes
- # from ``hermes_logging._session_context`` (set once per turn in
- # conversation_loop.py). Without this, post-rotation log lines in
- # the same turn keep the STALE old id while the message/DB/gateway
- # state carry the new one — breaking log correlation exactly at the
- # compaction boundary (see #34089). Guarded separately so a logging
- # failure can never regress the routing update above.
- try:
- from hermes_logging import set_session_context
+ if in_place:
+ # ── In-place compaction: keep the same session_id ──────────
+ # No end_session, no new row, no parent_session_id, no title
+ # renumber, no contextvar/env/logging re-sync. The session's
+ # id, title, cwd, /goal, and gateway routing all stay put.
+ #
+ # Durable, NON-DESTRUCTIVE replace: soft-archive the
+ # pre-compaction turns (active=0, kept on disk + FTS-searchable +
+ # recoverable) and insert `compressed` as the new live (active=1)
+ # set, atomically. `compressed` already carries the surviving
+ # tail (current-turn messages the compressor kept via
+ # protect_last_n), so we DON'T pre-flush here — a flush would
+ # INSERT current-turn rows that archive_and_compact would then
+ # archive alongside the rest (harmless but wasted writes). The
+ # live-context load filters active=1, so a resume reloads ONLY
+ # the compacted set; the original turns remain under the SAME id
+ # for search/recovery (Teknium review — keep one durable id
+ # WITHOUT destroying history, unlike a hard replace_messages).
+ # See #38763.
+ agent._session_db.archive_and_compact(agent.session_id, compressed)
+ # Reset the flush identity set so the next turn's appends are
+ # diffed against the COMPACTED transcript: the compacted dicts
+ # are passed as conversation_history next turn and skipped by
+ # identity, so only genuinely new turn messages get appended
+ # (no dup of the summary, no resurrection of dropped turns).
+ agent._flushed_db_message_ids = set()
+ # Rotation-independent signal: the conversation was compacted in
+ # place (id unchanged). The gateway reads this (NOT an id-change
+ # diff) to re-baseline transcript handling.
+ compacted_in_place = True
+ else:
+ # ── Rotation (legacy): end this session, fork a continuation ─
+ # Flush any un-persisted current-turn messages to the OLD
+ # session before ending it, so they survive in the preserved
+ # parent transcript (#47202). (In-place skips this — see above.)
+ try:
+ agent._flush_messages_to_session_db(messages)
+ except Exception:
+ pass # best-effort — don't block compression on a flush error
+ # Propagate title to the new session with auto-numbering
+ old_title = agent._session_db.get_session_title(agent.session_id)
+ agent._session_db.end_session(agent.session_id, "compression")
+ old_session_id = agent.session_id
+ agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
+ # Ordering contract: the agent thread updates the contextvar here;
+ # the gateway propagates to SessionEntry after run_in_executor returns.
+ try:
+ from gateway.session_context import set_current_session_id
- set_session_context(agent.session_id)
- except Exception:
- pass
- agent._session_db_created = False
- agent._session_db.create_session(
- session_id=agent.session_id,
- source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
- model=agent.model,
- model_config=agent._session_init_model_config,
- parent_session_id=old_session_id,
- )
- agent._session_db_created = True
- # Auto-number the title for the continuation session
- if old_title:
+ set_current_session_id(agent.session_id)
+ except Exception:
+ os.environ["HERMES_SESSION_ID"] = agent.session_id
+ # The gateway/tools session context (ContextVar + env) and the
+ # logging session context are SEPARATE mechanisms. The call above
+ # moves the former; the ``[session_id]`` tag on log lines comes
+ # from ``hermes_logging._session_context`` (set once per turn in
+ # conversation_loop.py). Without this, post-rotation log lines in
+ # the same turn keep the STALE old id while the message/DB/gateway
+ # state carry the new one — breaking log correlation exactly at the
+ # compaction boundary (see #34089). Guarded separately so a logging
+ # failure can never regress the routing update above.
+ try:
+ from hermes_logging import set_session_context
+
+ set_session_context(agent.session_id)
+ except Exception:
+ pass
+ agent._session_db_created = False
+ try:
+ agent._session_db.create_session(
+ session_id=agent.session_id,
+ source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
+ model=agent.model,
+ model_config=agent._session_init_model_config,
+ parent_session_id=old_session_id,
+ )
+ except Exception as _cs_err:
+ # The child row could not be created (e.g. FK constraint,
+ # contended write). Previously the outer handler simply
+ # warned and let the agent continue on the NEW id — which
+ # has no row in state.db, producing an orphan: the parent
+ # is ended, the child is never indexed, and every
+ # subsequent message is attributed to a session that
+ # doesn't exist (#33906/#33907). Roll the live id back to
+ # the parent so the conversation stays attached to a real,
+ # indexed session instead of a phantom.
+ logger.warning(
+ "Compression child session create failed (%s) — "
+ "rolling back to parent session %s to avoid an orphan.",
+ _cs_err, old_session_id,
+ )
+ agent.session_id = old_session_id
+ try:
+ from gateway.session_context import set_current_session_id
+ set_current_session_id(agent.session_id)
+ except Exception:
+ os.environ["HERMES_SESSION_ID"] = agent.session_id
+ try:
+ from hermes_logging import set_session_context
+ set_session_context(agent.session_id)
+ except Exception:
+ pass
+ # Re-open the parent: it was ended above, but we're
+ # continuing on it, so it must not stay closed.
+ try:
+ agent._session_db.reopen_session(old_session_id)
+ except Exception:
+ pass
+ old_session_id = None # no rotation happened
+ # The parent row already exists in state.db, so mark the
+ # session as created — _ensure_db_session would otherwise
+ # retry a (harmless INSERT OR IGNORE) create next turn.
+ agent._session_db_created = True
+ raise
+ agent._session_db_created = True
+ # Carry a persistent /goal onto the continuation session.
+ # Compression mints a fresh child id; load_goal does a flat
+ # per-session lookup with no parent walk, so without this an
+ # active goal silently dies at the boundary (#33618).
try:
- new_title = agent._session_db.get_next_title_in_lineage(old_title)
- agent._session_db.set_session_title(agent.session_id, new_title)
- except (ValueError, Exception) as e:
- logger.debug("Could not propagate title on compression: %s", e)
+ from hermes_cli.goals import migrate_goal_to_session
+ migrate_goal_to_session(old_session_id, agent.session_id, reason="compression")
+ except Exception as _goal_err:
+ logger.debug("Could not migrate goal on compression: %s", _goal_err)
+ # Auto-number the title for the continuation session
+ if old_title:
+ try:
+ new_title = agent._session_db.get_next_title_in_lineage(old_title)
+ agent._session_db.set_session_title(agent.session_id, new_title)
+ except (ValueError, Exception) as e:
+ logger.debug("Could not propagate title on compression: %s", e)
+
+ # Shared post-write steps (both modes target agent.session_id, which
+ # in-place keeps and rotation has already reassigned to the new id):
+ # refresh the stored system prompt and reset the flush cursor so the
+ # next turn re-bases its append diff.
agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
- # Reset flush cursor — new session starts with no messages written
agent._last_flushed_db_idx = 0
except Exception as e:
- logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
-
- # Notify the context engine that the session_id rotated because of
- # compression (not a fresh /new). Plugin engines (e.g. hermes-lcm) use
- # boundary_reason="compression" to preserve DAG lineage across the
- # rollover instead of re-initializing fresh per-session state.
- # See hermes-lcm#68. Built-in ContextCompressor ignores kwargs.
+ # If the rotation rolled back to the parent (orphan-avoidance
+ # above), agent.session_id is the still-indexed parent and
+ # old_session_id was cleared — so this is recovery, not an
+ # un-indexed orphan. Otherwise an earlier step failed before the
+ # child was created and the warning's original meaning holds.
+ if locals().get("old_session_id") is None and not in_place:
+ logger.warning(
+ "Compression rotation aborted and rolled back to the "
+ "parent session (%s): %s", agent.session_id or "?", e,
+ )
+ else:
+ logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
+
+ # Compaction-boundary bookkeeping, computed once. `old_session_id` is only
+ # bound in the rotation branch; in-place leaves it unset. `_boundary_parent`
+ # is the id the boundary notifications attribute the prior state to: the old
+ # id on rotation, the (unchanged) current id in-place.
+ _old_sid = locals().get("old_session_id")
+ _is_boundary = bool(_old_sid) or in_place
+ _boundary_parent = _old_sid or agent.session_id or ""
+
+ # Notify the context engine that a compaction boundary occurred. Plugin
+ # engines (e.g. hermes-lcm) use boundary_reason="compression" to preserve
+ # DAG lineage / checkpoint per-session state across the boundary instead of
+ # re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor
+ # ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place
+ # passes the SAME id (the boundary is real even though the id didn't move).
try:
- _old_sid = locals().get("old_session_id")
- if _old_sid and hasattr(agent.context_compressor, "on_session_start"):
+ if _is_boundary and hasattr(agent.context_compressor, "on_session_start"):
agent.context_compressor.on_session_start(
agent.session_id or "",
boundary_reason="compression",
- old_session_id=_old_sid,
+ old_session_id=_boundary_parent,
+ platform=getattr(agent, "platform", None) or "cli",
conversation_id=getattr(agent, "_gateway_session_key", None),
)
except Exception as _ce_err:
logger.debug("context engine on_session_start (compression): %s", _ce_err)
- # Notify memory providers of the compression-driven session_id rotation
- # so provider-cached per-session state (Hindsight's _document_id,
- # accumulated turn buffers, counters) refreshes. reset=False because
- # the logical conversation continues; only the id and DB row rolled
- # over. See #6672.
+ # Notify memory providers of the compaction boundary so provider-cached
+ # per-session state (Hindsight's _document_id, accumulated turn buffers,
+ # counters) refreshes. reset=False because the logical conversation
+ # continues. See #6672. Fires in BOTH modes: in-place uses the same id as
+ # parent (the conversation didn't fork, but the buffer must still be told
+ # the transcript was compacted so it doesn't double-count dropped turns).
try:
- _old_sid = locals().get("old_session_id")
- if _old_sid and agent._memory_manager:
+ if _is_boundary and agent._memory_manager:
agent._memory_manager.on_session_switch(
agent.session_id or "",
- parent_session_id=_old_sid,
+ parent_session_id=_boundary_parent,
reset=False,
reason="compression",
)
except Exception as _me_err:
logger.debug("memory manager on_session_switch (compression): %s", _me_err)
- # Warn on repeated compressions (quality degrades with each pass)
+ # Warn on repeated compressions (quality degrades with each pass).
+ # Route through _emit_status (like the other compression warnings above)
+ # so the warning reaches the TUI / Telegram / Discord via status_callback,
+ # not just CLI stdout. _emit_status still _vprints for the CLI, and
+ # storing it on _compression_warning lets replay_compression_warning
+ # re-deliver it once a late-bound gateway status_callback is wired (#36908).
_cc = agent.context_compressor.compression_count
if _cc >= 2:
- agent._vprint(
+ _cc_msg = (
f"{agent.log_prefix}⚠️ Session compressed {_cc} times — "
- f"accuracy may degrade. Consider /new to start fresh.",
- force=True,
+ f"accuracy may degrade. Consider /new to start fresh."
)
+ agent._compression_warning = _cc_msg
+ agent._emit_status(_cc_msg)
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
- # the completed old session before its details are lost.
- _old_sid_for_event = locals().get("old_session_id")
+ # the completed old session before its details are lost. In in-place mode
+ # there is no old id (same session); ``in_place=True`` tells hooks the
+ # transcript was compacted on the same id rather than rotated.
if getattr(agent, "event_callback", None):
try:
agent.event_callback("session:compress", {
"platform": agent.platform or "",
"session_id": agent.session_id,
- "old_session_id": _old_sid_for_event or "",
+ "old_session_id": _old_sid or "",
+ "in_place": in_place,
"compression_count": agent.context_compressor.compression_count,
})
except Exception as e:
logger.debug("event_callback error on session:compress: %s", e)
+ # Surface the compaction mode to the caller (run_conversation / gateway)
+ # via a rotation-independent flag. The gateway uses this — NOT an
+ # id-change diff — to re-baseline transcript handling (history_offset=0 +
+ # rewrite on the same id) when compaction happened in place. See #38763.
+ agent._last_compaction_in_place = compacted_in_place
+
# Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits.
@@ -676,10 +838,11 @@ def try_shrink_image_parts_in_messages(
Pillow couldn't help (caller should surface the original error).
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
- ``data:image/...;base64,...`` payload. For each one whose encoded
- size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
- ceiling with header overhead) or whose longest side exceeds
- ``max_dimension``, write the base64 to a tempfile, call
+ ``data:image/...;base64,...`` payload, plus Anthropic-native
+ ``{"type": "image", "source": {"type": "base64", ...}}`` blocks.
+ For each one whose encoded size exceeds 4 MB (a safe target that slides
+ under Anthropic's 5 MB ceiling with header overhead) or whose longest side
+ exceeds ``max_dimension``, write the base64 to a tempfile, call
``vision_tools._resize_image_for_vision`` to produce a smaller data
URL, and substitute it in place.
@@ -835,6 +998,28 @@ def _shrink_data_url(url: str) -> tuple:
logger.warning("image-shrink recovery: re-encode failed — %s", exc)
return None, triggered_by is not None
+ def _source_to_data_url(source: Any) -> Optional[str]:
+ if not isinstance(source, dict) or source.get("type") != "base64":
+ return None
+ data = source.get("data")
+ if not isinstance(data, str) or not data:
+ return None
+ media_type = str(source.get("media_type") or "image/jpeg").strip()
+ if not media_type.startswith("image/"):
+ media_type = "image/jpeg"
+ return f"data:{media_type};base64,{data}"
+
+ def _write_data_url_to_source(source: dict, data_url: str) -> None:
+ header, _, data = data_url.partition(",")
+ media_type = "image/jpeg"
+ if header.startswith("data:"):
+ candidate = header[len("data:"):].split(";", 1)[0].strip()
+ if candidate.startswith("image/"):
+ media_type = candidate
+ source["type"] = "base64"
+ source["media_type"] = media_type
+ source["data"] = data
+
for msg in api_messages:
if not isinstance(msg, dict):
continue
@@ -845,6 +1030,16 @@ def _shrink_data_url(url: str) -> tuple:
if not isinstance(part, dict):
continue
ptype = part.get("type")
+ if ptype == "image":
+ source = part.get("source")
+ url = _source_to_data_url(source)
+ resized, unshrinkable = _shrink_data_url(url or "")
+ if resized and isinstance(source, dict):
+ _write_data_url_to_source(source, resized)
+ changed_count += 1
+ elif unshrinkable:
+ unshrinkable_oversized += 1
+ continue
if ptype not in {"image_url", "input_image"}:
continue
image_value = part.get("image_url")
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index 0ccc964942..10825cfd68 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -28,6 +28,7 @@
from typing import Any, Dict, List, Optional
from agent.codex_responses_adapter import _summarize_user_message_for_log
+from agent.conversation_compression import conversation_history_after_compression
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
@@ -35,6 +36,7 @@
from agent.turn_retry_state import TurnRetryState
from agent.memory_manager import build_memory_context_block
from agent.message_sanitization import (
+ close_interrupted_tool_sequence,
_repair_tool_call_arguments,
_sanitize_messages_non_ascii,
_sanitize_messages_surrogates,
@@ -55,7 +57,7 @@
)
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import apply_anthropic_cache_control
-from agent.retry_utils import jittered_backoff
+from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
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
@@ -466,6 +468,32 @@ def _content_policy_blocked_result(
}
+def _sync_failover_system_message(agent, api_messages, active_system_prompt):
+ """Refresh the in-flight system message after a provider failover.
+
+ ``try_activate_fallback`` rewrites the ``Model:``/``Provider:`` identity
+ lines on ``agent._cached_system_prompt`` (see
+ ``rewrite_prompt_model_identity``) so the agent reports the model that is
+ actually answering. But the current call block's ``api_messages`` were
+ built from the pre-failover prompt, and the retry loop rebuilds
+ ``api_kwargs`` from that list each iteration — without this sync the
+ whole turn (and every gateway turn, since fallback re-activates per
+ message while the primary is down) ships the stale identity.
+
+ Mutates ``api_messages[0]`` in place and returns the prompt to use as
+ ``active_system_prompt`` for subsequent call-block rebuilds.
+ """
+ sp = getattr(agent, "_cached_system_prompt", None)
+ if not isinstance(sp, str) or not sp:
+ return active_system_prompt
+ if api_messages and api_messages[0].get("role") == "system":
+ effective = sp
+ if agent.ephemeral_system_prompt:
+ effective = (effective + "\n\n" + agent.ephemeral_system_prompt).strip()
+ api_messages[0]["content"] = effective
+ return sp
+
+
def run_conversation(
agent,
user_message: str,
@@ -475,6 +503,7 @@ def run_conversation(
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
+ moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@@ -497,6 +526,19 @@ def run_conversation(
Returns:
Dict: Complete conversation result with final response and message history
"""
+ if moa_config is None:
+ try:
+ from hermes_cli.moa_config import decode_moa_turn
+
+ _decoded_message, _decoded_moa_config = decode_moa_turn(user_message)
+ if _decoded_moa_config is not None:
+ user_message = _decoded_message
+ moa_config = _decoded_moa_config
+ if persist_user_message is None:
+ persist_user_message = _decoded_message
+ except Exception:
+ pass
+
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
@@ -546,6 +588,13 @@ def run_conversation(
compression_attempts = 0
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
+ # Per-turn tally of consecutive successful credential-pool token refreshes,
+ # keyed by (provider, pool-entry-id). A persistent upstream 401 lets
+ # ``try_refresh_current()`` "succeed" forever on a single-entry OAuth pool,
+ # so this tally caps same-entry refreshes and lets the fallback chain take
+ # over instead of spinning. Reset here so each turn starts fresh. See #26080.
+ agent._auth_pool_refresh_counts = {}
+
# Optional opt-in runtime: if api_mode == codex_app_server, hand the
# turn to the codex app-server subprocess (terminal/file ops/patching
# all run inside Codex). Default Hermes path is bypassed entirely.
@@ -775,6 +824,28 @@ def run_conversation(
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
+ if moa_config:
+ try:
+ from agent.moa_loop import 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),
+ )
+ if _moa_context:
+ for _msg in reversed(api_messages):
+ if _msg.get("role") == "user":
+ _base = _msg.get("content", "")
+ if isinstance(_base, str):
+ _msg["content"] = _base + "\n\n" + _moa_context
+ break
+ except Exception as _moa_exc:
+ logger.warning("MoA context aggregation failed: %s", _moa_exc)
+
# Inject ephemeral prefill messages right after the system prompt
# but before conversation history. Same API-call-time-only pattern.
if agent.prefill_messages:
@@ -940,6 +1011,8 @@ def run_conversation(
)
agent._buffer_status(f"⏳ {_nous_msg}")
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
@@ -1094,7 +1167,7 @@ def _stop_spinner():
# stream. Mirror the ACP exclusion used for Responses
# API upgrade (lines ~1083-1085).
elif (
- agent.provider == "copilot-acp"
+ agent.provider in {"copilot-acp", "moa"}
or str(agent.base_url or "").lower().startswith("acp://copilot")
or str(agent.base_url or "").lower().startswith("acp+tcp://")
):
@@ -1265,6 +1338,8 @@ def _perform_api_call(next_api_kwargs):
if agent._fallback_index < len(agent._fallback_chain):
agent._buffer_status("⚠️ Empty/malformed response — switching to fallback...")
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
@@ -1336,6 +1411,8 @@ def _perform_api_call(next_api_kwargs):
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
@@ -1364,10 +1441,12 @@ def _perform_api_call(next_api_kwargs):
while time.time() < sleep_end:
if agent._interrupt_requested:
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
+ _interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
+ close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return {
- "final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).",
+ "final_response": _interrupt_text,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -1479,6 +1558,8 @@ def _perform_api_call(next_api_kwargs):
"⚠️ Model declined to respond (safety refusal) — trying fallback..."
)
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
@@ -1618,6 +1699,56 @@ def _perform_api_call(next_api_kwargs):
if agent.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}:
assistant_message = _trunc_msg
+ # ── Content-filter stream stall → fallback (#32421) ──
+ # When the provider's output-layer safety filter (e.g.
+ # MiniMax "output new_sensitive (1027)", Azure
+ # content_filter) kills the stream mid-delivery, the
+ # raw error was classified at the swallow point and the
+ # stub tagged ``_content_filter_terminated``. This
+ # filter is content-deterministic — continuation
+ # retries against the SAME primary just re-hit it and
+ # burn paid attempts (the loop used to give up with
+ # "Response remained truncated after 3 continuation
+ # attempts" and never consult the fallback chain).
+ # Escalate to the configured fallback BEFORE retrying.
+ _cf_terminated = getattr(
+ response, "_content_filter_terminated", False
+ )
+ if (
+ _cf_terminated
+ and agent._fallback_index < len(agent._fallback_chain)
+ ):
+ agent._vprint(
+ f"{agent.log_prefix}🛡️ Content filter terminated "
+ f"stream — activating fallback provider...",
+ force=True,
+ )
+ agent._emit_status(
+ "Content filter terminated stream; switching to fallback..."
+ )
+ if agent._try_activate_fallback():
+ # Roll the partial content (if any was already
+ # appended in a prior continuation pass) back to
+ # the last clean turn so the fallback provider
+ # gets a coherent continuation point.
+ if truncated_response_parts:
+ messages = agent._get_messages_up_to_last_assistant(messages)
+ agent._session_messages = messages
+ length_continue_retries = 0
+ truncated_response_parts = []
+ retry_count = 0
+ compression_attempts = 0
+ _retry.primary_recovery_attempted = False
+ _retry.restart_with_rebuilt_messages = True
+ break
+ # No fallback available — fall through to normal
+ # continuation (best-effort, may loop).
+ agent._vprint(
+ f"{agent.log_prefix}⚠️ No fallback provider "
+ f"configured — retrying with same provider "
+ f"(may re-hit filter)...",
+ force=True,
+ )
if assistant_message is not None and not _trunc_has_tool_calls:
length_continue_retries += 1
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
@@ -1937,9 +2068,21 @@ def _perform_api_call(next_api_kwargs):
agent.thinking_callback("")
api_elapsed = time.time() - api_start_time
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
- agent._persist_session(messages, conversation_history)
interrupted = True
- final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
+ # Preserve any assistant text already streamed to the user
+ # before the stop landed. Dropping it leaves history with no
+ # record of the half-finished reply on screen, so the next turn
+ # the model "forgets" what it just said — exactly what users hit
+ # when they stop to redirect mid-response.
+ _partial = agent._strip_think_blocks(
+ getattr(agent, "_current_streamed_assistant_text", "") or ""
+ ).strip()
+ if _partial:
+ messages.append({"role": "assistant", "content": _partial})
+ final_response = _partial
+ else:
+ final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
+ agent._persist_session(messages, conversation_history)
break
except Exception as api_error:
@@ -2173,6 +2316,15 @@ def _perform_api_call(next_api_kwargs):
# "unknown variant `image_url`, expected `text`".
"unknown variant `image_url`, expected `text`",
"unknown variant image_url, expected text",
+ # OpenRouter routes a request to upstream endpoints and,
+ # when none of the candidate endpoints for the model accept
+ # image input, returns HTTP 404 "No endpoints found that
+ # support image input". Without this phrase the agent never
+ # strips the images, the retry loop re-sends the same
+ # rejected request until exhaustion, and the gateway leaves
+ # every subsequent message queued behind the stuck turn —
+ # the P1 in issue #21160. The 404 passes the 4xx gate below.
+ "no endpoints found that support image input",
)
_err_lower = _err_body.lower()
_looks_like_image_rejection = any(
@@ -2629,10 +2781,12 @@ def _perform_api_call(next_api_kwargs):
# Check for interrupt before deciding to retry
if agent._interrupt_requested:
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
+ _interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
+ close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return {
- "final_response": f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))}).",
+ "final_response": _interrupt_text,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -2742,10 +2896,9 @@ def _perform_api_call(next_api_kwargs):
approx_tokens=approx_tokens,
task_id=effective_task_id,
)
- # Compression created a new session — clear history
- # so _flush_messages_to_session_db writes compressed
- # messages to the new session, not skipping them.
- conversation_history = None
+ conversation_history = conversation_history_after_compression(
+ agent, messages
+ )
if len(messages) < original_len or old_ctx > _reduced_ctx:
agent._buffer_status(
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
@@ -2757,15 +2910,25 @@ def _perform_api_call(next_api_kwargs):
# Fall through to normal error handling if compression
# is exhausted or didn't help.
- # Eager fallback for rate-limit errors (429 or quota exhaustion).
- # When a fallback model is configured, switch immediately instead
- # of burning through retries with exponential backoff -- the
- # primary provider won't recover within the retry window.
+ # Eager fallback for rate-limit errors (429 or quota exhaustion)
+ # and transport errors (connection failure / timeout / provider
+ # overloaded). Rate limits and billing: switch immediately —
+ # the primary provider won't recover within the retry window.
+ # Transport errors: allow 1 retry first (transient hiccups
+ # recover), then fall back if the provider is truly unreachable.
is_rate_limited = classified.reason in {
FailoverReason.rate_limit,
FailoverReason.billing,
}
- if is_rate_limited and agent._fallback_index < len(agent._fallback_chain):
+ _is_transport_failure = classified.reason in {
+ FailoverReason.timeout,
+ FailoverReason.overloaded,
+ }
+ _should_fallback = (
+ is_rate_limited
+ or (_is_transport_failure and retry_count >= 2)
+ )
+ 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
@@ -2780,14 +2943,53 @@ def _perform_api_call(next_api_kwargs):
agent._buffer_status(
"⚠️ Billing or credits exhausted — switching to fallback provider..."
)
+ elif _is_transport_failure:
+ agent._buffer_status(
+ "⚠️ Provider unreachable — switching to fallback provider..."
+ )
else:
agent._buffer_status("⚠️ Rate limited — switching to fallback provider...")
if agent._try_activate_fallback(reason=classified.reason):
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
continue
+ # ── Auth-failure provider failover ───────────────────────
+ # A 401/403 that survives the per-provider credential-refresh
+ # attempt above (each guarded by its own
+ # ``*_auth_retry_attempted`` flag) means the active provider's
+ # credential or endpoint is broken in a way refreshing can't
+ # fix (revoked OAuth, blocked/expired key, an account pinned to
+ # a dead/staging endpoint). Previously the loop only printed
+ # "switch providers manually" advice and fell through, so a
+ # user with a configured fallback chain kept thrashing on the
+ # same dead credential every turn instead of failing over.
+ # Escalate to the fallback chain here, mirroring the rate-
+ # limit/billing failover above. When no fallback is configured
+ # (or the chain is exhausted), _try_activate_fallback returns
+ # False and we fall through to the existing terminal handling
+ # + provider-specific troubleshooting guidance unchanged.
+ if (
+ classified.is_auth
+ and not _retry.auth_failover_attempted
+ and agent._fallback_index < len(agent._fallback_chain)
+ ):
+ _retry.auth_failover_attempted = True
+ agent._buffer_status(
+ "🔐 Authentication failed and could not be refreshed — "
+ "switching to fallback provider..."
+ )
+ if agent._try_activate_fallback(reason=classified.reason):
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
+ retry_count = 0
+ compression_attempts = 0
+ _retry.primary_recovery_attempted = False
+ continue
+
# ── Nous Portal: record rate limit & skip retries ─────
# When Nous returns a 429 that is a genuine account-
# level rate limit, record the reset time to a shared
@@ -2914,17 +3116,27 @@ def _perform_api_call(next_api_kwargs):
agent._buffer_status(f"⚠️ Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...")
original_len = len(messages)
+ original_tokens = estimate_messages_tokens_rough(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
)
- # Compression created a new session — clear history
- # so _flush_messages_to_session_db writes compressed
- # messages to the new session, not skipping them.
- conversation_history = None
+ conversation_history = conversation_history_after_compression(
+ agent, messages
+ )
- if len(messages) < original_len:
- agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
+ # Re-estimate tokens after compression. Same-message-count
+ # compression (tool-result pruning, in-place summarization)
+ # can materially reduce request size without reducing the
+ # message array. (#39550)
+ new_tokens = estimate_messages_tokens_rough(messages)
+ approx_tokens = new_tokens # update for downstream logging
+
+ if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95):
+ if len(messages) < original_len:
+ agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
+ else:
+ agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@@ -3070,18 +3282,27 @@ def _perform_api_call(next_api_kwargs):
agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
original_len = len(messages)
+ original_tokens = estimate_messages_tokens_rough(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
)
- # Compression created a new session — clear history
- # so _flush_messages_to_session_db writes compressed
- # messages to the new session, not skipping them.
- conversation_history = None
+ conversation_history = conversation_history_after_compression(
+ agent, messages
+ )
+
+ # Re-estimate tokens after compression. Same-message-count
+ # compression (tool-result pruning, in-place summarization)
+ # can materially reduce request size without reducing the
+ # message array. (#39550)
+ new_tokens = estimate_messages_tokens_rough(messages)
+ approx_tokens = new_tokens # update for downstream logging
- if len(messages) < original_len or new_ctx and new_ctx < old_ctx:
+ if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95) or (new_ctx and new_ctx < old_ctx):
if len(messages) < original_len:
agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
+ elif new_tokens > 0 and new_tokens < original_tokens * 0.95:
+ agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@@ -3090,13 +3311,13 @@ def _perform_api_call(next_api_kwargs):
agent._flush_status_buffer()
agent._vprint(f"{agent.log_prefix}❌ Context length exceeded and cannot compress further.", force=True)
agent._vprint(f"{agent.log_prefix} 💡 The conversation has accumulated too much content. Try /new to start fresh, or /compress to manually trigger compression.", force=True)
- logger.error(f"{agent.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.")
+ logger.error(f"{agent.log_prefix}Context length exceeded: {new_tokens:,} tokens. Cannot compress further.")
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": f"Context length exceeded ({approx_tokens:,} tokens). Cannot compress further.",
+ "error": f"Context length exceeded ({new_tokens:,} tokens). Cannot compress further.",
"partial": True,
"failed": True,
"compression_exhausted": True,
@@ -3186,6 +3407,8 @@ def _perform_api_call(next_api_kwargs):
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
@@ -3328,11 +3551,20 @@ def _perform_api_call(next_api_kwargs):
):
_retry.primary_recovery_attempted = True
retry_count = 0
+ # Primary transport recovery starts a fresh attempt
+ # cycle. Re-open fallback state so a follow-on 429 can
+ # still activate fallback_providers after stale
+ # pre-recovery fallback/credential-pool bookkeeping.
+ _retry.has_retried_429 = False
+ agent._fallback_index = 0
+ agent._fallback_activated = False
continue
# Try fallback before giving up entirely
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
@@ -3391,6 +3623,65 @@ def _perform_api_call(next_api_kwargs):
force=True,
)
+ # Detect thinking-timeout pattern: a known reasoning model
+ # hit a transport-layer error before the first content
+ # token arrived. Distinct from _is_stream_drop above
+ # (which fires for large file-write stream drops) and
+ # from any classifier reason that's not a transport
+ # timeout. Reuses the reasoning-model allowlist from
+ # agent/reasoning_timeouts.py (Fixes #52217) so the
+ # trigger is consistent with what the per-model
+ # stale-timeout floor covers. After the classifier
+ # override at agent/error_classifier.py:720-738 (this
+ # PR), transport disconnects on reasoning models route
+ # to FailoverReason.timeout rather than
+ # context_overflow, so this branch actually fires.
+ # Detection and message text live in
+ # agent.thinking_timeout_guidance so they're
+ # unit-testable without driving the full retry loop.
+ # (Part 2 of Fixes #52310.)
+ from agent.thinking_timeout_guidance import (
+ is_thinking_timeout,
+ )
+ _is_thinking_timeout = is_thinking_timeout(
+ classified,
+ _model,
+ error_msg,
+ )
+ if _is_thinking_timeout:
+ agent._vprint(
+ f"{agent.log_prefix} 💡 The model's thinking "
+ f"phase exceeded the upstream proxy's idle "
+ f"timeout before the first content token "
+ f"arrived. This is a known issue with "
+ f"reasoning models behind cloud gateways "
+ f"(NVIDIA NIM, OpenAI, Anthropic, DeepSeek).",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} Workarounds in priority order:",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} 1. Set "
+ f"`providers.{_provider}.models.{_model}.stale_timeout_seconds: 900` "
+ f"in `~/.hermes/config.yaml` to extend the per-call "
+ f"timeout. (Hermes's built-in floor is 600s for "
+ f"known reasoning models — if you still see this "
+ f"after raising, the upstream cap is even shorter.)",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} 2. Lower `reasoning_budget` or set "
+ f"`reasoning_effort: medium` on this model if the provider supports it.",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} 3. Use a smaller / faster reasoning "
+ f"model if the task doesn't require deep thinking.",
+ force=True,
+ )
+
logger.error(
"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s",
agent.log_prefix, max_retries, _final_summary,
@@ -3407,7 +3698,22 @@ def _perform_api_call(next_api_kwargs):
_final_response += f"\n\n{_billing_guidance}"
else:
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
- if _is_stream_drop:
+ if _is_thinking_timeout:
+ # Thinking-timeout guidance overrides the generic
+ # stream-drop guidance — the latter is wrong for
+ # this case (it suggests splitting large file
+ # writes, which isn't what happened). See the
+ # reasoning-model override at
+ # agent/error_classifier.py:720-738 and the
+ # detection block above for context.
+ from agent.thinking_timeout_guidance import (
+ build_thinking_timeout_guidance,
+ )
+ _final_response += build_thinking_timeout_guidance(
+ provider=_provider,
+ model=_model,
+ )
+ elif _is_stream_drop:
_final_response += (
"\n\nThe provider's stream connection keeps "
"dropping — this often happens when generating "
@@ -3439,20 +3745,47 @@ def _perform_api_call(next_api_kwargs):
_ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After")
if _ra_raw:
try:
- _retry_after = min(float(_ra_raw), 120) # Cap at 2 minutes
+ # Cap at 10 minutes. Anthropic Tier 1 input-token
+ # buckets reset in ~171s, so a 120s cap caused us to
+ # retry before the actual reset window and re-trip the
+ # limit. 600s covers all realistic provider reset
+ # windows while still rejecting pathological values. (#26293)
+ _retry_after = min(float(_ra_raw), 600)
except (TypeError, ValueError):
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:
+ wait_time, _backoff_policy = adaptive_rate_limit_backoff(
+ retry_count,
+ base_url=str(_base),
+ model=_model,
+ error=api_error,
+ default_wait=wait_time,
+ )
if is_rate_limited:
- agent._buffer_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
+ _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}..."
+ # 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.
+ if _backoff_policy == "zai_coding_overload_long":
+ agent._emit_status(_rate_limit_status)
+ else:
+ agent._buffer_status(_rate_limit_status)
else:
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
logger.warning(
- "Retrying API call in %ss (attempt %s/%s) %s error=%s",
+ "Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
wait_time,
retry_count,
max_retries,
agent._client_log_context(),
+ _backoff_policy or "default",
api_error,
)
# Sleep in small increments so we can respond to interrupts quickly
@@ -3462,10 +3795,12 @@ def _perform_api_call(next_api_kwargs):
while time.time() < sleep_end:
if agent._interrupt_requested:
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
+ _interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
+ close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return {
- "final_response": f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries}).",
+ "final_response": _interrupt_text,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -3496,6 +3831,17 @@ def _perform_api_call(next_api_kwargs):
_retry.restart_with_compressed_messages = False
continue
+ if _retry.restart_with_rebuilt_messages:
+ # A content-filter stream stall (#32421) was escalated to the
+ # fallback chain and the partial content rolled back. Re-issue
+ # the API call against the now-active fallback provider. Refund
+ # the budget/count for the stalled attempt so the fallback gets a
+ # fair turn.
+ api_call_count -= 1
+ agent.iteration_budget.refund()
+ _retry.restart_with_rebuilt_messages = False
+ continue
+
if _retry.restart_with_length_continuation:
# Progressively boost the output token budget on each retry.
# Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.
@@ -3956,6 +4302,19 @@ def _perform_api_call(next_api_kwargs):
messages.append(assistant_msg)
agent._emit_interim_assistant_message(assistant_msg)
+ try:
+ # Persist the assistant tool-call turn before any tool
+ # side effects run. If a destructive tool restarts or
+ # terminates Hermes mid-turn, resume logic still sees the
+ # exact tool-call block that already executed.
+ agent._flush_messages_to_session_db(messages, conversation_history)
+ except Exception as exc:
+ logger.warning(
+ "Incremental tool-call persistence failed before execution "
+ "(session=%s): %s",
+ agent.session_id or "none",
+ exc,
+ )
# Close any open streaming display (response box, reasoning
# box) before tool execution begins. Intermediate turns may
@@ -4057,10 +4416,9 @@ def _perform_api_call(next_api_kwargs):
approx_tokens=agent.context_compressor.last_prompt_tokens,
task_id=effective_task_id,
)
- # Compression created a new session — clear history so
- # _flush_messages_to_session_db writes compressed messages
- # to the new session (see preflight compression comment).
- conversation_history = None
+ conversation_history = conversation_history_after_compression(
+ agent, messages
+ )
# Save session log incrementally (so progress is visible even if interrupted)
agent._session_messages = messages
@@ -4102,7 +4460,11 @@ def _perform_api_call(next_api_kwargs):
"as final response"
)
final_response = _recovered
- agent._response_was_previewed = True
+ # Streaming delivered a fragment, not a confirmed
+ # final preview. Leave response_previewed false so
+ # gateway fallback delivery can send the recovered
+ # text plus the abnormal-turn explanation.
+ agent._response_was_previewed = False
break
# If the previous turn already delivered real content alongside
@@ -4279,6 +4641,8 @@ def _perform_api_call(next_api_kwargs):
"switching to fallback provider..."
)
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
agent._empty_content_retries = 0
agent._buffer_status(
f"↻ Switched to fallback: {agent.model} "
@@ -4345,14 +4709,20 @@ def _perform_api_call(next_api_kwargs):
# status from earlier failed attempts in this turn.
agent._clear_status_buffer()
+ from agent.agent_runtime_helpers import (
+ intent_ack_continuation_mode,
+ )
+
+ _ack_mode = intent_ack_continuation_mode(agent)
if (
- agent.api_mode == "codex_responses"
+ _ack_mode != "off"
and agent.valid_tool_names
and codex_ack_continuations < 2
and agent._looks_like_codex_intermediate_ack(
user_message=user_message,
assistant_content=final_response,
messages=messages,
+ require_workspace=(_ack_mode == "codex_only"),
)
):
codex_ack_continuations += 1
@@ -4383,9 +4753,10 @@ def _perform_api_call(next_api_kwargs):
final_msg = agent._build_assistant_message(assistant_message, finish_reason)
# Pop thinking-only prefill and empty-response retry
- # scaffolding before appending the final response. These
- # internal turns are only for the next API retry and should
- # not become durable transcript context.
+ # scaffolding before appending either a final response or a
+ # verification-stop follow-up. These internal turns are only
+ # for the next API retry and should not become durable
+ # transcript context.
while (
messages
and isinstance(messages[-1], dict)
@@ -4397,6 +4768,48 @@ def _perform_api_call(next_api_kwargs):
):
messages.pop()
+ try:
+ from agent.verification_stop import (
+ build_verify_on_stop_nudge,
+ verify_on_stop_enabled,
+ )
+
+ if verify_on_stop_enabled():
+ _verify_nudge = build_verify_on_stop_nudge(
+ session_id=getattr(agent, "session_id", None),
+ changed_paths=getattr(agent, "_turn_file_mutation_paths", set()),
+ attempts=getattr(agent, "_verification_stop_nudges", 0),
+ )
+ else:
+ _verify_nudge = None
+ except Exception:
+ logger.debug("verification stop-loop check failed", exc_info=True)
+ _verify_nudge = None
+
+ if _verify_nudge:
+ agent._verification_stop_nudges = (
+ getattr(agent, "_verification_stop_nudges", 0) + 1
+ )
+ final_msg["finish_reason"] = "verification_required"
+ messages.append(final_msg)
+ # Keep the attempted final answer in model history so the
+ # synthetic user nudge preserves role alternation, but do
+ # not surface it to the user as an interim answer. The
+ # whole point of this guard is to prevent premature
+ # "done" claims before checks run.
+ messages.append({
+ "role": "user",
+ "content": _verify_nudge,
+ "_verification_stop_synthetic": True,
+ })
+ agent._session_messages = messages
+ # Run the verification-stop loop silently — the nudge is an
+ # internal turn that should not add noise to the user's
+ # terminal. Keep a debug breadcrumb in agent.log for tracing.
+ logger.debug("verification stop-loop nudge issued (attempt %d)",
+ agent._verification_stop_nudges)
+ continue
+
messages.append(final_msg)
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"
diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py
index e3c03938af..b6e301bd81 100644
--- a/agent/copilot_acp_client.py
+++ b/agent/copilot_acp_client.py
@@ -23,6 +23,7 @@
from agent.file_safety import get_read_block_error, is_write_denied
from agent.redact import redact_sensitive_text
+from tools.environments.local import hermes_subprocess_env
ACP_MARKER_BASE_URL = "acp://copilot"
_DEFAULT_TIMEOUT_SECONDS = 900.0
@@ -94,7 +95,10 @@ def _resolve_home_dir() -> str:
def _build_subprocess_env() -> dict[str, str]:
- env = os.environ.copy()
+ # Copilot ACP is a model-driving CLI executor: it legitimately needs LLM
+ # provider credentials. Route through the central helper so Tier-1 secrets
+ # (gateway bot tokens, GitHub auth, infra) are still stripped (#29157).
+ env = hermes_subprocess_env(inherit_credentials=True)
home = _resolve_home_dir()
env["HOME"] = home
from hermes_constants import apply_subprocess_home_env
diff --git a/agent/credential_pool.py b/agent/credential_pool.py
index b791ac4f82..d8ca2b1720 100644
--- a/agent/credential_pool.py
+++ b/agent/credential_pool.py
@@ -11,6 +11,7 @@
import re
from dataclasses import dataclass, fields, replace
from datetime import datetime, timezone
+from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
@@ -447,6 +448,63 @@ def get_pool_strategy(provider: str) -> str:
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
+def _write_through_provider_state_to_global_root(
+ provider_id: str, state: Dict[str, Any]
+) -> None:
+ """Persist a rotated OAuth ``state`` into the global-root auth.json.
+
+ Best-effort write-through for the multi-profile rotation hazard
+ (#48415 / #43589): nous, openai-codex, and xai-oauth rotate the
+ refresh_token on refresh, so when a profile pool refresh rotates a grant
+ it resolved from the root fallback, the rotated chain must land back in
+ root. Otherwise root keeps a now-revoked refresh token and every other
+ profile reading the stale root grant dies with ``refresh_token_reused`` /
+ ``invalid_grant`` once its access token expires.
+
+ Only updates ``providers.`` in the root store; never touches
+ the profile store (the caller already saved that). Swallows all errors — a
+ failed write-through degrades to the pre-existing behavior (root stale), it
+ must never break the profile's own successful save. Mirrors
+ ``hermes_cli.auth._write_through_xai_oauth_to_global_root`` (which covers
+ the non-pool xAI refresh path) for the credential-pool refresh path.
+ """
+ try:
+ global_path = auth_mod._global_auth_file_path()
+ except Exception:
+ return
+ if global_path is None:
+ # Classic mode (profile == root); the profile save already hit root.
+ return
+ # Seat belt: under pytest, refuse to write the real user's
+ # ~/.hermes/auth.json even when HERMES_HOME points at a profile path
+ # (mirrors the read-side guard in _load_global_auth_store). Uses the
+ # unmodified HOME env, not Path.home() which fixtures may monkeypatch.
+ if os.environ.get("PYTEST_CURRENT_TEST"):
+ real_home_env = os.environ.get("HOME", "")
+ if real_home_env:
+ real_root = Path(real_home_env) / ".hermes" / "auth.json"
+ try:
+ if global_path.resolve(strict=False) == real_root.resolve(strict=False):
+ return
+ except Exception:
+ return
+ try:
+ if global_path.exists():
+ global_store = _load_auth_store(global_path)
+ else:
+ global_store = {}
+ if not isinstance(global_store, dict):
+ return
+ _store_provider_state(global_store, provider_id, dict(state), set_active=False)
+ auth_mod._save_auth_store(global_store, global_path)
+ except Exception as exc: # pragma: no cover - best effort
+ logger.debug(
+ "%s pool refresh: write-through to global root failed: %s",
+ provider_id,
+ exc,
+ )
+
+
class CredentialPool:
def __init__(self, provider: str, entries: List[PooledCredential]):
self.provider = provider
@@ -479,10 +537,11 @@ def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
self._entries[idx] = new
return
- def _persist(self) -> None:
+ def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
+ removed_ids=removed_ids,
)
def _is_terminal_auth_failure(
@@ -800,6 +859,28 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None
try:
with _auth_store_lock():
auth_store = _load_auth_store()
+ # Decide BEFORE writing whether this profile is reading the
+ # grant from the global root (no own providers. block) vs.
+ # genuinely shadowing it. A pool refresh rotates single-use
+ # OAuth refresh tokens, so a profile that resolved the grant
+ # from root MUST write the rotated chain back to root too —
+ # otherwise root keeps a revoked refresh token and every other
+ # profile reading the stale root grant dies with
+ # refresh_token_reused / invalid_grant once its access token
+ # expires. This mirrors the xAI write-through in
+ # hermes_cli.auth._save_xai_oauth_tokens (#43589); the pool
+ # refresh path is the Codex/xAI analog reported in #48415.
+ _wt_provider_id = {
+ "nous": "nous",
+ "openai-codex": "openai-codex",
+ "xai-oauth": "xai-oauth",
+ }.get(self.provider)
+ write_through_to_root = bool(_wt_provider_id) and not (
+ isinstance(auth_store.get("providers"), dict)
+ and isinstance(
+ auth_store["providers"].get(_wt_provider_id), dict
+ )
+ )
if self.provider == "nous":
state = _load_provider_state(auth_store, "nous")
if state is None:
@@ -855,6 +936,10 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None
return
_save_auth_store(auth_store)
+ if write_through_to_root and _wt_provider_id:
+ _write_through_provider_state_to_global_root(
+ _wt_provider_id, state
+ )
except Exception as exc:
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
@@ -1040,13 +1125,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
logger.debug(
"Failed to clear terminal xAI OAuth state: %s", clear_exc
)
+ removed_ids = [
+ item.id for item in self._entries
+ if item.source == "loopback_pkce"
+ ]
self._entries = [
item for item in self._entries
if item.source != "loopback_pkce"
]
if self._current_id == entry.id:
self._current_id = None
- self._persist()
+ self._persist(removed_ids=removed_ids)
return None
# For openai-codex: same race as xAI/nous — another Hermes process
# may have consumed the refresh token between our proactive sync
@@ -1106,13 +1195,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
logger.debug(
"Failed to clear terminal Codex OAuth state: %s", clear_exc
)
+ removed_ids = [
+ item.id for item in self._entries
+ if item.source == "device_code"
+ ]
self._entries = [
item for item in self._entries
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
- self._persist()
+ self._persist(removed_ids=removed_ids)
return None
# For nous: another process may have consumed the refresh token
# between our proactive sync and the HTTP call. Re-sync from
@@ -1169,13 +1262,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
auth_mod.NOUS_DEVICE_CODE_SOURCE,
f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}",
}
+ removed_ids = [
+ item.id for item in self._entries
+ if item.source in singleton_sources
+ ]
self._entries = [
item for item in self._entries
if item.source not in singleton_sources
]
if self._current_id == entry.id:
self._current_id = None
- self._persist()
+ self._persist(removed_ids=removed_ids)
return None
self._mark_exhausted(entry, None)
return None
@@ -1337,7 +1434,7 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal
pruned_ids = set(entries_to_prune)
self._entries = [e for e in self._entries if e.id not in pruned_ids]
if cleared_any:
- self._persist()
+ self._persist(removed_ids=entries_to_prune)
return available
def _select_unlocked(self) -> Optional[PooledCredential]:
@@ -1511,7 +1608,11 @@ def remove_index(self, index: int) -> Optional[PooledCredential]:
replace(entry, priority=new_priority)
for new_priority, entry in enumerate(self._entries)
]
- self._persist()
+ write_credential_pool(
+ self.provider,
+ [entry.to_dict() for entry in self._entries],
+ removed_ids=[removed.id],
+ )
if self._current_id == removed.id:
self._current_id = None
return removed
@@ -2062,19 +2163,34 @@ def _env_payload(
return changed, active_sources
-def _prune_stale_seeded_entries(entries: List[PooledCredential], active_sources: Set[str]) -> bool:
+def _prune_stale_seeded_entries(
+ entries: List[PooledCredential],
+ active_sources: Set[str],
+ *,
+ prune_env_sources: bool = True,
+) -> bool:
+ def _is_prunable(entry: PooledCredential) -> bool:
+ # ``env:*`` entries are persisted references that get re-hydrated from
+ # the environment on every load. A process that merely lacks the env
+ # var this call must NOT delete the on-disk entry for every other
+ # process — that destructive read is the bug behind #9331. Only prune
+ # an env source when ``prune_env_sources`` is explicitly requested
+ # (e.g. an `hermes auth` command that confirmed the source is gone).
+ if entry.source.startswith("env:"):
+ return prune_env_sources
+ # File-backed singletons (device-code OAuth, claude_code) and Hermes
+ # PKCE should disappear from the pool when their backing file is gone.
+ return (
+ is_borrowed_credential_source(entry.source, entry.provider)
+ or entry.source == "hermes_pkce"
+ )
+
retained = [
entry
for entry in entries
if _is_manual_source(entry.source)
or entry.source in active_sources
- or not (
- is_borrowed_credential_source(entry.source, entry.provider)
- # Hermes PKCE is Hermes-owned/persistable while present, but it is
- # still a file-backed singleton and should disappear from the pool
- # when the backing OAuth file is gone.
- or entry.source == "hermes_pkce"
- )
+ or not _is_prunable(entry)
]
if len(retained) == len(entries):
return False
@@ -2158,6 +2274,11 @@ def _is_suppressed(_p, _s): # type: ignore[misc]
def load_pool(provider: str) -> CredentialPool:
provider = (provider or "").strip().lower()
raw_entries = read_credential_pool(provider)
+ disk_ids = {
+ entry.get("id")
+ for entry in raw_entries
+ if isinstance(entry, dict) and entry.get("id")
+ }
raw_needs_sanitization = any(
isinstance(payload, dict)
and sanitize_borrowed_credential_payload(payload, provider) != payload
@@ -2174,12 +2295,22 @@ def load_pool(provider: str) -> CredentialPool:
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
env_changed, env_sources = _seed_from_env(provider, entries)
changed = raw_needs_sanitization or singleton_changed or env_changed
- changed |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources)
+ # ``load_pool()`` is a non-destructive read for env-seeded entries: a
+ # process missing a provider env var must not delete the persisted
+ # pool entry for every other process (#9331). File-backed singletons
+ # still prune when their backing file is gone.
+ changed |= _prune_stale_seeded_entries(
+ entries,
+ singleton_sources | env_sources,
+ prune_env_sources=False,
+ )
changed |= _normalize_pool_priorities(provider, entries)
if changed:
+ new_ids = {entry.id for entry in entries}
write_credential_pool(
provider,
[entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)],
+ removed_ids=disk_ids - new_ids,
)
return CredentialPool(provider, entries)
diff --git a/agent/curator.py b/agent/curator.py
index 0ceebecbff..6843205c68 100644
--- a/agent/curator.py
+++ b/agent/curator.py
@@ -377,8 +377,10 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
"Hard rules — do not violate:\n"
- "1. DO NOT touch bundled or hub-installed skills. The candidate list "
- "below is already filtered to agent-created skills only.\n"
+ "1. DO NOT touch bundled, hub-installed, or external-dir skills "
+ "(`skills.external_dirs`). The candidate list below is already filtered "
+ "to local curator-managed skills only; external skills are externally "
+ "owned and read-only to this background curator.\n"
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
"Archives are recoverable; deletion is not.\n"
@@ -469,8 +471,9 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
"skill, or `absorbed_into=\"\"` when you're truly pruning with no "
"forwarding target. This drives cron-job skill-reference migration — "
"guessing from your YAML summary after the fact is fragile.\n"
- " - terminal — mv a sibling into the archive "
- "OR move its content into a support subfile\n\n"
+ " - terminal — move LOCAL candidate content into "
+ "a support subfile when package integrity requires it; never mv, cp, rm, "
+ "patch, or rewrite bundled, hub-installed, or external-dir skills\n\n"
"'keep' is a legitimate decision ONLY when the skill is already a "
"class-level umbrella and none of the proposed merges would improve "
"discoverability. 'This is narrow but distinct from its siblings' "
@@ -1843,6 +1846,14 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
# Disable recursive nudges — the curator must never spawn its own review.
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
+ # Tag this fork as autonomous background curation so skill_manage's
+ # background-review write guard fires. Without this the fork inherits
+ # the default "assistant_tool" origin, is_background_review() is False,
+ # and the external/bundled/hub-installed skill_manage guards never
+ # trigger during the curation pass they exist to protect against.
+ # turn_context.py binds this onto the write-origin ContextVar at turn
+ # start (see agent/turn_context.py).
+ review_agent._memory_write_origin = "background_review"
# Redirect the forked agent's stdout/stderr to /dev/null while it
# runs so its tool-call chatter doesn't pollute the foreground
diff --git a/agent/display.py b/agent/display.py
index 01267e91ea..861d84bc41 100644
--- a/agent/display.py
+++ b/agent/display.py
@@ -6,6 +6,7 @@
import logging
import os
+import re
import sys
import threading
import time
@@ -15,6 +16,7 @@
from typing import Any
from utils import safe_json_loads
+from agent.redact import redact_sensitive_text
from agent.tool_result_classification import file_mutation_result_landed
# ANSI escape codes for coloring tool failure indicators
@@ -177,6 +179,223 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
return text
+_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
+_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
+
+
+def _shell_basename(head: str) -> str:
+ return head.rsplit("/", 1)[-1] if head else ""
+
+
+def _split_shell_words(segment: str) -> list[str]:
+ words: list[str] = []
+ buf: list[str] = []
+ quote: str | None = None
+
+ for i, ch in enumerate(segment):
+ if quote:
+ buf.append(ch)
+ if ch == quote and (i == 0 or segment[i - 1] != "\\"):
+ quote = None
+ continue
+
+ if ch in {"'", '"'}:
+ quote = ch
+ buf.append(ch)
+ continue
+
+ if ch.isspace():
+ if buf:
+ words.append("".join(buf))
+ buf = []
+ continue
+
+ buf.append(ch)
+
+ if buf:
+ words.append("".join(buf))
+
+ return words
+
+
+def _strip_shell_pipe_tail(segment: str) -> str:
+ words = _split_shell_words(segment)
+ out: list[str] = []
+
+ for i, word in enumerate(words):
+ if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS:
+ break
+ out.append(word)
+
+ return " ".join(out).strip()
+
+
+def _split_shell_compound(command: str) -> list[str]:
+ segments: list[str] = []
+ buf: list[str] = []
+ quote: str | None = None
+ i = 0
+
+ while i < len(command):
+ ch = command[i]
+
+ if quote:
+ buf.append(ch)
+ if ch == quote and (i == 0 or command[i - 1] != "\\"):
+ quote = None
+ i += 1
+ continue
+
+ if ch in {"'", '"'}:
+ quote = ch
+ buf.append(ch)
+ i += 1
+ continue
+
+ op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0
+ if op_len:
+ segment = _strip_shell_pipe_tail("".join(buf).strip())
+ if segment:
+ segments.append(segment)
+ buf = []
+ i += op_len
+ continue
+
+ buf.append(ch)
+ i += 1
+
+ segment = _strip_shell_pipe_tail("".join(buf).strip())
+ if segment:
+ segments.append(segment)
+
+ return segments
+
+
+def _shell_head_word(segment: str) -> str:
+ words = _split_shell_words(segment)
+ index = 0
+ while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]):
+ index += 1
+ return _shell_basename(words[index] if index < len(words) else "")
+
+
+def _clean_shell_segment(segment: str) -> str:
+ words = _split_shell_words(segment)
+ out: list[str] = []
+ i = 0
+ while i < len(words):
+ word = words[i]
+ if re.match(r"^\d*(?:>>?|<)$", word):
+ i += 2
+ continue
+ if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word):
+ i += 1
+ continue
+ out.append(word)
+ i += 1
+ return " ".join(out).strip()
+
+
+def _is_shell_boundary_echo(segment: str) -> bool:
+ words = _split_shell_words(segment)
+ if _shell_basename(words[0] if words else "") != "echo":
+ return False
+ rest = " ".join(words[1:])
+ return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest))
+
+
+def summarize_shell_command(command: str) -> str:
+ """Compact shell wrapper/plumbing for display while preserving raw command elsewhere."""
+ original = _oneline(command)
+ if not original:
+ return ""
+
+ segments = _split_shell_compound(original)
+ if len(segments) <= 1:
+ return _clean_shell_segment(segments[0] if segments else original) or original
+
+ core: list[str] = []
+ for segment in segments:
+ cleaned = _clean_shell_segment(segment)
+ head = _shell_head_word(cleaned)
+ if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned):
+ core.append(cleaned)
+
+ if not core:
+ return original
+ if len(core) == 1:
+ return core[0]
+
+ count = len(core) - 1
+ return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
+
+
+def _read_file_line_label(args: dict) -> str:
+ offset = args.get("offset")
+ limit = args.get("limit")
+ if not isinstance(offset, int) or offset <= 0:
+ return ""
+ if not isinstance(limit, int) or limit <= 1:
+ return f"L{offset}"
+ return f"L{offset}-{offset + limit - 1}"
+
+
+def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any:
+ """Apply secret redaction to browser_type text in display-facing payloads.
+
+ Backends sometimes echo the attempted input in error strings or fallback
+ metadata. When the raw typed value contains a recognizable secret (API
+ key, token, JWT, etc.) the redacted form differs from the raw value, so we
+ replace every occurrence of the raw value with its redacted form before a
+ browser_type result reaches logs, callbacks, the model, or chat history.
+
+ Normal typed text (search queries, addresses, form fields) matches no
+ secret pattern, so it passes through unchanged and stays readable.
+
+ Redaction is forced here regardless of the global ``security.redact_secrets``
+ preference: a typed credential leaking into chat history is a security
+ boundary, not mere log hygiene.
+ """
+ if typed_text is None:
+ return value
+ needle = str(typed_text)
+ if needle == "":
+ return value
+ redacted = redact_sensitive_text(needle, force=True)
+ if redacted == needle:
+ # Nothing secret-looking in the typed text; leave payload untouched.
+ return value
+ if isinstance(value, str):
+ return value.replace(needle, redacted)
+ if isinstance(value, dict):
+ return {
+ key: redact_browser_typed_text_for_display(item, typed_text)
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [redact_browser_typed_text_for_display(item, typed_text) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value)
+ return value
+
+
+def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None:
+ """Return a copy of tool args safe for logs/progress UI.
+
+ For ``browser_type`` the ``text`` argument is run through the same
+ secret-pattern redactor used for logs. Recognizable credentials (API
+ keys, tokens) are masked before the value reaches tool progress
+ notifications; normal typed text is left intact for debuggability.
+ """
+ if not isinstance(args, dict):
+ return args
+ if tool_name == "browser_type" and isinstance(args.get("text"), str):
+ safe_args = dict(args)
+ safe_args["text"] = redact_sensitive_text(args["text"], force=True)
+ return safe_args
+ return args
+
+
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
@@ -200,13 +419,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
max_len = _tool_preview_max_len
if not args:
return None
+ args = redact_tool_args_for_display(tool_name, args) or args
primary_args = {
"terminal": "command", "web_search": "query", "web_extract": "urls",
"read_file": "path", "write_file": "path", "patch": "path",
"search_files": "pattern", "browser_navigate": "url",
"browser_click": "ref", "browser_type": "text",
"image_generate": "prompt", "text_to_speech": "text",
- "vision_analyze": "question", "mixture_of_agents": "user_prompt",
+ "vision_analyze": "question",
"skill_view": "name", "skills_list": "category",
"cronjob": "action",
"execute_code": "code", "delegate_task": "goal",
@@ -253,6 +473,23 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
else:
return f"planning {len(todos_arg)} task(s)"
+ if tool_name in {"terminal", "execute_code"}:
+ key = "code" if tool_name == "execute_code" else "command"
+ command = args.get(key)
+ if command is None:
+ return None
+ preview = summarize_shell_command(str(command))
+ return _truncate_preview(preview, max_len) if preview else None
+
+ if tool_name == "read_file":
+ path = args.get("path") or args.get("file") or args.get("filepath")
+ if path is None:
+ return None
+ label = Path(str(path).replace("\\", "/")).name or str(path)
+ line_label = _read_file_line_label(args)
+ preview = f"{label} {line_label}".strip()
+ return _truncate_preview(preview, max_len) if preview else None
+
if tool_name == "session_search":
query = _oneline(args.get("query", ""))
return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\""
@@ -906,6 +1143,7 @@ def get_cute_tool_message(
When *result* is provided the line is checked for failure indicators.
Failed tool calls get a red prefix and an informational suffix.
"""
+ args = redact_tool_args_for_display(tool_name, args) or args
dur = f"{duration:.1f}s"
is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
skin_prefix = get_skin_tool_prefix()
@@ -943,7 +1181,7 @@ def _wrap(line: str) -> str:
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
- return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
+ return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}")
if tool_name == "process":
action = args.get("action", "?")
sid = args.get("session_id", "")[:12]
@@ -951,7 +1189,7 @@ def _wrap(line: str) -> str:
"wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"}
return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}")
if tool_name == "read_file":
- return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}")
+ return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}")
if tool_name == "write_file":
return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}")
if tool_name == "patch":
@@ -1037,8 +1275,6 @@ def _wrap(line: str) -> str:
return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}")
if tool_name == "vision_analyze":
return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}")
- if tool_name == "mixture_of_agents":
- return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
if tool_name == "send_message":
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
if tool_name == "cronjob":
diff --git a/agent/error_classifier.py b/agent/error_classifier.py
index c39c24a6a5..a64683ba41 100644
--- a/agent/error_classifier.py
+++ b/agent/error_classifier.py
@@ -133,6 +133,31 @@ def is_auth(self) -> bool:
"servicequotaexceededexception",
]
+# Patterns that indicate provider-side overload, NOT a per-credential rate
+# limit or billing problem. The credential is valid — the server is just
+# busy — so the correct recovery is "back off and retry the same key", never
+# "rotate the credential" (rotating exhausts the pool while the endpoint is
+# still busy; a single-key user has nothing to rotate to). Some providers
+# (notably Z.AI / Zhipu) reuse HTTP 429 for server-wide overload, so the 429
+# status path matches the body against this list before falling through to
+# the rate_limit default. Phrases are kept narrow and overload-flavoured so a
+# normal rate-limit message ("you have been rate-limited") doesn't hit this
+# bucket. (#14038, #15297)
+_OVERLOADED_PATTERNS = [
+ "overloaded",
+ "temporarily overloaded",
+ "service is temporarily overloaded",
+ "service may be temporarily overloaded",
+ "server is overloaded",
+ "server overloaded",
+ "service overloaded",
+ "service is overloaded",
+ "upstream overloaded",
+ "currently overloaded",
+ "at capacity",
+ "over capacity",
+]
+
# Usage-limit patterns that need disambiguation (could be billing OR rate_limit)
_USAGE_LIMIT_PATTERNS = [
"usage limit",
@@ -330,6 +355,14 @@ def is_auth(self) -> bool:
# echo back; the underscore form is provider-specific enough.
"content_filter",
"responsibleaipolicyviolation",
+ # MiniMax output-layer safety filter. The error string is surfaced
+ # verbatim by MiniMax SDK / OpenAI-compatible endpoints, usually in the
+ # form "output new_sensitive (1027)" when the model's *output* (often a
+ # large tool-call argument block) trips the upstream safety filter and
+ # the SSE stream is truncated mid-flight. ``new_sensitive`` is the
+ # filter name and is narrow enough that billing / format / auth error
+ # strings will not collide. See #32421.
+ "new_sensitive",
]
# Auth patterns (non-status-code signals)
@@ -717,6 +750,26 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError:
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
if is_disconnect and not status_code:
+ # Reasoning-model override: a transport disconnect on a reasoning
+ # model is much more likely the upstream proxy idle-killing a
+ # long thinking stream than a true context overflow — even on
+ # large sessions. The default disconnect+large-session routing
+ # below would otherwise send the user into the compression
+ # branch (should_compress=True) and silently delete
+ # conversation history on a phantom context-length error.
+ # Reasoning models have multi-minute thinking phases that
+ # routinely exceed the cloud gateway's idle window (NVIDIA
+ # NIM ~120s — first-party repro at NVIDIA/NemoClaw#4846;
+ # OpenAI worker / Anthropic stream-idle similar). The
+ # per-reasoning-model stale-timeout floor in
+ # agent/reasoning_timeouts.py raises the stale-detector
+ # threshold to tolerate long thinking, so a true
+ # transport-layer failure here is recoverable via the retry
+ # path — not via context compression. Reclassify as timeout.
+ # (Part 1 of Fixes #52310.)
+ from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
+ if get_reasoning_stale_timeout_floor(model) is not None:
+ return _result(FailoverReason.timeout, retryable=True)
# Absolute token/message-count thresholds are only a proxy for smaller
# context windows. Large-context sessions can have hundreds of
# messages while still being far below their actual token budget.
@@ -843,7 +896,19 @@ def _classify_by_status(
)
if status_code == 429:
- # Already checked long_context_tier above; this is a normal rate limit
+ # Already checked long_context_tier above. Some providers (notably
+ # Z.AI / Zhipu) reuse HTTP 429 for server-wide overload — same status
+ # code as a true per-credential rate limit, but the credential is
+ # valid and the correct recovery is "back off and retry the same key",
+ # NOT "rotate the credential" (which exhausts the pool while the
+ # endpoint is still busy, and does nothing for a single-key user).
+ # Disambiguate on the error body so an overload 429 takes the
+ # transient-overload path instead of burning the pool. (#14038)
+ if any(p in error_msg for p in _OVERLOADED_PATTERNS):
+ return result_fn(
+ FailoverReason.overloaded,
+ retryable=True,
+ )
return result_fn(
FailoverReason.rate_limit,
retryable=True,
@@ -1194,6 +1259,17 @@ def _classify_by_message(
should_fallback=True,
)
+ # Overloaded / server-busy patterns — must come BEFORE the rate_limit and
+ # billing checks so that a message-only "overloaded" (no 503/529 status,
+ # e.g. some Anthropic-compatible proxies) classifies as a transient
+ # overload (backoff + retry) instead of falling through to `unknown` or
+ # incorrectly triggering credential rotation.
+ if any(p in error_msg for p in _OVERLOADED_PATTERNS):
+ return result_fn(
+ FailoverReason.overloaded,
+ retryable=True,
+ )
+
# Billing patterns
if any(p in error_msg for p in _BILLING_PATTERNS):
return result_fn(
@@ -1283,19 +1359,25 @@ def _extract_status_code(error: Exception) -> Optional[int]:
def _extract_error_body(error: Exception) -> dict:
- """Extract the structured error body from an SDK exception."""
- body = getattr(error, "body", None)
- if isinstance(body, dict):
- return body
- # Some errors have .response.json()
- response = getattr(error, "response", None)
- if response is not None:
- try:
- json_body = response.json()
- if isinstance(json_body, dict):
- return json_body
- except Exception:
- pass
+ """Extract the structured error body from an SDK exception or its cause chain."""
+ current = error
+ for _ in range(5): # Match _extract_status_code() traversal depth.
+ body = getattr(current, "body", None)
+ if isinstance(body, dict):
+ return body
+ # Some errors have .response.json()
+ response = getattr(current, "response", None)
+ if response is not None:
+ try:
+ json_body = response.json()
+ if isinstance(json_body, dict):
+ return json_body
+ except Exception:
+ pass
+ cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
+ if cause is None or cause is current:
+ break
+ current = cause
return {}
diff --git a/agent/file_safety.py b/agent/file_safety.py
index 7a70f96412..482c4217c8 100644
--- a/agent/file_safety.py
+++ b/agent/file_safety.py
@@ -77,15 +77,22 @@ def build_write_denied_prefixes(home: str) -> list[str]:
]
-def get_safe_write_root() -> Optional[str]:
- """Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
- root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
- if not root:
- return None
- try:
- return os.path.realpath(os.path.expanduser(root))
- except Exception:
- return None
+def get_safe_write_roots() -> set[str]:
+ """Return resolved HERMES_WRITE_SAFE_ROOT paths. Supports multiple directories
+ separated by ``os.pathsep`` (``:`` on Unix, ``;`` on Windows).
+ E.g., ``/opt/data:/var/www/html`` on Unix, ``C:\\data;D:\\www`` on Windows."""
+ env = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
+ if not env:
+ return set()
+ roots: set[str] = set()
+ for path in env.split(os.pathsep):
+ if path:
+ try:
+ resolved = os.path.realpath(os.path.expanduser(path))
+ roots.add(resolved)
+ except (OSError, ValueError):
+ continue
+ return roots
def is_write_denied(path: str) -> bool:
@@ -124,9 +131,15 @@ def is_write_denied(path: str) -> bool:
except Exception:
pass
- safe_root = get_safe_write_root()
- if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
- return True
+ safe_roots = get_safe_write_roots()
+ if safe_roots:
+ allowed = False
+ for safe_root in safe_roots:
+ if resolved == safe_root or resolved.startswith(safe_root + os.sep):
+ allowed = True
+ break
+ if not allowed:
+ return True
return False
diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py
deleted file mode 100644
index 222327807b..0000000000
--- a/agent/gemini_cloudcode_adapter.py
+++ /dev/null
@@ -1,909 +0,0 @@
-"""OpenAI-compatible facade that talks to Google's Cloud Code Assist backend.
-
-This adapter lets Hermes use the ``google-gemini-cli`` provider as if it were
-a standard OpenAI-shaped chat completion endpoint, while the underlying HTTP
-traffic goes to ``cloudcode-pa.googleapis.com/v1internal:{generateContent,
-streamGenerateContent}`` with a Bearer access token obtained via OAuth PKCE.
-
-Architecture
-------------
-- ``GeminiCloudCodeClient`` exposes ``.chat.completions.create(**kwargs)``
- mirroring the subset of the OpenAI SDK that ``run_agent.py`` uses.
-- Incoming OpenAI ``messages[]`` / ``tools[]`` / ``tool_choice`` are translated
- to Gemini's native ``contents[]`` / ``tools[].functionDeclarations`` /
- ``toolConfig`` / ``systemInstruction`` shape.
-- The request body is wrapped ``{project, model, user_prompt_id, request}``
- per Code Assist API expectations.
-- Responses (``candidates[].content.parts[]``) are converted back to
- OpenAI ``choices[0].message`` shape with ``content`` + ``tool_calls``.
-- Streaming uses SSE (``?alt=sse``) and yields OpenAI-shaped delta chunks.
-
-Attribution
------------
-Translation semantics follow jenslys/opencode-gemini-auth (MIT) and the public
-Gemini API docs. Request envelope shape
-(``{project, model, user_prompt_id, request}``) is documented nowhere; it is
-reverse-engineered from the opencode-gemini-auth and clawdbot implementations.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import time
-import uuid
-from types import SimpleNamespace
-from typing import Any, Dict, Iterator, List, Optional
-
-import httpx
-
-from agent import google_oauth
-from agent.gemini_schema import sanitize_gemini_tool_parameters
-from agent.google_code_assist import (
- CODE_ASSIST_ENDPOINT,
- CodeAssistError,
- ProjectContext,
- resolve_project_context,
-)
-
-logger = logging.getLogger(__name__)
-
-
-# =============================================================================
-# Request translation: OpenAI → Gemini
-# =============================================================================
-
-_ROLE_MAP_OPENAI_TO_GEMINI = {
- "user": "user",
- "assistant": "model",
- "system": "user", # handled separately via systemInstruction
- "tool": "user", # functionResponse is wrapped in a user-role turn
- "function": "user",
-}
-
-
-def _coerce_content_to_text(content: Any) -> str:
- """OpenAI content may be str or a list of parts; reduce to plain text."""
- if content is None:
- return ""
- if isinstance(content, str):
- return content
- if isinstance(content, list):
- pieces: List[str] = []
- for p in content:
- if isinstance(p, str):
- pieces.append(p)
- elif isinstance(p, dict):
- if p.get("type") == "text" and isinstance(p.get("text"), str):
- pieces.append(p["text"])
- # Multimodal (image_url, etc.) — stub for now; log and skip
- elif p.get("type") in {"image_url", "input_audio"}:
- logger.debug("Dropping multimodal part (not yet supported): %s", p.get("type"))
- return "\n".join(pieces)
- return str(content)
-
-
-def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
- """OpenAI tool_call -> Gemini functionCall part."""
- fn = tool_call.get("function") or {}
- args_raw = fn.get("arguments", "")
- try:
- args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
- except json.JSONDecodeError:
- args = {"_raw": args_raw}
- if not isinstance(args, dict):
- args = {"_value": args}
- return {
- "functionCall": {
- "name": fn.get("name") or "",
- "args": args,
- },
- # Sentinel signature — matches opencode-gemini-auth's approach.
- # Without this, Code Assist rejects function calls that originated
- # outside its own chain.
- "thoughtSignature": "skip_thought_signature_validator",
- }
-
-
-def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:
- """OpenAI tool-role message -> Gemini functionResponse part.
-
- The function name isn't in the OpenAI tool message directly; it must be
- passed via the assistant message that issued the call. For simplicity we
- look up ``name`` on the message (OpenAI SDK copies it there) or on the
- ``tool_call_id`` cross-reference.
- """
- name = str(message.get("name") or message.get("tool_call_id") or "tool")
- content = _coerce_content_to_text(message.get("content"))
- # Gemini expects the response as a dict under `response`. We wrap plain
- # text in {"output": "..."}.
- try:
- parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None
- except json.JSONDecodeError:
- parsed = None
- response = parsed if isinstance(parsed, dict) else {"output": content}
- return {
- "functionResponse": {
- "name": name,
- "response": response,
- },
- }
-
-
-def _build_gemini_contents(
- messages: List[Dict[str, Any]],
-) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
- """Convert OpenAI messages[] to Gemini contents[] + systemInstruction."""
- system_text_parts: List[str] = []
- contents: List[Dict[str, Any]] = []
-
- for msg in messages:
- if not isinstance(msg, dict):
- continue
- role = str(msg.get("role") or "user")
-
- if role == "system":
- system_text_parts.append(_coerce_content_to_text(msg.get("content")))
- continue
-
- # Tool result message — emit a user-role turn with functionResponse
- if role == "tool" or role == "function":
- contents.append({
- "role": "user",
- "parts": [_translate_tool_result_to_gemini(msg)],
- })
- continue
-
- gemini_role = _ROLE_MAP_OPENAI_TO_GEMINI.get(role, "user")
- parts: List[Dict[str, Any]] = []
-
- text = _coerce_content_to_text(msg.get("content"))
- if text:
- parts.append({"text": text})
-
- # Assistant messages can carry tool_calls
- tool_calls = msg.get("tool_calls") or []
- if isinstance(tool_calls, list):
- for tc in tool_calls:
- if isinstance(tc, dict):
- parts.append(_translate_tool_call_to_gemini(tc))
-
- if not parts:
- # Gemini rejects empty parts; skip the turn entirely
- continue
-
- contents.append({"role": gemini_role, "parts": parts})
-
- system_instruction: Optional[Dict[str, Any]] = None
- joined_system = "\n".join(p for p in system_text_parts if p).strip()
- if joined_system:
- system_instruction = {
- "role": "system",
- "parts": [{"text": joined_system}],
- }
-
- return contents, system_instruction
-
-
-def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
- """OpenAI tools[] -> Gemini tools[].functionDeclarations[]."""
- if not isinstance(tools, list) or not tools:
- return []
- declarations: List[Dict[str, Any]] = []
- for t in tools:
- if not isinstance(t, dict):
- continue
- fn = t.get("function") or {}
- if not isinstance(fn, dict):
- continue
- name = fn.get("name")
- if not name:
- continue
- decl = {"name": str(name)}
- if fn.get("description"):
- decl["description"] = str(fn["description"])
- params = fn.get("parameters")
- if isinstance(params, dict):
- decl["parameters"] = sanitize_gemini_tool_parameters(params)
- declarations.append(decl)
- if not declarations:
- return []
- return [{"functionDeclarations": declarations}]
-
-
-def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:
- """OpenAI tool_choice -> Gemini toolConfig.functionCallingConfig."""
- if tool_choice is None:
- return None
- if isinstance(tool_choice, str):
- if tool_choice == "auto":
- return {"functionCallingConfig": {"mode": "AUTO"}}
- if tool_choice == "required":
- return {"functionCallingConfig": {"mode": "ANY"}}
- if tool_choice == "none":
- return {"functionCallingConfig": {"mode": "NONE"}}
- if isinstance(tool_choice, dict):
- fn = tool_choice.get("function") or {}
- name = fn.get("name")
- if name:
- return {
- "functionCallingConfig": {
- "mode": "ANY",
- "allowedFunctionNames": [str(name)],
- },
- }
- return None
-
-
-def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
- """Accept thinkingBudget / thinkingLevel / includeThoughts (+ snake_case)."""
- if not isinstance(config, dict) or not config:
- return None
- budget = config.get("thinkingBudget", config.get("thinking_budget"))
- level = config.get("thinkingLevel", config.get("thinking_level"))
- include = config.get("includeThoughts", config.get("include_thoughts"))
- normalized: Dict[str, Any] = {}
- if isinstance(budget, (int, float)):
- normalized["thinkingBudget"] = int(budget)
- if isinstance(level, str) and level.strip():
- normalized["thinkingLevel"] = level.strip().lower()
- if isinstance(include, bool):
- normalized["includeThoughts"] = include
- return normalized or None
-
-
-def build_gemini_request(
- *,
- messages: List[Dict[str, Any]],
- tools: Any = None,
- tool_choice: Any = None,
- temperature: Optional[float] = None,
- max_tokens: Optional[int] = None,
- top_p: Optional[float] = None,
- stop: Any = None,
- thinking_config: Any = None,
-) -> Dict[str, Any]:
- """Build the inner Gemini request body (goes inside ``request`` wrapper)."""
- contents, system_instruction = _build_gemini_contents(messages)
-
- body: Dict[str, Any] = {"contents": contents}
- if system_instruction is not None:
- body["systemInstruction"] = system_instruction
-
- gemini_tools = _translate_tools_to_gemini(tools)
- if gemini_tools:
- body["tools"] = gemini_tools
- tool_cfg = _translate_tool_choice_to_gemini(tool_choice)
- if tool_cfg is not None:
- body["toolConfig"] = tool_cfg
-
- generation_config: Dict[str, Any] = {}
- if isinstance(temperature, (int, float)):
- generation_config["temperature"] = float(temperature)
- if isinstance(max_tokens, int) and max_tokens > 0:
- generation_config["maxOutputTokens"] = max_tokens
- if isinstance(top_p, (int, float)):
- generation_config["topP"] = float(top_p)
- if isinstance(stop, str) and stop:
- generation_config["stopSequences"] = [stop]
- elif isinstance(stop, list) and stop:
- generation_config["stopSequences"] = [str(s) for s in stop if s]
- normalized_thinking = _normalize_thinking_config(thinking_config)
- if normalized_thinking:
- generation_config["thinkingConfig"] = normalized_thinking
- if generation_config:
- body["generationConfig"] = generation_config
-
- return body
-
-
-def wrap_code_assist_request(
- *,
- project_id: str,
- model: str,
- inner_request: Dict[str, Any],
- user_prompt_id: Optional[str] = None,
-) -> Dict[str, Any]:
- """Wrap the inner Gemini request in the Code Assist envelope."""
- return {
- "project": project_id,
- "model": model,
- "user_prompt_id": user_prompt_id or str(uuid.uuid4()),
- "request": inner_request,
- }
-
-
-# =============================================================================
-# Response translation: Gemini → OpenAI
-# =============================================================================
-
-def _translate_gemini_response(
- resp: Dict[str, Any],
- model: str,
-) -> SimpleNamespace:
- """Non-streaming Gemini response -> OpenAI-shaped SimpleNamespace.
-
- Code Assist wraps the actual Gemini response inside ``response``, so we
- unwrap it first if present.
- """
- inner = resp.get("response") if isinstance(resp.get("response"), dict) else resp
-
- candidates = inner.get("candidates") or []
- if not isinstance(candidates, list) or not candidates:
- return _empty_response(model)
-
- cand = candidates[0]
- content_obj = cand.get("content") if isinstance(cand, dict) else {}
- parts = content_obj.get("parts") if isinstance(content_obj, dict) else []
-
- text_pieces: List[str] = []
- reasoning_pieces: List[str] = []
- tool_calls: List[SimpleNamespace] = []
-
- for i, part in enumerate(parts or []):
- if not isinstance(part, dict):
- continue
- # Thought parts are model's internal reasoning — surface as reasoning,
- # don't mix into content.
- if part.get("thought") is True:
- if isinstance(part.get("text"), str):
- reasoning_pieces.append(part["text"])
- continue
- if isinstance(part.get("text"), str):
- text_pieces.append(part["text"])
- continue
- fc = part.get("functionCall")
- if isinstance(fc, dict) and fc.get("name"):
- try:
- args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
- except (TypeError, ValueError):
- args_str = "{}"
- tool_calls.append(SimpleNamespace(
- id=f"call_{uuid.uuid4().hex[:12]}",
- type="function",
- index=i,
- function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
- ))
-
- finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(
- str(cand.get("finishReason") or "")
- )
-
- usage_meta = inner.get("usageMetadata") or {}
- usage = SimpleNamespace(
- prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
- completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
- total_tokens=int(usage_meta.get("totalTokenCount") or 0),
- prompt_tokens_details=SimpleNamespace(
- cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0),
- ),
- )
-
- message = SimpleNamespace(
- role="assistant",
- content="".join(text_pieces) if text_pieces else None,
- tool_calls=tool_calls or None,
- reasoning="".join(reasoning_pieces) or None,
- reasoning_content="".join(reasoning_pieces) or None,
- reasoning_details=None,
- )
- choice = SimpleNamespace(
- index=0,
- message=message,
- finish_reason=finish_reason,
- )
- return SimpleNamespace(
- id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
- object="chat.completion",
- created=int(time.time()),
- model=model,
- choices=[choice],
- usage=usage,
- )
-
-
-def _empty_response(model: str) -> SimpleNamespace:
- message = SimpleNamespace(
- role="assistant", content="", tool_calls=None,
- reasoning=None, reasoning_content=None, reasoning_details=None,
- )
- choice = SimpleNamespace(index=0, message=message, finish_reason="stop")
- usage = SimpleNamespace(
- prompt_tokens=0, completion_tokens=0, total_tokens=0,
- prompt_tokens_details=SimpleNamespace(cached_tokens=0),
- )
- return SimpleNamespace(
- id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
- object="chat.completion",
- created=int(time.time()),
- model=model,
- choices=[choice],
- usage=usage,
- )
-
-
-def _map_gemini_finish_reason(reason: str) -> str:
- mapping = {
- "STOP": "stop",
- "MAX_TOKENS": "length",
- "SAFETY": "content_filter",
- "RECITATION": "content_filter",
- "OTHER": "stop",
- }
- return mapping.get(reason.upper(), "stop")
-
-
-# =============================================================================
-# Streaming SSE iterator
-# =============================================================================
-
-class _GeminiStreamChunk(SimpleNamespace):
- """Mimics an OpenAI ChatCompletionChunk with .choices[0].delta."""
- pass
-
-
-def _make_stream_chunk(
- *,
- model: str,
- content: str = "",
- tool_call_delta: Optional[Dict[str, Any]] = None,
- finish_reason: Optional[str] = None,
- reasoning: str = "",
-) -> _GeminiStreamChunk:
- delta_kwargs: Dict[str, Any] = {
- "role": "assistant",
- "content": None,
- "tool_calls": None,
- "reasoning": None,
- "reasoning_content": None,
- }
- if content:
- delta_kwargs["content"] = content
- if tool_call_delta is not None:
- delta_kwargs["tool_calls"] = [SimpleNamespace(
- index=tool_call_delta.get("index", 0),
- id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}",
- type="function",
- function=SimpleNamespace(
- name=tool_call_delta.get("name") or "",
- arguments=tool_call_delta.get("arguments") or "",
- ),
- )]
- if reasoning:
- delta_kwargs["reasoning"] = reasoning
- delta_kwargs["reasoning_content"] = reasoning
- delta = SimpleNamespace(**delta_kwargs)
- choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
- return _GeminiStreamChunk(
- id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
- object="chat.completion.chunk",
- created=int(time.time()),
- model=model,
- choices=[choice],
- usage=None,
- )
-
-
-def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
- """Parse Server-Sent Events from an httpx streaming response."""
- buffer = ""
- for chunk in response.iter_text():
- if not chunk:
- continue
- buffer += chunk
- while "\n" in buffer:
- line, buffer = buffer.split("\n", 1)
- line = line.rstrip("\r")
- if not line:
- continue
- if line.startswith("data: "):
- data = line[6:]
- if data == "[DONE]":
- return
- try:
- yield json.loads(data)
- except json.JSONDecodeError:
- logger.debug("Non-JSON SSE line: %s", data[:200])
-
-
-def _translate_stream_event(
- event: Dict[str, Any],
- model: str,
- tool_call_counter: List[int],
-) -> List[_GeminiStreamChunk]:
- """Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s).
-
- ``tool_call_counter`` is a single-element list used as a mutable counter
- across events in the same stream. Each ``functionCall`` part gets a
- fresh, unique OpenAI ``index`` — keying by function name would collide
- whenever the model issues parallel calls to the same tool (e.g. reading
- three files in one turn).
- """
- inner = event.get("response") if isinstance(event.get("response"), dict) else event
- candidates = inner.get("candidates") or []
- if not candidates:
- return []
- cand = candidates[0]
- if not isinstance(cand, dict):
- return []
-
- chunks: List[_GeminiStreamChunk] = []
-
- content = cand.get("content") or {}
- parts = content.get("parts") if isinstance(content, dict) else []
- for part in parts or []:
- if not isinstance(part, dict):
- continue
- if part.get("thought") is True and isinstance(part.get("text"), str):
- chunks.append(_make_stream_chunk(
- model=model, reasoning=part["text"],
- ))
- continue
- if isinstance(part.get("text"), str) and part["text"]:
- chunks.append(_make_stream_chunk(model=model, content=part["text"]))
- fc = part.get("functionCall")
- if isinstance(fc, dict) and fc.get("name"):
- name = str(fc["name"])
- idx = tool_call_counter[0]
- tool_call_counter[0] += 1
- try:
- args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
- except (TypeError, ValueError):
- args_str = "{}"
- chunks.append(_make_stream_chunk(
- model=model,
- tool_call_delta={
- "index": idx,
- "name": name,
- "arguments": args_str,
- },
- ))
-
- finish_reason_raw = str(cand.get("finishReason") or "")
- if finish_reason_raw:
- mapped = _map_gemini_finish_reason(finish_reason_raw)
- if tool_call_counter[0] > 0:
- mapped = "tool_calls"
- chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
- return chunks
-
-
-# =============================================================================
-# GeminiCloudCodeClient — OpenAI-compatible facade
-# =============================================================================
-
-MARKER_BASE_URL = "cloudcode-pa://google"
-
-
-class _GeminiChatCompletions:
- def __init__(self, client: "GeminiCloudCodeClient"):
- self._client = client
-
- def create(self, **kwargs: Any) -> Any:
- return self._client._create_chat_completion(**kwargs)
-
-
-class _GeminiChatNamespace:
- def __init__(self, client: "GeminiCloudCodeClient"):
- self.completions = _GeminiChatCompletions(client)
-
-
-class GeminiCloudCodeClient:
- """Minimal OpenAI-SDK-compatible facade over Code Assist v1internal."""
-
- def __init__(
- self,
- *,
- api_key: Optional[str] = None,
- base_url: Optional[str] = None,
- default_headers: Optional[Dict[str, str]] = None,
- project_id: str = "",
- **_: Any,
- ):
- # `api_key` here is a dummy — real auth is the OAuth access token
- # fetched on every call via agent.google_oauth.get_valid_access_token().
- # We accept the kwarg for openai.OpenAI interface parity.
- self.api_key = api_key or "google-oauth"
- self.base_url = base_url or MARKER_BASE_URL
- self._default_headers = dict(default_headers or {})
- self._configured_project_id = project_id
- self._project_context: Optional[ProjectContext] = None
- self._project_context_lock = False # simple single-thread guard
- self.chat = _GeminiChatNamespace(self)
- self.is_closed = False
- self._http = httpx.Client(timeout=httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0))
-
- def close(self) -> None:
- self.is_closed = True
- try:
- self._http.close()
- except Exception:
- pass
-
- # Implement the OpenAI SDK's context-manager-ish closure check
- def __enter__(self):
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb):
- self.close()
-
- def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:
- """Lazily resolve and cache the project context for this client."""
- if self._project_context is not None:
- return self._project_context
-
- env_project = google_oauth.resolve_project_id_from_env()
- creds = google_oauth.load_credentials()
- stored_project = creds.project_id if creds else ""
-
- # Prefer what's already baked into the creds
- if stored_project:
- self._project_context = ProjectContext(
- project_id=stored_project,
- managed_project_id=creds.managed_project_id if creds else "",
- tier_id="",
- source="stored",
- )
- return self._project_context
-
- ctx = resolve_project_context(
- access_token,
- configured_project_id=self._configured_project_id,
- env_project_id=env_project,
- user_agent_model=model,
- )
- # Persist discovered project back to the creds file so the next
- # session doesn't re-run the discovery.
- if ctx.project_id or ctx.managed_project_id:
- google_oauth.update_project_ids(
- project_id=ctx.project_id,
- managed_project_id=ctx.managed_project_id,
- )
- self._project_context = ctx
- return ctx
-
- def _create_chat_completion(
- self,
- *,
- model: str = "gemini-2.5-flash",
- messages: Optional[List[Dict[str, Any]]] = None,
- stream: bool = False,
- tools: Any = None,
- tool_choice: Any = None,
- temperature: Optional[float] = None,
- max_tokens: Optional[int] = None,
- top_p: Optional[float] = None,
- stop: Any = None,
- extra_body: Optional[Dict[str, Any]] = None,
- timeout: Any = None,
- **_: Any,
- ) -> Any:
- access_token = google_oauth.get_valid_access_token()
- ctx = self._ensure_project_context(access_token, model)
-
- thinking_config = None
- if isinstance(extra_body, dict):
- thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
-
- inner = build_gemini_request(
- messages=messages or [],
- tools=tools,
- tool_choice=tool_choice,
- temperature=temperature,
- max_tokens=max_tokens,
- top_p=top_p,
- stop=stop,
- thinking_config=thinking_config,
- )
- wrapped = wrap_code_assist_request(
- project_id=ctx.project_id,
- model=model,
- inner_request=inner,
- )
-
- headers = {
- "Content-Type": "application/json",
- "Accept": "application/json",
- "Authorization": f"Bearer {access_token}",
- "User-Agent": "hermes-agent (gemini-cli-compat)",
- "X-Goog-Api-Client": "gl-python/hermes",
- "x-activity-request-id": str(uuid.uuid4()),
- }
- headers.update(self._default_headers)
-
- if stream:
- return self._stream_completion(model=model, wrapped=wrapped, headers=headers)
-
- url = f"{CODE_ASSIST_ENDPOINT}/v1internal:generateContent"
- response = self._http.post(url, json=wrapped, headers=headers)
- if response.status_code != 200:
- raise _gemini_http_error(response)
- try:
- payload = response.json()
- except ValueError as exc:
- raise CodeAssistError(
- f"Invalid JSON from Code Assist: {exc}",
- code="code_assist_invalid_json",
- ) from exc
- return _translate_gemini_response(payload, model=model)
-
- def _stream_completion(
- self,
- *,
- model: str,
- wrapped: Dict[str, Any],
- headers: Dict[str, str],
- ) -> Iterator[_GeminiStreamChunk]:
- """Generator that yields OpenAI-shaped streaming chunks."""
- url = f"{CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse"
- stream_headers = dict(headers)
- stream_headers["Accept"] = "text/event-stream"
-
- def _generator() -> Iterator[_GeminiStreamChunk]:
- try:
- with self._http.stream("POST", url, json=wrapped, headers=stream_headers) as response:
- if response.status_code != 200:
- # Materialize error body for better diagnostics
- response.read()
- raise _gemini_http_error(response)
- tool_call_counter: List[int] = [0]
- for event in _iter_sse_events(response):
- for chunk in _translate_stream_event(event, model, tool_call_counter):
- yield chunk
- except httpx.HTTPError as exc:
- raise CodeAssistError(
- f"Streaming request failed: {exc}",
- code="code_assist_stream_error",
- ) from exc
-
- return _generator()
-
-
-def _gemini_http_error(response: httpx.Response) -> CodeAssistError:
- """Translate an httpx response into a CodeAssistError with rich metadata.
-
- Parses Google's error envelope (``{"error": {"code", "message", "status",
- "details": [...]}}``) so the agent's error classifier can reason about
- the failure — ``status_code`` enables the rate_limit / auth classification
- paths, and ``response`` lets the main loop honor ``Retry-After`` just
- like it does for OpenAI SDK exceptions.
-
- Also lifts a few recognizable Google conditions into human-readable
- messages so the user sees something better than a 500-char JSON dump:
-
- MODEL_CAPACITY_EXHAUSTED → "Gemini model capacity exhausted for
- . This is a Google-side throttle..."
- RESOURCE_EXHAUSTED w/o reason → quota-style message
- 404 → "Model not found at cloudcode-pa..."
- """
- status = response.status_code
-
- # Parse the body once, surviving any weird encodings.
- body_text = ""
- body_json: Dict[str, Any] = {}
- try:
- body_text = response.text
- except Exception:
- body_text = ""
- if body_text:
- try:
- parsed = json.loads(body_text)
- if isinstance(parsed, dict):
- body_json = parsed
- except (ValueError, TypeError):
- body_json = {}
-
- # Dig into Google's error envelope. Shape is:
- # {"error": {"code": 429, "message": "...", "status": "RESOURCE_EXHAUSTED",
- # "details": [{"@type": ".../ErrorInfo", "reason": "MODEL_CAPACITY_EXHAUSTED",
- # "metadata": {...}},
- # {"@type": ".../RetryInfo", "retryDelay": "30s"}]}}
- err_obj = body_json.get("error") if isinstance(body_json, dict) else None
- if not isinstance(err_obj, dict):
- err_obj = {}
- err_status = str(err_obj.get("status") or "").strip()
- err_message = str(err_obj.get("message") or "").strip()
- _raw_details = err_obj.get("details")
- err_details_list = _raw_details if isinstance(_raw_details, list) else []
-
- # Extract google.rpc.ErrorInfo reason + metadata. There may be more
- # than one ErrorInfo (rare), so we pick the first one with a reason.
- error_reason = ""
- error_metadata: Dict[str, Any] = {}
- retry_delay_seconds: Optional[float] = None
- for detail in err_details_list:
- if not isinstance(detail, dict):
- continue
- type_url = str(detail.get("@type") or "")
- if not error_reason and type_url.endswith("/google.rpc.ErrorInfo"):
- reason = detail.get("reason")
- if isinstance(reason, str) and reason:
- error_reason = reason
- md = detail.get("metadata")
- if isinstance(md, dict):
- error_metadata = md
- elif retry_delay_seconds is None and type_url.endswith("/google.rpc.RetryInfo"):
- # retryDelay is a google.protobuf.Duration string like "30s" or "1.5s".
- delay_raw = detail.get("retryDelay")
- if isinstance(delay_raw, str) and delay_raw.endswith("s"):
- try:
- retry_delay_seconds = float(delay_raw[:-1])
- except ValueError:
- pass
- elif isinstance(delay_raw, (int, float)):
- retry_delay_seconds = float(delay_raw)
-
- # Fall back to the Retry-After header if the body didn't include RetryInfo.
- if retry_delay_seconds is None:
- try:
- header_val = response.headers.get("Retry-After") or response.headers.get("retry-after")
- except Exception:
- header_val = None
- if header_val:
- try:
- retry_delay_seconds = float(header_val)
- except (TypeError, ValueError):
- retry_delay_seconds = None
-
- # Classify the error code. ``code_assist_rate_limited`` stays the default
- # for 429s; a more specific reason tag helps downstream callers (e.g. tests,
- # logs) without changing the rate_limit classification path.
- code = f"code_assist_http_{status}"
- if status == 401:
- code = "code_assist_unauthorized"
- elif status == 429:
- code = "code_assist_rate_limited"
- if error_reason == "MODEL_CAPACITY_EXHAUSTED":
- code = "code_assist_capacity_exhausted"
-
- # Build a human-readable message. Keep the status + a raw-body tail for
- # debugging, but lead with a friendlier summary when we recognize the
- # Google signal.
- model_hint = ""
- if isinstance(error_metadata, dict):
- model_hint = str(error_metadata.get("model") or error_metadata.get("modelId") or "").strip()
-
- if status == 429 and error_reason == "MODEL_CAPACITY_EXHAUSTED":
- target = model_hint or "this Gemini model"
- message = (
- f"Gemini capacity exhausted for {target} (Google-side throttle, "
- f"not a Hermes issue). Try a different Gemini model or set a "
- f"fallback_providers entry to a non-Gemini provider."
- )
- if retry_delay_seconds is not None:
- message += f" Google suggests retrying in {retry_delay_seconds:g}s."
- elif status == 429 and err_status == "RESOURCE_EXHAUSTED":
- message = (
- f"Gemini quota exhausted ({err_message or 'RESOURCE_EXHAUSTED'}). "
- f"Check /gquota for remaining daily requests."
- )
- if retry_delay_seconds is not None:
- message += f" Retry suggested in {retry_delay_seconds:g}s."
- elif status == 404:
- # Google returns 404 when a model has been retired or renamed.
- target = model_hint or (err_message or "model")
- message = (
- f"Code Assist 404: {target} is not available at "
- f"cloudcode-pa.googleapis.com. It may have been renamed or "
- f"retired. Check hermes_cli/models.py for the current list."
- )
- elif err_message:
- # Generic fallback with the parsed message.
- message = f"Code Assist HTTP {status} ({err_status or 'error'}): {err_message}"
- else:
- # Last-ditch fallback — raw body snippet.
- message = f"Code Assist returned HTTP {status}: {body_text[:500]}"
-
- return CodeAssistError(
- message,
- code=code,
- status_code=status,
- response=response,
- retry_after=retry_delay_seconds,
- details={
- "status": err_status,
- "reason": error_reason,
- "metadata": error_metadata,
- "message": err_message,
- },
- )
diff --git a/agent/google_code_assist.py b/agent/google_code_assist.py
deleted file mode 100644
index eec6441f80..0000000000
--- a/agent/google_code_assist.py
+++ /dev/null
@@ -1,451 +0,0 @@
-"""Google Code Assist API client — project discovery, onboarding, quota.
-
-The Code Assist API powers Google's official gemini-cli. It sits at
-``cloudcode-pa.googleapis.com`` and provides:
-
-- Free tier access (generous daily quota) for personal Google accounts
-- Paid tier access via GCP projects with billing / Workspace / Standard / Enterprise
-
-This module handles the control-plane dance needed before inference:
-
-1. ``load_code_assist()`` — probe the user's account to learn what tier they're on
- and whether a ``cloudaicompanionProject`` is already assigned.
-2. ``onboard_user()`` — if the user hasn't been onboarded yet (new account, fresh
- free tier, etc.), call this with the chosen tier + project id. Supports LRO
- polling for slow provisioning.
-3. ``retrieve_user_quota()`` — fetch the ``buckets[]`` array showing remaining
- quota per model, used by the ``/gquota`` slash command.
-
-VPC-SC handling: enterprise accounts under a VPC Service Controls perimeter
-will get ``SECURITY_POLICY_VIOLATED`` on ``load_code_assist``. We catch this
-and force the account to ``standard-tier`` so the call chain still succeeds.
-
-Derived from opencode-gemini-auth (MIT) and clawdbot/extensions/google. The
-request/response shapes are specific to Google's internal Code Assist API,
-documented nowhere public — we copy them from the reference implementations.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import time
-import urllib.error
-import urllib.request
-import uuid
-from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional
-
-logger = logging.getLogger(__name__)
-
-
-# =============================================================================
-# Constants
-# =============================================================================
-
-CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
-
-# Fallback endpoints tried when prod returns an error during project discovery
-FALLBACK_ENDPOINTS = [
- "https://daily-cloudcode-pa.sandbox.googleapis.com",
- "https://autopush-cloudcode-pa.sandbox.googleapis.com",
-]
-
-# Tier identifiers that Google's API uses
-FREE_TIER_ID = "free-tier"
-LEGACY_TIER_ID = "legacy-tier"
-STANDARD_TIER_ID = "standard-tier"
-
-# Default HTTP headers matching gemini-cli's fingerprint.
-# Google may reject unrecognized User-Agents on these internal endpoints.
-_GEMINI_CLI_USER_AGENT = "google-api-nodejs-client/9.15.1 (gzip)"
-_X_GOOG_API_CLIENT = "gl-node/24.0.0"
-_DEFAULT_REQUEST_TIMEOUT = 30.0
-_ONBOARDING_POLL_ATTEMPTS = 12
-_ONBOARDING_POLL_INTERVAL_SECONDS = 5.0
-
-
-class CodeAssistError(RuntimeError):
- """Exception raised by the Code Assist (``cloudcode-pa``) integration.
-
- Carries HTTP status / response / retry-after metadata so the agent's
- ``error_classifier._extract_status_code`` and the main loop's Retry-After
- handling (which walks ``error.response.headers``) pick up the right
- signals. Without these, 429s from the OAuth path look like opaque
- ``RuntimeError`` and skip the rate-limit path.
- """
-
- def __init__(
- self,
- message: str,
- *,
- code: str = "code_assist_error",
- status_code: Optional[int] = None,
- response: Any = None,
- retry_after: Optional[float] = None,
- details: Optional[Dict[str, Any]] = None,
- ) -> None:
- super().__init__(message)
- self.code = code
- # ``status_code`` is picked up by ``agent.error_classifier._extract_status_code``
- # so a 429 from Code Assist classifies as FailoverReason.rate_limit and
- # triggers the main loop's fallback_providers chain the same way SDK
- # errors do.
- self.status_code = status_code
- # ``response`` is the underlying ``httpx.Response`` (or a shim with a
- # ``.headers`` mapping and ``.json()`` method). The main loop reads
- # ``error.response.headers["Retry-After"]`` to honor Google's retry
- # hints when the backend throttles us.
- self.response = response
- # Parsed ``Retry-After`` seconds (kept separately for convenience —
- # Google returns retry hints in both the header and the error body's
- # ``google.rpc.RetryInfo`` details, and we pick whichever we found).
- self.retry_after = retry_after
- # Parsed structured error details from the Google error envelope
- # (e.g. ``{"reason": "MODEL_CAPACITY_EXHAUSTED", "status": "RESOURCE_EXHAUSTED"}``).
- # Useful for logging and for tests that want to assert on specifics.
- self.details = details or {}
-
-
-class ProjectIdRequiredError(CodeAssistError):
- def __init__(self, message: str = "GCP project id required for this tier") -> None:
- super().__init__(message, code="code_assist_project_id_required")
-
-
-# =============================================================================
-# HTTP primitive (auth via Bearer token passed per-call)
-# =============================================================================
-
-def _build_headers(access_token: str, *, user_agent_model: str = "") -> Dict[str, str]:
- ua = _GEMINI_CLI_USER_AGENT
- if user_agent_model:
- ua = f"{ua} model/{user_agent_model}"
- return {
- "Content-Type": "application/json",
- "Accept": "application/json",
- "Authorization": f"Bearer {access_token}",
- "User-Agent": ua,
- "X-Goog-Api-Client": _X_GOOG_API_CLIENT,
- "x-activity-request-id": str(uuid.uuid4()),
- }
-
-
-def _client_metadata() -> Dict[str, str]:
- """Match Google's gemini-cli exactly — unrecognized metadata may be rejected."""
- return {
- "ideType": "IDE_UNSPECIFIED",
- "platform": "PLATFORM_UNSPECIFIED",
- "pluginType": "GEMINI",
- }
-
-
-def _post_json(
- url: str,
- body: Dict[str, Any],
- access_token: str,
- *,
- timeout: float = _DEFAULT_REQUEST_TIMEOUT,
- user_agent_model: str = "",
-) -> Dict[str, Any]:
- data = json.dumps(body).encode("utf-8")
- request = urllib.request.Request(
- url, data=data, method="POST",
- headers=_build_headers(access_token, user_agent_model=user_agent_model),
- )
- try:
- with urllib.request.urlopen(request, timeout=timeout) as response:
- raw = response.read().decode("utf-8", errors="replace")
- return json.loads(raw) if raw else {}
- except urllib.error.HTTPError as exc:
- detail = ""
- try:
- detail = exc.read().decode("utf-8", errors="replace")
- except Exception:
- pass
- # Special case: VPC-SC violation should be distinguishable
- if _is_vpc_sc_violation(detail):
- raise CodeAssistError(
- f"VPC-SC policy violation: {detail}",
- code="code_assist_vpc_sc",
- ) from exc
- raise CodeAssistError(
- f"Code Assist HTTP {exc.code}: {detail or exc.reason}",
- code=f"code_assist_http_{exc.code}",
- ) from exc
- except urllib.error.URLError as exc:
- raise CodeAssistError(
- f"Code Assist request failed: {exc}",
- code="code_assist_network_error",
- ) from exc
-
-
-def _is_vpc_sc_violation(body: str) -> bool:
- """Detect a VPC Service Controls violation from a response body."""
- if not body:
- return False
- try:
- parsed = json.loads(body)
- except (json.JSONDecodeError, ValueError):
- return "SECURITY_POLICY_VIOLATED" in body
- # Walk the nested error structure Google uses
- error = parsed.get("error") if isinstance(parsed, dict) else None
- if not isinstance(error, dict):
- return False
- details = error.get("details") or []
- if isinstance(details, list):
- for item in details:
- if isinstance(item, dict):
- reason = item.get("reason") or ""
- if reason == "SECURITY_POLICY_VIOLATED":
- return True
- msg = str(error.get("message", ""))
- return "SECURITY_POLICY_VIOLATED" in msg
-
-
-# =============================================================================
-# load_code_assist — discovers current tier + assigned project
-# =============================================================================
-
-@dataclass
-class CodeAssistProjectInfo:
- """Result from ``load_code_assist``."""
- current_tier_id: str = ""
- cloudaicompanion_project: str = "" # Google-managed project (free tier)
- allowed_tiers: List[str] = field(default_factory=list)
- raw: Dict[str, Any] = field(default_factory=dict)
-
-
-def load_code_assist(
- access_token: str,
- *,
- project_id: str = "",
- user_agent_model: str = "",
-) -> CodeAssistProjectInfo:
- """Call ``POST /v1internal:loadCodeAssist`` with prod → sandbox fallback.
-
- Returns whatever tier + project info Google reports. On VPC-SC violations,
- returns a synthetic ``standard-tier`` result so the chain can continue.
- """
- body: Dict[str, Any] = {
- "metadata": {
- "duetProject": project_id,
- **_client_metadata(),
- },
- }
- if project_id:
- body["cloudaicompanionProject"] = project_id
-
- endpoints = [CODE_ASSIST_ENDPOINT] + FALLBACK_ENDPOINTS
- last_err: Optional[Exception] = None
- for endpoint in endpoints:
- url = f"{endpoint}/v1internal:loadCodeAssist"
- try:
- resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
- return _parse_load_response(resp)
- except CodeAssistError as exc:
- if exc.code == "code_assist_vpc_sc":
- logger.info("VPC-SC violation on %s — defaulting to standard-tier", endpoint)
- return CodeAssistProjectInfo(
- current_tier_id=STANDARD_TIER_ID,
- cloudaicompanion_project=project_id,
- )
- last_err = exc
- logger.warning("loadCodeAssist failed on %s: %s", endpoint, exc)
- continue
- if last_err:
- raise last_err
- return CodeAssistProjectInfo()
-
-
-def _parse_load_response(resp: Dict[str, Any]) -> CodeAssistProjectInfo:
- current_tier = resp.get("currentTier") or {}
- tier_id = str(current_tier.get("id") or "") if isinstance(current_tier, dict) else ""
- project = str(resp.get("cloudaicompanionProject") or "")
- allowed = resp.get("allowedTiers") or []
- allowed_ids: List[str] = []
- if isinstance(allowed, list):
- for t in allowed:
- if isinstance(t, dict):
- tid = str(t.get("id") or "")
- if tid:
- allowed_ids.append(tid)
- return CodeAssistProjectInfo(
- current_tier_id=tier_id,
- cloudaicompanion_project=project,
- allowed_tiers=allowed_ids,
- raw=resp,
- )
-
-
-# =============================================================================
-# onboard_user — provisions a new user on a tier (with LRO polling)
-# =============================================================================
-
-def onboard_user(
- access_token: str,
- *,
- tier_id: str,
- project_id: str = "",
- user_agent_model: str = "",
-) -> Dict[str, Any]:
- """Call ``POST /v1internal:onboardUser`` to provision the user.
-
- For paid tiers, ``project_id`` is REQUIRED (raises ProjectIdRequiredError).
- For free tiers, ``project_id`` is optional — Google will assign one.
-
- Returns the final operation response. Polls ``/v1internal/`` for up
- to ``_ONBOARDING_POLL_ATTEMPTS`` × ``_ONBOARDING_POLL_INTERVAL_SECONDS``
- (default: 12 × 5s = 1 min).
- """
- if tier_id != FREE_TIER_ID and tier_id != LEGACY_TIER_ID and not project_id:
- raise ProjectIdRequiredError(
- f"Tier {tier_id!r} requires a GCP project id. "
- "Set HERMES_GEMINI_PROJECT_ID or GOOGLE_CLOUD_PROJECT."
- )
-
- body: Dict[str, Any] = {
- "tierId": tier_id,
- "metadata": _client_metadata(),
- }
- if project_id:
- body["cloudaicompanionProject"] = project_id
-
- endpoint = CODE_ASSIST_ENDPOINT
- url = f"{endpoint}/v1internal:onboardUser"
- resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
-
- # Poll if LRO (long-running operation)
- if not resp.get("done"):
- op_name = resp.get("name", "")
- if not op_name:
- return resp
- for attempt in range(_ONBOARDING_POLL_ATTEMPTS):
- time.sleep(_ONBOARDING_POLL_INTERVAL_SECONDS)
- poll_url = f"{endpoint}/v1internal/{op_name}"
- try:
- poll_resp = _post_json(poll_url, {}, access_token, user_agent_model=user_agent_model)
- except CodeAssistError as exc:
- logger.warning("Onboarding poll attempt %d failed: %s", attempt + 1, exc)
- continue
- if poll_resp.get("done"):
- return poll_resp
- logger.warning("Onboarding did not complete within %d attempts", _ONBOARDING_POLL_ATTEMPTS)
- return resp
-
-
-# =============================================================================
-# retrieve_user_quota — for /gquota
-# =============================================================================
-
-@dataclass
-class QuotaBucket:
- model_id: str
- token_type: str = ""
- remaining_fraction: float = 0.0
- reset_time_iso: str = ""
- raw: Dict[str, Any] = field(default_factory=dict)
-
-
-def retrieve_user_quota(
- access_token: str,
- *,
- project_id: str = "",
- user_agent_model: str = "",
-) -> List[QuotaBucket]:
- """Call ``POST /v1internal:retrieveUserQuota`` and parse ``buckets[]``."""
- body: Dict[str, Any] = {}
- if project_id:
- body["project"] = project_id
- url = f"{CODE_ASSIST_ENDPOINT}/v1internal:retrieveUserQuota"
- resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
- raw_buckets = resp.get("buckets") or []
- buckets: List[QuotaBucket] = []
- if not isinstance(raw_buckets, list):
- return buckets
- for b in raw_buckets:
- if not isinstance(b, dict):
- continue
- buckets.append(QuotaBucket(
- model_id=str(b.get("modelId") or ""),
- token_type=str(b.get("tokenType") or ""),
- remaining_fraction=float(b.get("remainingFraction") or 0.0),
- reset_time_iso=str(b.get("resetTime") or ""),
- raw=b,
- ))
- return buckets
-
-
-# =============================================================================
-# Project context resolution
-# =============================================================================
-
-@dataclass
-class ProjectContext:
- """Resolved state for a given OAuth session."""
- project_id: str = "" # effective project id sent on requests
- managed_project_id: str = "" # Google-assigned project (free tier)
- tier_id: str = ""
- source: str = "" # "env", "config", "discovered", "onboarded"
-
-
-def resolve_project_context(
- access_token: str,
- *,
- configured_project_id: str = "",
- env_project_id: str = "",
- user_agent_model: str = "",
-) -> ProjectContext:
- """Figure out what project id + tier to use for requests.
-
- Priority:
- 1. If configured_project_id or env_project_id is set, use that directly
- and short-circuit (no discovery needed).
- 2. Otherwise call loadCodeAssist to see what Google says.
- 3. If no tier assigned yet, onboard the user (free tier default).
- """
- # Short-circuit: caller provided a project id
- if configured_project_id:
- return ProjectContext(
- project_id=configured_project_id,
- tier_id=STANDARD_TIER_ID, # assume paid since they specified one
- source="config",
- )
- if env_project_id:
- return ProjectContext(
- project_id=env_project_id,
- tier_id=STANDARD_TIER_ID,
- source="env",
- )
-
- # Discover via loadCodeAssist
- info = load_code_assist(access_token, user_agent_model=user_agent_model)
-
- effective_project = info.cloudaicompanion_project
- tier = info.current_tier_id
-
- if not tier:
- # User hasn't been onboarded — provision them on free tier
- onboard_resp = onboard_user(
- access_token,
- tier_id=FREE_TIER_ID,
- project_id="",
- user_agent_model=user_agent_model,
- )
- # Re-parse from the onboard response
- response_body = onboard_resp.get("response") or {}
- if isinstance(response_body, dict):
- effective_project = (
- effective_project
- or str(response_body.get("cloudaicompanionProject") or "")
- )
- tier = FREE_TIER_ID
- source = "onboarded"
- else:
- source = "discovered"
-
- return ProjectContext(
- project_id=effective_project,
- managed_project_id=effective_project if tier == FREE_TIER_ID else "",
- tier_id=tier,
- source=source,
- )
diff --git a/agent/google_oauth.py b/agent/google_oauth.py
deleted file mode 100644
index 9eb55ec19d..0000000000
--- a/agent/google_oauth.py
+++ /dev/null
@@ -1,1067 +0,0 @@
-"""Google OAuth PKCE flow for the Gemini (google-gemini-cli) inference provider.
-
-This module implements Authorization Code + PKCE (S256) OAuth against Google's
-accounts.google.com endpoints. The resulting access token is used by
-``agent.gemini_cloudcode_adapter`` to talk to ``cloudcode-pa.googleapis.com``
-(Google's Code Assist backend that powers the Gemini CLI's free and paid tiers).
-
-Synthesized from:
-- jenslys/opencode-gemini-auth (MIT) — overall flow shape, public OAuth creds, request format
-- clawdbot/extensions/google/ — refresh-token rotation, VPC-SC handling reference
-- PRs #10176 (@sliverp) and #10779 (@newarthur) — PKCE module structure, cross-process lock
-
-Storage (``~/.hermes/auth/google_oauth.json``, chmod 0o600):
-
- {
- "refresh": "refreshToken|projectId|managedProjectId",
- "access": "...",
- "expires": 1744848000000, // unix MILLIseconds
- "email": "user@example.com"
- }
-
-The ``refresh`` field packs the refresh_token together with the resolved GCP
-project IDs so subsequent sessions don't need to re-discover the project.
-This matches opencode-gemini-auth's storage contract exactly.
-
-The packed format stays parseable even if no project IDs are present — just
-a bare refresh_token is treated as "packed with empty IDs".
-
-Public client credentials
--------------------------
-The client_id and client_secret below are Google's PUBLIC desktop OAuth client
-for their own open-source gemini-cli. They are baked into every copy of the
-gemini-cli npm package and are NOT confidential — desktop OAuth clients have
-no secret-keeping requirement (PKCE provides the security). Shipping them here
-is consistent with opencode-gemini-auth and the official Google gemini-cli.
-
-Policy note: Google considers using this OAuth client with third-party software
-a policy violation. Users see an upfront warning with ``confirm(default=False)``
-before authorization begins.
-"""
-
-from __future__ import annotations
-
-import base64
-import contextlib
-import hashlib
-import http.server
-import json
-import logging
-import os
-import secrets
-import stat
-import threading
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any, Dict, Optional, Tuple
-
-from hermes_constants import get_hermes_home, secure_parent_dir
-
-logger = logging.getLogger(__name__)
-
-
-# =============================================================================
-# OAuth client credential resolution.
-#
-# Resolution order:
-# 1. HERMES_GEMINI_CLIENT_ID / HERMES_GEMINI_CLIENT_SECRET env vars (power users)
-# 2. Shipped defaults — Google's public gemini-cli desktop OAuth client
-# (baked into every copy of Google's open-source gemini-cli; NOT
-# confidential — desktop OAuth clients use PKCE, not client_secret, for
-# security). Using these matches opencode-gemini-auth behavior.
-# 3. Fallback: scrape from a locally installed gemini-cli binary (helps forks
-# that deliberately wipe the shipped defaults).
-# 4. Fail with a helpful error.
-# =============================================================================
-
-ENV_CLIENT_ID = "HERMES_GEMINI_CLIENT_ID"
-ENV_CLIENT_SECRET = "HERMES_GEMINI_CLIENT_SECRET"
-
-# Public gemini-cli desktop OAuth client (shipped in Google's open-source
-# gemini-cli MIT repo). Composed piecewise to keep the constants readable and
-# to pair each piece with an explicit comment about why it is non-confidential.
-# See: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
-_PUBLIC_CLIENT_ID_PROJECT_NUM = "681255809395"
-_PUBLIC_CLIENT_ID_HASH = "oo8ft2oprdrnp9e3aqf6av3hmdib135j"
-_PUBLIC_CLIENT_SECRET_SUFFIX = "4uHgMPm-1o7Sk-geV6Cu5clXFsxl"
-
-_DEFAULT_CLIENT_ID = (
- f"{_PUBLIC_CLIENT_ID_PROJECT_NUM}-{_PUBLIC_CLIENT_ID_HASH}"
- ".apps.googleusercontent.com"
-)
-_DEFAULT_CLIENT_SECRET = f"GOCSPX-{_PUBLIC_CLIENT_SECRET_SUFFIX}"
-
-# Regex patterns for fallback scraping from an installed gemini-cli.
-import re as _re
-from utils import atomic_replace
-_CLIENT_ID_PATTERN = _re.compile(
- r"OAUTH_CLIENT_ID\s*=\s*['\"]([0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com)['\"]"
-)
-_CLIENT_SECRET_PATTERN = _re.compile(
- r"OAUTH_CLIENT_SECRET\s*=\s*['\"](GOCSPX-[A-Za-z0-9_-]+)['\"]"
-)
-_CLIENT_ID_SHAPE = _re.compile(r"([0-9]{8,}-[a-z0-9]{20,}\.apps\.googleusercontent\.com)")
-_CLIENT_SECRET_SHAPE = _re.compile(r"(GOCSPX-[A-Za-z0-9_-]{20,})")
-
-
-# =============================================================================
-# Endpoints & constants
-# =============================================================================
-
-AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
-TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
-USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v1/userinfo"
-
-OAUTH_SCOPES = (
- "https://www.googleapis.com/auth/cloud-platform "
- "https://www.googleapis.com/auth/userinfo.email "
- "https://www.googleapis.com/auth/userinfo.profile"
-)
-
-DEFAULT_REDIRECT_PORT = 8085
-REDIRECT_HOST = "127.0.0.1"
-CALLBACK_PATH = "/oauth2callback"
-
-# 60-second clock skew buffer (matches opencode-gemini-auth).
-REFRESH_SKEW_SECONDS = 60
-
-TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0
-CALLBACK_WAIT_SECONDS = 300
-LOCK_TIMEOUT_SECONDS = 30.0
-
-# Headless env detection
-_HEADLESS_ENV_VARS = ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "HERMES_HEADLESS")
-
-
-# =============================================================================
-# Error type
-# =============================================================================
-
-class GoogleOAuthError(RuntimeError):
- """Raised for any failure in the Google OAuth flow."""
-
- def __init__(self, message: str, *, code: str = "google_oauth_error") -> None:
- super().__init__(message)
- self.code = code
-
-
-# =============================================================================
-# File paths & cross-process locking
-# =============================================================================
-
-def _credentials_path() -> Path:
- return get_hermes_home() / "auth" / "google_oauth.json"
-
-
-def _lock_path() -> Path:
- return _credentials_path().with_suffix(".json.lock")
-
-
-_lock_state = threading.local()
-
-
-@contextlib.contextmanager
-def _credentials_lock(timeout_seconds: float = LOCK_TIMEOUT_SECONDS):
- """Cross-process lock around the credentials file (fcntl POSIX / msvcrt Windows)."""
- depth = getattr(_lock_state, "depth", 0)
- if depth > 0:
- _lock_state.depth = depth + 1
- try:
- yield
- finally:
- _lock_state.depth -= 1
- return
-
- lock_file_path = _lock_path()
- lock_file_path.parent.mkdir(parents=True, exist_ok=True)
- fd = os.open(str(lock_file_path), os.O_CREAT | os.O_RDWR, 0o600)
- acquired = False
- try:
- try:
- import fcntl
- except ImportError:
- fcntl = None
-
- if fcntl is not None:
- deadline = time.monotonic() + max(0.0, float(timeout_seconds))
- while True:
- try:
- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
- acquired = True
- break
- except BlockingIOError:
- if time.monotonic() >= deadline:
- raise TimeoutError(
- f"Timed out acquiring Google OAuth credentials lock at {lock_file_path}."
- )
- time.sleep(0.05)
- else:
- try:
- import msvcrt # type: ignore[import-not-found]
-
- deadline = time.monotonic() + max(0.0, float(timeout_seconds))
- while True:
- try:
- msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
- acquired = True
- break
- except OSError:
- if time.monotonic() >= deadline:
- raise TimeoutError(
- f"Timed out acquiring Google OAuth credentials lock at {lock_file_path}."
- )
- time.sleep(0.05)
- except ImportError:
- acquired = True
-
- _lock_state.depth = 1
- yield
- finally:
- try:
- if acquired:
- try:
- import fcntl
-
- fcntl.flock(fd, fcntl.LOCK_UN)
- except ImportError:
- try:
- import msvcrt # type: ignore[import-not-found]
-
- try:
- msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
- except OSError:
- pass
- except ImportError:
- pass
- finally:
- os.close(fd)
- _lock_state.depth = 0
-
-
-# =============================================================================
-# Client ID resolution
-# =============================================================================
-
-_scraped_creds_cache: Dict[str, str] = {}
-
-
-def _locate_gemini_cli_oauth_js() -> Optional[Path]:
- """Walk the user's gemini binary install to find its oauth2.js.
-
- Returns None if gemini isn't installed. Supports both the npm install
- (``node_modules/@google/gemini-cli-core/dist/**/code_assist/oauth2.js``)
- and the Homebrew ``bundle/`` layout.
- """
- import shutil
-
- gemini = shutil.which("gemini")
- if not gemini:
- return None
-
- try:
- real = Path(gemini).resolve()
- except OSError:
- return None
-
- # Walk up from the binary to find npm install root
- search_dirs: list[Path] = []
- cur = real.parent
- for _ in range(8): # don't walk too far
- search_dirs.append(cur)
- if (cur / "node_modules").exists():
- search_dirs.append(cur / "node_modules" / "@google" / "gemini-cli-core")
- break
- if cur.parent == cur:
- break
- cur = cur.parent
-
- for root in search_dirs:
- if not root.exists():
- continue
- # Common known paths
- candidates = [
- root / "dist" / "src" / "code_assist" / "oauth2.js",
- root / "dist" / "code_assist" / "oauth2.js",
- root / "src" / "code_assist" / "oauth2.js",
- ]
- for c in candidates:
- if c.exists():
- return c
- # Recursive fallback: look for oauth2.js within 10 dirs deep
- try:
- for path in root.rglob("oauth2.js"):
- return path
- except (OSError, ValueError):
- continue
-
- return None
-
-
-def _scrape_client_credentials() -> Tuple[str, str]:
- """Extract client_id + client_secret from the local gemini-cli install."""
- if _scraped_creds_cache.get("resolved"):
- return _scraped_creds_cache.get("client_id", ""), _scraped_creds_cache.get("client_secret", "")
-
- oauth_js = _locate_gemini_cli_oauth_js()
- if oauth_js is None:
- _scraped_creds_cache["resolved"] = "1" # Don't retry on every call
- return "", ""
-
- try:
- content = oauth_js.read_text(encoding="utf-8", errors="replace")
- except OSError as exc:
- logger.debug("Failed to read oauth2.js at %s: %s", oauth_js, exc)
- _scraped_creds_cache["resolved"] = "1"
- return "", ""
-
- # Precise pattern first, then fallback shape match
- cid_match = _CLIENT_ID_PATTERN.search(content) or _CLIENT_ID_SHAPE.search(content)
- cs_match = _CLIENT_SECRET_PATTERN.search(content) or _CLIENT_SECRET_SHAPE.search(content)
-
- client_id = cid_match.group(1) if cid_match else ""
- client_secret = cs_match.group(1) if cs_match else ""
-
- _scraped_creds_cache["client_id"] = client_id
- _scraped_creds_cache["client_secret"] = client_secret
- _scraped_creds_cache["resolved"] = "1"
-
- if client_id:
- logger.info("Scraped Gemini OAuth client from %s", oauth_js)
-
- return client_id, client_secret
-
-
-def _get_client_id() -> str:
- env_val = (os.getenv(ENV_CLIENT_ID) or "").strip()
- if env_val:
- return env_val
- if _DEFAULT_CLIENT_ID:
- return _DEFAULT_CLIENT_ID
- scraped, _ = _scrape_client_credentials()
- return scraped
-
-
-def _get_client_secret() -> str:
- env_val = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
- if env_val:
- return env_val
- if _DEFAULT_CLIENT_SECRET:
- return _DEFAULT_CLIENT_SECRET
- _, scraped = _scrape_client_credentials()
- return scraped
-
-
-def _require_client_id() -> str:
- cid = _get_client_id()
- if not cid:
- raise GoogleOAuthError(
- "Google OAuth client ID is not available.\n"
- "Hermes looks for a locally installed gemini-cli to source the OAuth client. "
- "Either:\n"
- " 1. Install it: npm install -g @google/gemini-cli (or brew install gemini-cli)\n"
- " 2. Set HERMES_GEMINI_CLIENT_ID and HERMES_GEMINI_CLIENT_SECRET in ~/.hermes/.env\n"
- "\n"
- "Register a Desktop OAuth client at:\n"
- " https://console.cloud.google.com/apis/credentials\n"
- "(enable the Generative Language API on the project).",
- code="google_oauth_client_id_missing",
- )
- return cid
-
-
-# =============================================================================
-# PKCE
-# =============================================================================
-
-def _generate_pkce_pair() -> Tuple[str, str]:
- """Generate a (verifier, challenge) pair using S256."""
- verifier = secrets.token_urlsafe(64)
- digest = hashlib.sha256(verifier.encode("ascii")).digest()
- challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
- return verifier, challenge
-
-
-# =============================================================================
-# Packed refresh format: refresh_token[|project_id[|managed_project_id]]
-# =============================================================================
-
-@dataclass
-class RefreshParts:
- refresh_token: str
- project_id: str = ""
- managed_project_id: str = ""
-
- @classmethod
- def parse(cls, packed: str) -> "RefreshParts":
- if not packed:
- return cls(refresh_token="")
- parts = packed.split("|", 2)
- return cls(
- refresh_token=parts[0],
- project_id=parts[1] if len(parts) > 1 else "",
- managed_project_id=parts[2] if len(parts) > 2 else "",
- )
-
- def format(self) -> str:
- if not self.refresh_token:
- return ""
- if not self.project_id and not self.managed_project_id:
- return self.refresh_token
- return f"{self.refresh_token}|{self.project_id}|{self.managed_project_id}"
-
-
-# =============================================================================
-# Credentials (dataclass wrapping the on-disk format)
-# =============================================================================
-
-@dataclass
-class GoogleCredentials:
- access_token: str
- refresh_token: str
- expires_ms: int # unix milliseconds
- email: str = ""
- project_id: str = ""
- managed_project_id: str = ""
-
- def to_dict(self) -> Dict[str, Any]:
- return {
- "refresh": RefreshParts(
- refresh_token=self.refresh_token,
- project_id=self.project_id,
- managed_project_id=self.managed_project_id,
- ).format(),
- "access": self.access_token,
- "expires": int(self.expires_ms),
- "email": self.email,
- }
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "GoogleCredentials":
- refresh_packed = str(data.get("refresh", "") or "")
- parts = RefreshParts.parse(refresh_packed)
- return cls(
- access_token=str(data.get("access", "") or ""),
- refresh_token=parts.refresh_token,
- expires_ms=int(data.get("expires", 0) or 0),
- email=str(data.get("email", "") or ""),
- project_id=parts.project_id,
- managed_project_id=parts.managed_project_id,
- )
-
- def expires_unix_seconds(self) -> float:
- return self.expires_ms / 1000.0
-
- def access_token_expired(self, skew_seconds: int = REFRESH_SKEW_SECONDS) -> bool:
- if not self.access_token or not self.expires_ms:
- return True
- return (time.time() + max(0, skew_seconds)) * 1000 >= self.expires_ms
-
-
-# =============================================================================
-# Credential I/O (atomic + locked)
-# =============================================================================
-
-def load_credentials() -> Optional[GoogleCredentials]:
- """Load credentials from disk. Returns None if missing or corrupt."""
- path = _credentials_path()
- if not path.exists():
- return None
- try:
- with _credentials_lock():
- raw = path.read_text(encoding="utf-8")
- data = json.loads(raw)
- except (json.JSONDecodeError, OSError, IOError) as exc:
- logger.warning("Failed to read Google OAuth credentials at %s: %s", path, exc)
- return None
- if not isinstance(data, dict):
- return None
- creds = GoogleCredentials.from_dict(data)
- if not creds.access_token:
- return None
- return creds
-
-
-def save_credentials(creds: GoogleCredentials) -> Path:
- """Atomically write creds to disk with 0o600 permissions."""
- path = _credentials_path()
- path.parent.mkdir(parents=True, exist_ok=True)
- # Tighten parent dir to 0o700 so siblings can't traverse to the creds file.
- # On Windows this is a no-op (POSIX mode bits aren't enforced); ignore failures.
- # secure_parent_dir refuses to chmod / or top-level dirs (#25821).
- secure_parent_dir(path)
- payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + "\n"
-
- with _credentials_lock():
- tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
- try:
- # Create with 0o600 atomically to close the TOCTOU window where the
- # default umask (often 0o644) would briefly expose tokens to other
- # local users between open() and chmod().
- fd = os.open(
- str(tmp_path),
- os.O_WRONLY | os.O_CREAT | os.O_EXCL,
- stat.S_IRUSR | stat.S_IWUSR,
- )
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
- fh.write(payload)
- fh.flush()
- os.fsync(fh.fileno())
- atomic_replace(tmp_path, path)
- finally:
- try:
- if tmp_path.exists():
- tmp_path.unlink()
- except OSError:
- pass
- return path
-
-
-def clear_credentials() -> None:
- """Remove the creds file. Idempotent."""
- path = _credentials_path()
- with _credentials_lock():
- try:
- path.unlink()
- except FileNotFoundError:
- pass
- except OSError as exc:
- logger.warning("Failed to remove Google OAuth credentials at %s: %s", path, exc)
-
-
-# =============================================================================
-# HTTP helpers
-# =============================================================================
-
-def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]:
- """POST x-www-form-urlencoded and return parsed JSON response."""
- body = urllib.parse.urlencode(data).encode("ascii")
- request = urllib.request.Request(
- url,
- data=body,
- method="POST",
- headers={
- "Content-Type": "application/x-www-form-urlencoded",
- "Accept": "application/json",
- },
- )
- try:
- with urllib.request.urlopen(request, timeout=timeout) as response:
- raw = response.read().decode("utf-8", errors="replace")
- return json.loads(raw)
- except urllib.error.HTTPError as exc:
- detail = ""
- try:
- detail = exc.read().decode("utf-8", errors="replace")
- except Exception:
- pass
- # Detect invalid_grant to signal credential revocation
- code = "google_oauth_token_http_error"
- if "invalid_grant" in detail.lower():
- code = "google_oauth_invalid_grant"
- raise GoogleOAuthError(
- f"Google OAuth token endpoint returned HTTP {exc.code}: {detail or exc.reason}",
- code=code,
- ) from exc
- except urllib.error.URLError as exc:
- raise GoogleOAuthError(
- f"Google OAuth token request failed: {exc}",
- code="google_oauth_token_network_error",
- ) from exc
-
-
-def exchange_code(
- code: str,
- verifier: str,
- redirect_uri: str,
- *,
- client_id: Optional[str] = None,
- client_secret: Optional[str] = None,
- timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
-) -> Dict[str, Any]:
- """Exchange authorization code for access + refresh tokens."""
- cid = client_id if client_id is not None else _get_client_id()
- csecret = client_secret if client_secret is not None else _get_client_secret()
- data = {
- "grant_type": "authorization_code",
- "code": code,
- "code_verifier": verifier,
- "client_id": cid,
- "redirect_uri": redirect_uri,
- }
- if csecret:
- data["client_secret"] = csecret
- return _post_form(TOKEN_ENDPOINT, data, timeout)
-
-
-def refresh_access_token(
- refresh_token: str,
- *,
- client_id: Optional[str] = None,
- client_secret: Optional[str] = None,
- timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
-) -> Dict[str, Any]:
- """Refresh the access token."""
- if not refresh_token:
- raise GoogleOAuthError(
- "Cannot refresh: refresh_token is empty. Re-run OAuth login.",
- code="google_oauth_refresh_token_missing",
- )
- cid = client_id if client_id is not None else _get_client_id()
- csecret = client_secret if client_secret is not None else _get_client_secret()
- data = {
- "grant_type": "refresh_token",
- "refresh_token": refresh_token,
- "client_id": cid,
- }
- if csecret:
- data["client_secret"] = csecret
- return _post_form(TOKEN_ENDPOINT, data, timeout)
-
-
-def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str:
- """Best-effort userinfo fetch for display. Failures return empty string."""
- try:
- request = urllib.request.Request(
- USERINFO_ENDPOINT + "?alt=json",
- headers={"Authorization": f"Bearer {access_token}"},
- )
- with urllib.request.urlopen(request, timeout=timeout) as response:
- raw = response.read().decode("utf-8", errors="replace")
- data = json.loads(raw)
- return str(data.get("email", "") or "")
- except Exception as exc:
- logger.debug("Userinfo fetch failed (non-fatal): %s", exc)
- return ""
-
-
-# =============================================================================
-# In-flight refresh deduplication
-# =============================================================================
-
-_refresh_inflight: Dict[str, threading.Event] = {}
-_refresh_inflight_lock = threading.Lock()
-
-
-def get_valid_access_token(*, force_refresh: bool = False) -> str:
- """Load creds, refreshing if near expiry, and return a valid bearer token.
-
- Dedupes concurrent refreshes by refresh_token. On ``invalid_grant``, the
- credential file is wiped and a ``google_oauth_invalid_grant`` error is raised
- (caller is expected to trigger a re-login flow).
- """
- creds = load_credentials()
- if creds is None:
- raise GoogleOAuthError(
- "No Google OAuth credentials found. Run `hermes auth add google-gemini-cli` first.",
- code="google_oauth_not_logged_in",
- )
-
- if not force_refresh and not creds.access_token_expired():
- return creds.access_token
-
- # Dedupe concurrent refreshes by refresh_token
- rt = creds.refresh_token
- with _refresh_inflight_lock:
- event = _refresh_inflight.get(rt)
- if event is None:
- event = threading.Event()
- _refresh_inflight[rt] = event
- owner = True
- else:
- owner = False
-
- if not owner:
- # Another thread is refreshing — wait, then re-read from disk.
- event.wait(timeout=LOCK_TIMEOUT_SECONDS)
- fresh = load_credentials()
- if fresh is not None and not fresh.access_token_expired():
- return fresh.access_token
- # Fall through to do our own refresh if the other attempt failed
-
- try:
- try:
- resp = refresh_access_token(rt)
- except GoogleOAuthError as exc:
- if exc.code == "google_oauth_invalid_grant":
- logger.warning(
- "Google OAuth refresh token invalid (revoked/expired). "
- "Clearing credentials at %s — user must re-login.",
- _credentials_path(),
- )
- clear_credentials()
- raise
-
- new_access = str(resp.get("access_token", "") or "").strip()
- if not new_access:
- raise GoogleOAuthError(
- "Refresh response did not include an access_token.",
- code="google_oauth_refresh_empty",
- )
- # Google sometimes rotates refresh_token; preserve existing if omitted.
- new_refresh = str(resp.get("refresh_token", "") or "").strip() or creds.refresh_token
- expires_in = int(resp.get("expires_in", 0) or 0)
-
- creds.access_token = new_access
- creds.refresh_token = new_refresh
- creds.expires_ms = int((time.time() + max(60, expires_in)) * 1000)
- save_credentials(creds)
- return creds.access_token
- finally:
- if owner:
- with _refresh_inflight_lock:
- _refresh_inflight.pop(rt, None)
- event.set()
-
-
-# =============================================================================
-# Update project IDs on stored creds
-# =============================================================================
-
-def update_project_ids(project_id: str = "", managed_project_id: str = "") -> None:
- """Persist resolved/discovered project IDs back into the credential file."""
- creds = load_credentials()
- if creds is None:
- return
- if project_id:
- creds.project_id = project_id
- if managed_project_id:
- creds.managed_project_id = managed_project_id
- save_credentials(creds)
-
-
-# =============================================================================
-# Callback server
-# =============================================================================
-
-class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
- expected_state: str = ""
- captured_code: Optional[str] = None
- captured_error: Optional[str] = None
- ready: Optional[threading.Event] = None
-
- def log_message(self, format: str, *args: Any) -> None: # noqa: A002, N802
- logger.debug("OAuth callback: " + format, *args)
-
- def do_GET(self) -> None: # noqa: N802
- parsed = urllib.parse.urlparse(self.path)
- if parsed.path != CALLBACK_PATH:
- self.send_response(404)
- self.end_headers()
- return
-
- params = urllib.parse.parse_qs(parsed.query)
- state = (params.get("state") or [""])[0]
- error = (params.get("error") or [""])[0]
- code = (params.get("code") or [""])[0]
-
- if state != type(self).expected_state:
- type(self).captured_error = "state_mismatch"
- self._respond_html(400, _ERROR_PAGE.format(message="State mismatch — aborting for safety."))
- elif error:
- type(self).captured_error = error
- # Simple HTML-escape of the error value
- safe_err = (
- str(error)
- .replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- )
- self._respond_html(400, _ERROR_PAGE.format(message=f"Authorization denied: {safe_err}"))
- elif code:
- type(self).captured_code = code
- self._respond_html(200, _SUCCESS_PAGE)
- else:
- type(self).captured_error = "no_code"
- self._respond_html(400, _ERROR_PAGE.format(message="Callback received no authorization code."))
-
- if type(self).ready is not None:
- type(self).ready.set()
-
- def _respond_html(self, status: int, body: str) -> None:
- payload = body.encode("utf-8")
- self.send_response(status)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(payload)))
- self.end_headers()
- self.wfile.write(payload)
-
-
-_SUCCESS_PAGE = """
-Hermes — signed in
-
-
Signed in to Google.
-
You can close this tab and return to your terminal.