diff --git a/.dockerignore b/.dockerignore
index f6fbbc9f137c..ec3d52f81413 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -66,8 +66,12 @@ runtime/
# ---------- Not needed inside the Docker image ----------
-# Desktop app source (Tauri/Electron); never installed in the container
+# Desktop app source (Tauri/Electron); never installed in the container.
+# apps/shared is the dashboard↔desktop websocket helper and is linked from
+# web/package.json as a file: workspace dep — keep it in the build context.
apps/
+!apps/shared/
+!apps/shared/**
# Test suite — not shipped in production images
tests/
@@ -102,6 +106,3 @@ acp_registry/
.gitattributes
.hadolint.yaml
.mailmap
-
-# Top-level LICENSE (not matched by *.md); not needed inside the container
-LICENSE
diff --git a/.env.example b/.env.example
index 924146613c45..4c83db1f3b48 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 f746973cae60..663714366d26 100644
--- a/.envrc
+++ b/.envrc
@@ -1,5 +1,5 @@
-watch_file pyproject.toml uv.lock
+watch_file pyproject.toml uv.lock hermes
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
-watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
+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 000000000000..268b0aa103c8
--- /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 8b79c4bf34d3..000000000000
--- 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 000000000000..0eba2866ebec
--- /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/pr-screenshots/45449/billing-confirm.png b/.github/pr-screenshots/45449/billing-confirm.png
new file mode 100644
index 000000000000..643f62a651db
Binary files /dev/null and b/.github/pr-screenshots/45449/billing-confirm.png differ
diff --git a/.github/pr-screenshots/45449/billing-overview.png b/.github/pr-screenshots/45449/billing-overview.png
new file mode 100644
index 000000000000..6e2e319a9bd1
Binary files /dev/null and b/.github/pr-screenshots/45449/billing-overview.png differ
diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml
deleted file mode 100644
index 3fc4f2b07464..000000000000
--- 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 000000000000..ab6012016536
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,238 @@
+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)')
+ "
+
+ # ─────────────────────────────────────────────────────────────────────
+ # CI timing report: collect per-job/step durations from the GitHub API,
+ # cache them on main (as a baseline), and on PRs generate an HTML diff
+ # report with a gantt chart + per-step breakdown. The report is uploaded
+ # as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
+ # ─────────────────────────────────────────────────────────────────────
+ ci-timings:
+ name: CI timing report
+ needs: [all-checks-pass, docker]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Restore baseline cache (PR only)
+ if: github.event_name == 'pull_request'
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ci-timings-baseline.json
+ # Prefix-match: exact key will never hit (run_id differs), so
+ # restore-keys finds the most recent baseline from main.
+ key: ci-timings-baseline-never-exact
+ restore-keys: |
+ ci-timings-baseline-
+
+ - name: Collect timings and generate report
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python3 scripts/ci/timings_report.py \
+ --baseline ci-timings-baseline.json \
+ --output ci-timings-report.html \
+ --json-out ci-timings.json \
+ --summary-out ci-timings-summary.md
+
+ - name: Upload HTML report
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ id: ci-timings-artifact
+ with:
+ name: ci-timings-report
+ path: ci-timings-report.html
+ retention-days: 14
+ archive: false
+
+ - name: Output summary
+ env:
+ REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
+ run: |
+ echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
+ echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
+ cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Save baseline cache (main only)
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ run: |
+ # Degraded runs (API rate-limited) produce no ci-timings.json —
+ # skip rather than fail, and never cache an empty baseline.
+ if [ -f ci-timings.json ]; then
+ cp ci-timings.json ci-timings-baseline.json
+ else
+ echo "No timings JSON this run — skipping baseline update"
+ fi
+
+ - name: Upload baseline to cache (main only)
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ci-timings-baseline.json
+ key: ci-timings-baseline-${{ github.run_id }}
diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml
index 23266931a699..b7c3db7f8270 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 631add200ad8..89b80fa10e09 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-publish.yml
deleted file mode 100644
index 09b89138412d..000000000000
--- a/.github/workflows/docker-publish.yml
+++ /dev/null
@@ -1,357 +0,0 @@
-name: Docker Build 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]
-
-permissions:
- contents: read
- # Needed so the arm64 job can push/pull its registry-backed build cache
- # to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
- # job for why registry cache replaced the gha cache on that arch.
- packages: write
-
-# Concurrency: push/release runs are NEVER cancelled so every merge gets
-# its own image. PR runs reuse a PR-scoped group with
-# cancel-in-progress: true so rapid pushes to the same PR collapse to the
-# latest commit.
-concurrency:
- group: docker-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-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-amd64:
- # Only run on the upstream repository, not on forks
- if: github.repository == 'NousResearch/hermes-agent'
- runs-on: ubuntu-latest
- timeout-minutes: 45
- outputs:
- digest: ${{ steps.push.outputs.digest }}
- steps:
- - name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
-
- # Build once, load into the local daemon for smoke 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
- with:
- context: .
- file: Dockerfile
- load: true
- platforms: linux/amd64
- tags: ${{ env.IMAGE_NAME }}:test
- build-args: |
- HERMES_GIT_SHA=${{ github.sha }}
- 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.
- #
- # 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
- # that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
- # tests/docker/conftest.py:62-63) short-circuits the rebuild.
- #
- # Why this job and not a standalone one: the image is 5GB+; passing
- # it between jobs via ``docker save``/``upload-artifact`` is slower
- # than the build itself. Reusing the existing daemon state is the
- # cheapest path to coverage on every PR that touches docker code.
- # ---------------------------------------------------------------------
- - name: Install uv (for docker tests)
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
-
- - 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]"
-
- - name: Run docker integration tests
- env:
- # Skip rebuild; use the image already loaded by the build step.
- HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
- # Match the policy in tests.yml :: test job — no accidental
- # real-API calls from inside the harness.
- OPENROUTER_API_KEY: ""
- OPENAI_API_KEY: ""
- NOUS_API_KEY: ""
- run: |
- source .venv/bin/activate
- python -m pytest tests/docker/ -v --tb=short
-
- - 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
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- # Push amd64 by digest only (no tag). The merge job assembles the
- # tagged manifest list. `push-by-digest=true` is docker's recommended
- # pattern for multi-runner multi-platform builds.
- - 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
- with:
- context: .
- file: Dockerfile
- platforms: linux/amd64
- labels: |
- org.opencontainers.image.revision=${{ github.sha }}
- build-args: |
- HERMES_GIT_SHA=${{ github.sha }}
- outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
- cache-from: type=gha,scope=docker-amd64
- cache-to: type=gha,mode=max,scope=docker-amd64
-
- # Write the digest to a file and upload it as an artifact so the
- # merge job can stitch both per-arch digests into a manifest list.
- - name: Export digest
- if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- run: |
- mkdir -p /tmp/digests
- digest="${{ steps.push.outputs.digest }}"
- touch "/tmp/digests/${digest#sha256:}"
-
- - name: Upload digest artifact
- if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
- with:
- name: digest-amd64
- path: /tmp/digests/*
- if-no-files-found: error
- 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-arm64:
- if: github.repository == 'NousResearch/hermes-agent'
- runs-on: ubuntu-24.04-arm
- timeout-minutes: 45
- outputs:
- digest: ${{ steps.push.outputs.digest }}
- steps:
- - name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Set up Docker Buildx
- 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
- # 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
- 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).
- #
- # 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
- 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
- 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: 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
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - 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
- with:
- context: .
- file: Dockerfile
- platforms: linux/arm64
- labels: |
- org.opencontainers.image.revision=${{ github.sha }}
- build-args: |
- HERMES_GIT_SHA=${{ github.sha }}
- outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
- 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: Export digest
- if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- run: |
- mkdir -p /tmp/digests
- digest="${{ steps.push.outputs.digest }}"
- touch "/tmp/digests/${digest#sha256:}"
-
- - name: Upload digest artifact
- if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
- with:
- name: digest-arm64
- path: /tmp/digests/*
- if-no-files-found: error
- retention-days: 1
-
- # ---------------------------------------------------------------------------
- # Stitch both per-arch digests into a single tagged multi-arch manifest.
- # This is a registry-side operation — no building, no layer re-push —
- # so it runs in ~30 seconds.
- #
- # On main pushes: tags both :main and :latest.
- # On releases: tags :.
- # ---------------------------------------------------------------------------
- merge:
- if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
- runs-on: ubuntu-latest
- needs: [build-amd64, build-arm64]
- timeout-minutes: 10
- steps:
- - name: Download digests
- 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
-
- - name: Log in to Docker Hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Create manifest list and push
- working-directory: /tmp/digests
- run: |
- set -euo pipefail
- args=()
- for digest_file in *; do
- args+=("${IMAGE_NAME}@sha256:${digest_file}")
- done
- if [ "${{ github.event_name }}" = "release" ]; then
- TAG="${{ github.event.release.tag_name }}"
- docker buildx imagetools create \
- -t "${IMAGE_NAME}:${TAG}" \
- "${args[@]}"
- else
- docker buildx imagetools create \
- -t "${IMAGE_NAME}:main" \
- -t "${IMAGE_NAME}:latest" \
- "${args[@]}"
- fi
- env:
- IMAGE_NAME: ${{ env.IMAGE_NAME }}
-
- - name: Inspect image
- run: |
- if [ "${{ github.event_name }}" = "release" ]; then
- docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
- else
- docker buildx imagetools inspect "${IMAGE_NAME}:main"
- fi
- env:
- IMAGE_NAME: ${{ env.IMAGE_NAME }}
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 000000000000..e19894c96fdc
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,210 @@
+name: Docker Build, Test, and Publish
+
+on:
+ release:
+ types: [published]
+ workflow_call:
+
+permissions:
+ contents: read
+
+# Concurrency: push/release runs are NEVER cancelled so every merge gets
+# its own image. PR runs reuse a PR-scoped group with
+# cancel-in-progress: true so rapid pushes to the same PR collapse to
+# the latest commit.
+concurrency:
+ group: docker-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+env:
+ IMAGE_NAME: nousresearch/hermes-agent
+
+jobs:
+ # Build, test, and optionally push the image for each architecture.
+ build:
+ if: github.repository == 'NousResearch/hermes-agent'
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ runner: ubuntu-latest
+ platform: linux/amd64
+ cache-from: type=gha,scope=docker-amd64
+ cache-to: type=gha,mode=max,scope=docker-amd64
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ platform: linux/arm64
+ cache-from: type=gha,scope=docker-arm64
+ cache-to: type=gha,mode=max,scope=docker-arm64
+
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 45
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
+
+ # Build once, load into the local daemon for testing. Cached
+ # per-arch; the push step below reuses every layer from this build.
+ - name: Build image (${{ matrix.arch }})
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ with:
+ context: .
+ file: Dockerfile
+ load: true
+ platforms: ${{ matrix.platform }}
+ tags: ${{ env.IMAGE_NAME }}:test
+ build-args: |
+ HERMES_GIT_SHA=${{ github.sha }}
+ cache-from: ${{ matrix.cache-from }}
+ cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
+
+ - 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
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Push by digest only (no tag). The merge job assembles the
+ # tagged manifest list. `push-by-digest=true` is docker's recommended
+ # pattern for multi-runner multi-platform builds.
+ - name: Push ${{ matrix.arch }} 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
+ with:
+ context: .
+ file: Dockerfile
+ platforms: ${{ matrix.platform }}
+ labels: |
+ org.opencontainers.image.revision=${{ github.sha }}
+ build-args: |
+ HERMES_GIT_SHA=${{ github.sha }}
+ outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
+ cache-from: ${{ matrix.cache-from }}
+ cache-to: ${{ matrix.cache-to }}
+
+ # Write the digest to a file and upload it as an artifact so the
+ # merge job can stitch both per-arch digests into a manifest list.
+ - name: Export digest
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
+ run: |
+ mkdir -p /tmp/digests
+ digest="${{ steps.push.outputs.digest }}"
+ touch "/tmp/digests/${digest#sha256:}"
+
+ - name: Upload digest artifact
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: digest-${{ matrix.arch }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ # Run the docker-integration test suite against the freshly-built
+ # image already loaded into the local daemon (`:test`).
+ #
+ # 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.
+ #
+ # Why this job and not a standalone one: the image is 5GB+; passing
+ # it between jobs via ``docker save``/``upload-artifact`` is slower
+ # than the build itself. Reusing the existing daemon state is the
+ # cheapest path to coverage on every PR that touches docker code.
+ # ---------------------------------------------------------------------
+ - 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: |
+ # ``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 sync --locked --python 3.11 --extra dev
+
+ - name: Run docker integration tests
+ env:
+ # Skip rebuild; use the image already loaded by the build step.
+ HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
+ # Match the policy in tests.yml :: test job — no accidental
+ # real-API calls from inside the harness.
+ OPENROUTER_API_KEY: ""
+ OPENAI_API_KEY: ""
+ NOUS_API_KEY: ""
+ run: |
+ scripts/run_tests.sh tests/docker/ --file-timeout 600
+
+ # ---------------------------------------------------------------------------
+ # Stitch both per-arch digests into a single tagged multi-arch manifest.
+ # This is a registry-side operation — no building, no layer re-push —
+ # so it runs in ~30 seconds.
+ #
+ # On main pushes: tags both :main and :latest.
+ # On releases: tags :.
+ # ---------------------------------------------------------------------------
+ merge:
+ if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
+ runs-on: ubuntu-latest
+ needs: [build]
+ timeout-minutes: 10
+ steps:
+ - name: Download digests
+ 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
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Create manifest list and push
+ working-directory: /tmp/digests
+ env:
+ IMAGE_NAME: ${{ env.IMAGE_NAME }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ set -euo pipefail
+ args=()
+ for digest_file in *; do
+ args+=("${IMAGE_NAME}@sha256:${digest_file}")
+ done
+ if [ "${{ github.event_name }}" = "release" ]; then
+ docker buildx imagetools create \
+ -t "${IMAGE_NAME}:${RELEASE_TAG}" \
+ "${args[@]}"
+ else
+ docker buildx imagetools create \
+ -t "${IMAGE_NAME}:main" \
+ -t "${IMAGE_NAME}:latest" \
+ "${args[@]}"
+ fi
+
+ - name: Inspect image
+ env:
+ IMAGE_NAME: ${{ env.IMAGE_NAME }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ if [ "${{ github.event_name }}" = "release" ]; then
+ docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
+ else
+ docker buildx imagetools inspect "${IMAGE_NAME}:main"
+ fi
diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml
index 975028afe238..705f2171e5ce 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 ef657d5982c3..07e4fa348e43 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 f2765823a0bf..beb3a07abaee 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
@@ -103,6 +98,8 @@ jobs:
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
- name: Generate diff summary
+ env:
+ HEAD_REF: ${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}
run: |
python scripts/lint_diff.py \
--base-ruff .lint-reports/base/ruff.json \
@@ -110,50 +107,10 @@ 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 "$HEAD_REF" \
--output .lint-reports/summary.md
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
- - name: Upload reports as artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
- 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
- continue-on-error: true
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
- with:
- script: |
- const fs = require('fs');
- const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
- const marker = '';
- const fullBody = marker + '\n' + body;
-
- const { data: comments } = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- });
- const existing = comments.find(c => c.body && c.body.includes(marker));
- if (existing) {
- await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: existing.id,
- body: fullBody,
- });
- } else {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- body: fullBody,
- });
- }
-
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
@@ -169,10 +126,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 d1b318cc737f..48b485c55fdf 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 c6caf098133a..1997dedf5c75 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 f3405b7660f0..201e92d174cc 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 29994e3e295d..dd2906629b01 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 9d1806d6f72a..03fad4eba0ce 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 54662b23edaf..8a7f52e899a4 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/.gitignore b/.gitignore
index 6d87318e35e0..e4240ea36e75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,8 +5,10 @@
*.pyc*
__pycache__/
.venv/
+.venv
.vscode/
.env
+.op.env
.env.local
.env.development.local
.env.test.local
@@ -136,3 +138,9 @@ RELEASE_v*.md
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
# walkthroughs). Throwaway artifacts, never part of the app.
apps/desktop/demo/
+
+# PR infographics are rendered locally and embedded in PR descriptions via the
+# image-provider (fal.media) URL — they are NEVER committed to the repo. The
+# PR body is the archive. See the hermes-agent-dev skill's
+# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
+infographic/
diff --git a/AGENTS.md b/AGENTS.md
index e032f7654474..e1dbaaa5c43c 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)
@@ -480,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
### Electron Desktop Chat App (`apps/desktop/`)
-A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
+A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
@@ -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"`.
@@ -1259,65 +1289,22 @@ scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
-scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
```
-### Subprocess-per-test isolation
-
-Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
-at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
-ContextVars from one test cannot leak into the next — the historic
-`_reset_module_state` autouse fixture is gone.
-
-Implementation notes:
-
-- The plugin uses `multiprocessing.get_context("spawn")`, which works on
- Linux, macOS, and Windows alike (POSIX `fork` is not used).
-- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist
- parallelism amortizes this across cores; on a 20-core box the full suite
- finishes in roughly the same wall time as before, but flake-free.
-- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
- Hangs are killed and surfaced as a failure report.
-- Pass `--no-isolate` to disable isolation — useful when debugging a single
- test interactively, or when you specifically want to verify state leakage.
-- The plugin disables itself in child processes (sentinel envvar
- `HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
-
-### Why the wrapper (and why the old "just call pytest" doesn't work)
-
-Five real sources of local-vs-CI drift the script closes:
-
-| | Without wrapper | With wrapper |
-|---|---|---|
-| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
-| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
-| Timezone | Local TZ (PDT etc.) | UTC |
-| Locale | Whatever is set | C.UTF-8 |
-| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
-
-`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
-invocation (including IDE integrations) gets hermetic behavior — but the wrapper
-is belt-and-suspenders.
-
-### Running without the wrapper (only if you must)
+### Subprocess-per-test-file isolation
-If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
-at minimum activate the venv. The isolation plugin loads automatically from
-`addopts` in `pyproject.toml`, so you get the same per-test process isolation
-either way.
-
-```bash
-source .venv/bin/activate # or: source venv/bin/activate
-python -m pytest tests/ -q
-```
+Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
+ContextVars from one test file cannot leak into the next.
-If you need to bypass isolation for fast feedback while debugging:
+### Why the wrapper
-```bash
-python -m pytest tests/agent/test_foo.py -q --no-isolate
-```
+| | Without wrapper | With wrapper |
+| ------------------- | ------------------------------------------- | ----------------------------------------- |
+| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
+| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
+| Timezone | Local TZ (PDT etc.) | UTC |
+| Locale | Whatever is set | C.UTF-8 |
-Always run the full suite before pushing changes.
### Don't write change-detector tests
diff --git a/CONTRIBUTING.es.md b/CONTRIBUTING.es.md
new file mode 100644
index 000000000000..78c80113c6e4
--- /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–3.13** | uv lo instalará si falta |
+| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
+| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
+
+### 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 1a70116548aa..46581d820037 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
@@ -74,7 +109,7 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
| Requirement | Notes |
|-------------|-------|
| **Git** | With the `git-lfs` extension installed |
-| **Python 3.11+** | uv will install it if missing |
+| **Python 3.11–3.13** | uv will install it if missing |
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
@@ -114,13 +149,20 @@ this way, make sure you run the `hermes` entrypoint from this venv; running the
system `python3 -m hermes_cli.main` can pick up unrelated system Python
packages.
+Create the venv **outside** the cloned source tree. A venv that lives inside
+the directory the agent operates from can be wiped by a relative-path command
+the agent runs against its own checkout (`rm -rf venv`, `uv venv venv`, etc.),
+which silently destroys the running runtime mid-session. Keeping it outside the
+tree means no relative path from the workspace resolves to it.
+
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
-# Create venv with Python 3.11
-uv venv venv --python 3.11
-export VIRTUAL_ENV="$(pwd)/venv"
+# Create venv with Python 3.11, OUTSIDE the source tree
+uv venv ~/.hermes/venvs/hermes-dev --python 3.11
+export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev"
+export PATH="$VIRTUAL_ENV/bin:$PATH"
# Install with all extras (messaging, cron, CLI menus, dev tools)
uv pip install -e ".[all,dev]"
@@ -412,6 +454,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 be358ac53439..6f957f779678 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,8 +9,11 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
FROM debian:13.4
-# Disable Python stdout buffering to ensure logs are printed immediately
+# Disable Python stdout buffering to ensure logs are printed immediately.
+# Do not write .pyc files at runtime: /opt/hermes is immutable in the
+# published container and writable state belongs under /opt/data.
ENV PYTHONUNBUFFERED=1
+ENV PYTHONDONTWRITEBYTECODE=1
# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
@@ -116,6 +119,9 @@ COPY package.json package-lock.json ./
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
+# apps/shared/ is copied IN FULL because web/package.json references it as a
+# `file:` workspace dependency (same pattern as hermes-ink above).
+COPY apps/shared/ apps/shared/
# `npm_config_install_links=false` forces npm to install `file:` deps as
# symlinks instead of copies. This is the default since npm 10+, which is
@@ -181,41 +187,46 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
+COPY apps/shared/ apps/shared/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
-COPY --chown=hermes:hermes . .
+# --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 ----------
-# Make install dir world-readable so any HERMES_UID can read it at runtime.
-# The venv needs to be traversable too.
-# node_modules trees additionally need to be writable by the hermes user
-# so the runtime `npm install` triggered by _tui_need_npm_install() in
-# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
-# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
-# not chowned here.
-# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
-# gateway state artifacts beneath the package after services drop privileges,
-# especially when the hermes UID is remapped at boot (#27221).
-# The .venv MUST remain hermes-writable so lazy_deps.py can install
-# remaining optional platform packages and future pin bumps at first use.
-# Without this, `uv pip install` fails with EACCES and adapters silently
-# fail to load. See tools/lazy_deps.py.
+# Link hermes-agent itself (editable). Deps are already installed in the
+# cached layer above; `--no-deps` makes this a fast egg-link creation with no
+# resolution or downloads.
+RUN uv pip install --no-cache-dir --no-deps -e "."
+
+# 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 chmod -R a+rX /opt/hermes && \
- chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
+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
+# 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
+# host-side Desktop/CLI install; stamping it at boot used to clobber that
+# host install's marker and wrongly block its ``hermes update``. A code-scoped
+# stamp is read first by detect_install_method() and is immune to the share.
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000).
-# ---------- Link hermes-agent itself (editable) ----------
-# Deps are already installed in the cached layer above; `--no-deps` makes
-# this a fast (~1s) egg-link creation with no resolution or downloads.
-RUN uv pip install --no-cache-dir --no-deps -e "."
-
# ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports
@@ -231,12 +242,11 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
#
# 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 \
- printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
- chown hermes:hermes /opt/hermes/.hermes_build_sha; \
+ printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
@@ -282,6 +292,21 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# check. (A separate launcher hardening is tracked independently.)
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
@@ -294,7 +319,6 @@ ENV HERMES_HOME=/opt/data
# Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
-COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
diff --git a/MANIFEST.in b/MANIFEST.in
index 5d5a1b1b271b..159c215ff6b0 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -7,5 +7,7 @@ graft locales
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
+# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
+recursive-include gateway/assets *
global-exclude __pycache__
global-exclude *.py[cod]
diff --git a/README.es.md b/README.es.md
new file mode 100644
index 000000000000..af8558513c5d
--- /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 5fb4e80082b2..ba1322a38920 100644
--- a/README.md
+++ b/README.md
@@ -13,11 +13,12 @@
+
**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.
@@ -64,6 +65,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
@@ -196,10 +232,14 @@ scripts/run_tests.sh
Manual clone fallback (for throwaway clones/CI where you intentionally do not
want the managed install layout):
+Create the venv outside the cloned source tree — a venv inside the directory
+the agent operates from can be wiped by a relative-path command the agent runs
+against its own checkout, destroying the running runtime mid-session.
+
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
-uv venv .venv --python 3.11
-source .venv/bin/activate
+uv venv ~/.hermes/venvs/hermes-dev --python 3.11
+source ~/.hermes/venvs/hermes-dev/bin/activate
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2453739f917f..5ebfe1a7c50d 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 000000000000..30b43716ebbb
--- /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/SECURITY.md b/SECURITY.md
index c58e348b5791..2579c6eaec56 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -121,10 +121,11 @@ outside the supported security posture.
### 2.3 Credential Scoping
Hermes Agent filters the environment it passes to its lower-trust
-in-process components: shell subprocesses, MCP subprocesses, and
-the code-execution child. Credentials like provider API keys and
-gateway tokens are stripped by default; variables explicitly
-declared by the operator or by a loaded skill are passed through.
+in-process components: shell subprocesses, MCP subprocesses,
+cron job scripts, and the code-execution child. Credentials like
+provider API keys and gateway tokens are stripped by default;
+variables explicitly declared by the operator or by a loaded
+skill are passed through.
This reduces casual exfiltration. It is not containment. Any
component running inside the agent process (skills, plugins, hook
diff --git a/acp_adapter/edit_approval.py b/acp_adapter/edit_approval.py
index cbe7b699a50f..b73325ec0935 100644
--- a/acp_adapter/edit_approval.py
+++ b/acp_adapter/edit_approval.py
@@ -10,6 +10,7 @@
import asyncio
import json
import logging
+import re
import tempfile
from concurrent.futures import TimeoutError as FutureTimeout
from contextvars import ContextVar, Token
@@ -127,13 +128,64 @@ def _proposal_for_patch_replace(arguments: dict[str, Any]) -> EditProposal:
)
+def _extract_v4a_patch_paths(patch_body: str) -> list[str]:
+ paths: list[str] = []
+ for match in re.finditer(
+ r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$',
+ patch_body,
+ re.MULTILINE,
+ ):
+ path = match.group(1).strip()
+ if path:
+ paths.append(path)
+ for match in re.finditer(
+ r'^\*\*\*\s+Move\s+File:\s*(.+?)\s*->\s*(.+)$',
+ patch_body,
+ re.MULTILINE,
+ ):
+ src = match.group(1).strip()
+ dst = match.group(2).strip()
+ if src:
+ paths.append(src)
+ if dst:
+ paths.append(dst)
+ return paths
+
+
+def _proposal_for_patch_v4a(arguments: dict[str, Any]) -> EditProposal:
+ patch_body = arguments.get("patch")
+ if not isinstance(patch_body, str) or not patch_body:
+ raise ValueError("patch content required")
+
+ paths = _extract_v4a_patch_paths(patch_body)
+ if not paths:
+ raise ValueError("no file paths found in V4A patch")
+
+ proposal_path = paths[0] if len(paths) == 1 else ", ".join(paths)
+ old_text = _read_text_if_exists(paths[0]) if len(paths) == 1 else None
+ return EditProposal(
+ tool_name="patch",
+ path=proposal_path,
+ old_text=old_text,
+ # ACP only supports a single diff payload here. Surface the exact V4A
+ # patch content before execution so patch-mode calls are permissioned
+ # and denied patches cannot mutate.
+ new_text=patch_body,
+ arguments=dict(arguments),
+ )
+
+
def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None:
"""Return an edit proposal for supported file mutation calls."""
if tool_name == "write_file":
return _proposal_for_write_file(arguments)
- if tool_name == "patch" and arguments.get("mode", "replace") == "replace":
- return _proposal_for_patch_replace(arguments)
+ if tool_name == "patch":
+ mode = arguments.get("mode", "replace")
+ if mode == "replace":
+ return _proposal_for_patch_replace(arguments)
+ if mode == "patch":
+ return _proposal_for_patch_v4a(arguments)
return None
diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py
index 9ce6281824c9..5048b7025982 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/server.py b/acp_adapter/server.py
index a51db91d4e82..df773297346a 100644
--- a/acp_adapter/server.py
+++ b/acp_adapter/server.py
@@ -74,6 +74,10 @@
from acp_adapter.provenance import session_provenance_meta
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
from acp_adapter.tools import build_tool_complete, build_tool_start
+from tools.approval import (
+ reset_hermes_interactive_context,
+ set_hermes_interactive_context,
+)
logger = logging.getLogger(__name__)
@@ -1446,20 +1450,23 @@ def stream_delta_cb(text: str) -> None:
# Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr).
# Set it INSIDE _run_agent so the TLS write happens in the executor
# thread — setting it here would write to the event-loop thread's TLS,
- # not the executor's. Also set HERMES_INTERACTIVE so approval.py
- # takes the CLI-interactive path (which calls the registered
- # callback via prompt_dangerous_approval) instead of the
- # non-interactive auto-approve branch (GHSA-96vc-wcxf-jjff).
+ # not the executor's. Interactive routing uses a contextvar in
+ # tools.approval (set_hermes_interactive_context) rather than
+ # os.environ["HERMES_INTERACTIVE"], so concurrent executor workers can't
+ # race on a process-global flag — one session's restore can't drop
+ # another onto the non-interactive auto-approve path mid-run
+ # (GHSA-96vc-wcxf-jjff). The contextvar write is isolated by the
+ # contextvars.copy_context() wrapper around the executor call below.
# ACP's conn.request_permission maps cleanly to the interactive
# callback shape — not the gateway-queue HERMES_EXEC_ASK path,
# which requires a notify_cb registered in _gateway_notify_cbs.
previous_approval_cb = None
- previous_interactive = None
+ interactive_token = None
edit_approval_token = None
previous_session_id = None
def _run_agent() -> dict:
- nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id
+ nonlocal previous_approval_cb, interactive_token, edit_approval_token, previous_session_id
# Bind HERMES_SESSION_KEY for this session so per-session caches
# (e.g. the interactive sudo password cache in tools.terminal_tool)
# scope to the ACP session rather than leaking across sessions
@@ -1491,9 +1498,10 @@ def _run_agent() -> dict:
except Exception:
logger.debug("Could not set ACP edit approval requester", exc_info=True)
# Signal to tools.approval that we have an interactive callback
- # and the non-interactive auto-approve path must not fire.
- previous_interactive = os.environ.get("HERMES_INTERACTIVE")
- os.environ["HERMES_INTERACTIVE"] = "1"
+ # and the non-interactive auto-approve path must not fire. Uses a
+ # contextvar (not os.environ) so concurrent executor workers don't
+ # race on the flag (GHSA-96vc-wcxf-jjff).
+ interactive_token = set_hermes_interactive_context(True)
# Propagate the originating ACP session id to tools that want to
# tag side-effects with it (e.g. ``kanban_create`` stamps it on
# the new task so clients can render a per-session board). Save
@@ -1513,11 +1521,9 @@ def _run_agent() -> dict:
logger.exception("Agent error in session %s", session_id)
return {"final_response": f"Error: {e}", "messages": state.history}
finally:
- # Restore HERMES_INTERACTIVE.
- if previous_interactive is None:
- os.environ.pop("HERMES_INTERACTIVE", None)
- else:
- os.environ["HERMES_INTERACTIVE"] = previous_interactive
+ # Restore the interactive contextvar for this context.
+ if interactive_token is not None:
+ reset_hermes_interactive_context(interactive_token)
# Restore HERMES_SESSION_ID symmetrically.
if previous_session_id is None:
os.environ.pop("HERMES_SESSION_ID", None)
diff --git a/acp_adapter/session.py b/acp_adapter/session.py
index c124229bec89..b048fae510f8 100644
--- a/acp_adapter/session.py
+++ b/acp_adapter/session.py
@@ -461,10 +461,47 @@ def _persist(self, state: SessionState) -> None:
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)
- # Replace stored messages with current history atomically so a
- # mid-rewrite failure rolls back and the previously persisted
- # conversation is preserved (salvaged from #13675).
- db.replace_messages(state.session_id, state.history)
+ # When the agent owns persistence to this same SessionDB it has
+ # already flushed the live transcript incrementally during
+ # run_conversation (append_message), and it preserves pre-compaction
+ # turns non-destructively via archive_and_compact() — keeping them on
+ # disk as searchable active=0/compacted=1 rows. Calling
+ # replace_messages() here would then be a redundant double-write that
+ # DELETEs exactly those archived rows (and, after a compression-driven
+ # id rotation where agent.session_id no longer equals
+ # state.session_id, clobbers the ended parent transcript) — silent
+ # data loss for any ACP conversation long enough to compress.
+ #
+ # Only fall back to the destructive atomic replace when the agent is
+ # NOT persisting itself to this DB (e.g. a test agent factory, or a
+ # fresh create/fork whose copied history the agent has not flushed
+ # yet). That path still rolls back on a mid-rewrite failure so the
+ # previously persisted conversation survives (salvaged from #13675).
+ agent = state.agent
+ agent_db = getattr(agent, "_session_db", None)
+ agent_owns_persistence = (
+ agent_db is not None
+ and agent_db is db
+ and bool(getattr(agent, "_session_db_created", False))
+ )
+ if not agent_owns_persistence:
+ # Even when the current agent doesn't "own" persistence, the
+ # session on disk may already carry compaction-archived rows —
+ # e.g. after a model switch or a /restore, both of which mint a
+ # fresh agent with _session_db_created=False (so the check above
+ # is False) yet leave the durable archived transcript in place.
+ # A full-history replace would DELETE those archived rows just
+ # like the owned-agent case. Guard against it: when archived
+ # rows exist, replace ONLY the live (active=1) set and leave the
+ # archived turns untouched; otherwise the destructive replace is
+ # safe (fresh create/fork with no archived history to lose).
+ try:
+ has_archived = db.has_archived_messages(state.session_id)
+ except Exception:
+ has_archived = False
+ db.replace_messages(
+ state.session_id, state.history, active_only=has_archived
+ )
except Exception:
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
@@ -617,6 +654,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 b913e1043afb..cbf7d15b3b86 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",
}
@@ -617,7 +617,7 @@ def _format_session_search_result(result: Optional[str]) -> Optional[str]:
return None
mode = data.get("mode") or "search"
query = data.get("query")
- lines = ["Recent sessions" if mode == "recent" else f"Session search results" + (f" for `{query}`" if query else "")]
+ lines = ["Recent sessions" if mode == "recent" else "Session search results" + (f" for `{query}`" if query else "")]
if not results:
lines.append(str(data.get("message") or "No matching sessions found."))
return "\n".join(lines)
diff --git a/acp_registry/agent.json b/acp_registry/agent.json
index 4d9000752299..09319a1d02a6 100644
--- a/acp_registry/agent.json
+++ b/acp_registry/agent.json
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
- "version": "0.16.0",
+ "version": "0.18.2",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
- "package": "hermes-agent[acp]==0.16.0",
+ "package": "hermes-agent[acp]==0.18.2",
"args": ["hermes-acp"]
}
}
diff --git a/agent/account_usage.py b/agent/account_usage.py
index da02af3c478c..712e57feda4a 100644
--- a/agent/account_usage.py
+++ b/agent/account_usage.py
@@ -9,7 +9,7 @@
import httpx
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
-from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
+from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
if TYPE_CHECKING:
@@ -436,20 +436,78 @@ def _resolve_codex_usage_url(base_url: str) -> str:
return normalized + "/api/codex/usage"
-def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
- creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
- token_data = _read_codex_tokens()
- tokens = token_data.get("tokens") or {}
- account_id = str(tokens.get("account_id", "") or "").strip() or None
+def _resolve_codex_usage_credentials(
+ base_url: Optional[str],
+ api_key: Optional[str],
+) -> tuple[str, str, Optional[str]]:
+ """Resolve Codex quota credentials from the native runtime path.
+
+ Prefer explicit live-agent credentials, then the legacy singleton OAuth
+ state, then the credential pool. Hermes's native OAuth setup now stores
+ device-code logins in the pool, so quota diagnostics must not depend only
+ on the older singleton store.
+ """
+ explicit_key = str(api_key or "").strip()
+ if explicit_key:
+ return explicit_key, str(base_url or "").strip(), None
+
+ # Tier 2: the native runtime resolver. It ALREADY falls back to the
+ # credential pool when the singleton is empty (see
+ # ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only
+ # setup this returns a usable ``source="credential_pool"`` token.
+ #
+ # Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can
+ # run: a broad ``except Exception`` would (a) mask a transient refresh /
+ # network failure and silently hand back a DIFFERENT pool account's usage,
+ # and (b) hide genuine programming errors. A refresh/network error must
+ # propagate — the outer ``fetch_account_usage`` guard fails open (shows
+ # nothing this turn) rather than reporting the wrong account.
+ #
+ # The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read
+ # best-effort: a partial/missing singleton token store must not sink an
+ # otherwise-usable resolver credential and force a header-less pool fallback.
+ try:
+ creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
+ account_id: Optional[str] = None
+ try:
+ token_data = _read_codex_tokens()
+ tokens = token_data.get("tokens") or {}
+ account_id = str(tokens.get("account_id", "") or "").strip() or None
+ except AuthError:
+ # Pool-only creds carry no singleton account_id; header is optional.
+ logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True)
+ return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id
+ except AuthError:
+ logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True)
+
+ # Tier 3: direct pool select. Reached only when the resolver itself raises
+ # AuthError (e.g. singleton missing AND its own pool read found nothing at
+ # resolve time, but a pool entry is usable now). Pool credentials have no
+ # account_id concept, so the ChatGPT-Account-Id header is intentionally
+ # omitted here.
+ from agent.credential_pool import load_pool
+
+ pool = load_pool("openai-codex")
+ entry = pool.select()
+ if entry is None:
+ raise RuntimeError("No available openai-codex credential in credential pool")
+ return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None
+
+
+def _fetch_codex_account_usage(
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+) -> Optional[AccountUsageSnapshot]:
+ token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
headers = {
- "Authorization": f"Bearer {creds['api_key']}",
+ "Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
if account_id:
headers["ChatGPT-Account-Id"] = account_id
with httpx.Client(timeout=15.0) as client:
- response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
+ response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers)
response.raise_for_status()
payload = response.json() or {}
rate_limit = payload.get("rate_limit") or {}
@@ -628,7 +686,7 @@ def fetch_account_usage(
return None
try:
if normalized == "openai-codex":
- return _fetch_codex_account_usage()
+ return _fetch_codex_account_usage(base_url=base_url, api_key=api_key)
if normalized == "anthropic":
return _fetch_anthropic_account_usage()
if normalized == "openrouter":
diff --git a/agent/agent_init.py b/agent/agent_init.py
index 3a648c1b9558..495260d21886 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
@@ -68,24 +68,118 @@ def _ra():
return run_agent
-def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
- """Build the one-time notice shown when Codex gpt-5.5 raises compaction.
+def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
+ """Build the one-time notice shown when Codex gpt-5.x raises compaction.
- ``autoraise`` is ``{"from": , "to": }``. The same
- text is printed inline for CLI users and replayed via ``status_callback``
- for gateway users, so it must be self-contained and include the exact
- opt-back-out command.
+ ``autoraise`` is ``{"model": , "from": , "to": }``.
+ The same text is printed inline for CLI users and replayed via
+ ``status_callback`` for gateway users, so it must be self-contained and
+ include the exact opt-back-out command.
"""
+ model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
+ # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is
+ # capped at 272K by the Codex OAuth backend.
+ cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
return (
- f"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
+ f"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised "
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
f"summarizing.\n"
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
)
+def _resolve_compression_threshold(
+ global_threshold: float,
+ model_cthresh: Optional[float],
+ *,
+ model: Optional[str] = None,
+ is_codex_autoraise: bool,
+) -> tuple[float, Optional[Dict[str, Any]]]:
+ """Combine the user's global compaction threshold with a per-model override.
+
+ Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is
+ ``{"model": , "from": , "to": }`` only when a Codex
+ autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises
+ the threshold, otherwise ``None``.
+
+ The Codex overrides are *autoraises*: they must never LOWER a higher
+ user-configured threshold. A user who already set ``compression.threshold``
+ above the raised value deliberately keeps more raw context, and silently
+ dropping them would both waste usable window and contradict the feature's
+ purpose (use more of the window). Other overrides (e.g. Arcee Trinity)
+ keep their existing unconditional behaviour.
+ """
+ if model_cthresh is None:
+ return global_threshold, None
+ if is_codex_autoraise:
+ if model_cthresh <= global_threshold + 1e-9:
+ # Autoraise never lowers; keep the user's higher/equal threshold.
+ return global_threshold, None
+ return model_cthresh, {
+ "model": model,
+ "from": global_threshold,
+ "to": model_cthresh,
+ }
+ return model_cthresh, None
+
+
+def _codex_gpt55_autoraise_notice_marker():
+ """Path to the per-profile marker recording that the autoraise notice ran.
+
+ Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other
+ internal markers like ``.container-mode`` — so it is not a user-facing config
+ key, and every profile tracks its own notice state independently.
+ """
+ return get_hermes_home() / ".codex_gpt55_autoraise_notice"
+
+
+def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:
+ """Stable identity for one autoraise notice, keyed on what it displays.
+
+ Uses the model slug plus the same from→to percentages the notice text
+ shows, so an unchanged threshold stays silent across restarts while a
+ later change (the user edits their global ``threshold``, or switches to a
+ different autoraised Codex model) re-notifies once.
+ """
+ model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1]
+ from_pct = int(round(float(autoraise["from"]) * 100))
+ to_pct = int(round(float(autoraise["to"]) * 100))
+ return f"{model}:{from_pct}:{to_pct}"
+
+
+def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:
+ """True if this exact autoraise notice was already shown for this profile.
+
+ A missing/unreadable marker (or one recording a different threshold) reads
+ as unseen, so the notice shows.
+ """
+ try:
+ current = _codex_gpt55_autoraise_notice_state(autoraise)
+ return _codex_gpt55_autoraise_notice_marker().read_text(
+ encoding="utf-8"
+ ).strip() == current
+ except (OSError, KeyError, TypeError, ValueError):
+ return False
+
+
+def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None:
+ """Persist that the autoraise notice was shown for this profile/config state.
+
+ Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice
+ may show again next init, which is preferable to breaking agent init.
+ """
+ try:
+ marker = _codex_gpt55_autoraise_notice_marker()
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_text(
+ _codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8"
+ )
+ except (OSError, KeyError, TypeError, ValueError):
+ pass
+
+
def _normalized_custom_base_url(value: Any) -> str:
if not isinstance(value, str):
return ""
@@ -106,7 +200,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 +216,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 +371,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
@@ -531,7 +638,14 @@ def init_agent(
agent._last_activity_desc: str = "initializing"
agent._current_tool: str | None = None
agent._api_call_count: int = 0
-
+ # Opt-out flag for the between-turns MCP tool refresh (build_turn_context).
+ # Set on internal forks (e.g. background_review) that must keep ``tools[]``
+ # byte-identical to a parent for provider cache parity.
+ agent._skip_mcp_refresh = False
+ # Registry generation the current tool snapshot was derived from. Lets a
+ # late/concurrent refresh reject a stale (older-generation) rebuild instead
+ # of clobbering a newer one. Set adjacent to the tool snapshot below.
+ agent._tool_snapshot_generation = 0
# Rate limit tracking — updated from x-ratelimit-* response headers
# after each API call. Accessed by /usage slash command.
agent._rate_limit_state: Optional["RateLimitState"] = None
@@ -699,6 +813,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.
@@ -759,7 +922,7 @@ def init_agent(
client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base)
elif base_url_host_matches(effective_base, "api.routermint.com"):
client_kwargs["default_headers"] = _ra()._routermint_headers()
- elif base_url_host_matches(effective_base, "api.githubcopilot.com"):
+ elif base_url_host_matches(effective_base, "githubcopilot.com"):
from hermes_cli.models import copilot_default_headers
client_kwargs["default_headers"] = copilot_default_headers()
@@ -800,6 +963,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:
@@ -853,6 +1018,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:
@@ -901,6 +1068,34 @@ def init_agent(
# this mutation is reflected in the client built just below.
agent._apply_user_default_headers()
+ try:
+ from hermes_cli.config import (
+ apply_custom_provider_extra_headers_to_client_kwargs,
+ apply_custom_provider_tls_to_client_kwargs,
+ get_compatible_custom_providers,
+ load_config,
+ )
+
+ _cp_config = load_config()
+ _cp_entries = get_compatible_custom_providers(_cp_config)
+ _cp_base_url = str(client_kwargs.get("base_url") or agent.base_url or "")
+ apply_custom_provider_tls_to_client_kwargs(
+ client_kwargs,
+ _cp_base_url,
+ _cp_entries,
+ )
+ # Per-provider extra HTTP headers (providers..extra_headers /
+ # custom_providers[].extra_headers) — proxies, gateways, custom
+ # auth. Applied last so the most specific config level wins.
+ # SECURITY: values may carry credentials — never log them.
+ apply_custom_provider_extra_headers_to_client_kwargs(
+ client_kwargs,
+ _cp_base_url,
+ _cp_entries,
+ )
+ except Exception:
+ logger.debug("custom-provider TLS resolution skipped", exc_info=True)
+
agent.api_key = client_kwargs.get("api_key", "")
agent.base_url = client_kwargs.get("base_url", agent.base_url)
try:
@@ -953,7 +1148,14 @@ def init_agent(
print(f"🔄 Fallback chain ({len(agent._fallback_chain)} providers): " +
" → ".join(f"{f['model']} ({f['provider']})" for f in agent._fallback_chain))
- # Get available tools with filtering
+ # Get available tools with filtering. Capture the registry generation this
+ # snapshot is derived from FIRST, so a later concurrent refresh can tell
+ # whether it holds a newer or staler view (see refresh_agent_mcp_tools).
+ try:
+ from tools.registry import registry as _snapshot_registry
+ agent._tool_snapshot_generation = _snapshot_registry._generation
+ except Exception:
+ agent._tool_snapshot_generation = 0
agent.tools = _ra().get_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
@@ -1081,6 +1283,17 @@ 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
+ # When True, this agent NEVER persists to the canonical session store
+ # (state.db) or the JSON snapshot, regardless of session_id. Set on the
+ # background skill/memory review fork so its harness turn can't leak into
+ # the user's real session and hijack the next live turn. Default False.
+ agent._persist_disabled = False
agent._session_init_model_config = {
"max_iterations": agent.max_iterations,
"reasoning_config": reasoning_config,
@@ -1156,6 +1369,9 @@ def init_agent(
"hermes_home": str(get_hermes_home()),
"agent_context": "primary",
}
+ if _init_kwargs["platform"] == "cli":
+ _init_kwargs["warning_callback"] = agent._emit_warning
+ _init_kwargs["status_callback"] = agent._emit_status
# Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys)
if agent._session_db:
@@ -1218,17 +1434,57 @@ 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
# targets.
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
+ # Universal parallel-tool-call guidance toggle. Default True. Separate
+ # flag from task_completion_guidance because a user may want one but not
+ # the other. Steers the model to batch independent tool calls into a
+ # single turn; the runtime already executes such batches concurrently.
+ agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
+
# Local Python toolchain probe toggle. Default True. When False,
# the probe is skipped entirely (no subprocess calls, no system-prompt
# line). Useful for users on exotic setups where the probe heuristics
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
+ # Warm the probe off-thread: it shells out to python3/pip (~0.5s of
+ # subprocess round-trips) and its result lands in the FIRST system
+ # prompt build, which sits on the time-to-first-token critical path.
+ # The warm runs during agent init (network/credential setup dominates),
+ # so by the time the first prompt is built the line is already cached.
+ if agent._environment_probe:
+ try:
+ from tools.env_probe import warm_environment_probe_async
+ warm_environment_probe_async()
+ except Exception:
+ pass
+
+ # Per-platform prompt-hint overrides (config.yaml → platform_hints).
+ # Lets an enterprise admin append to or replace Hermes' built-in
+ # platform hint for a single messaging platform (e.g. WhatsApp) without
+ # affecting other platforms. Shape:
+ # platform_hints:
+ # whatsapp:
+ # append: "When tabular output would help, invoke the ... skill."
+ # slack:
+ # replace: "Custom Slack hint that fully replaces the default."
+ # Stored verbatim; resolution happens in agent/system_prompt.py against
+ # the active platform. Invalid shapes are ignored defensively so a bad
+ # config entry can never break prompt assembly.
+ _platform_hints_cfg = _agent_cfg.get("platform_hints", {})
+ if not isinstance(_platform_hints_cfg, dict):
+ _platform_hints_cfg = {}
+ agent._platform_hint_overrides = _platform_hints_cfg
# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
@@ -1247,41 +1503,48 @@ def init_agent(
if not isinstance(_compression_cfg, dict):
_compression_cfg = {}
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
- # Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
- # 85% (the Codex backend caps the window at 272K, so the default 50% would
- # compact at ~136K — half the usable context). Gated by an opt-out config
- # flag so the user can fall back to the global threshold; when the override
- # fires we stash a one-time notification (replayed on the first turn) that
- # tells the user what changed and how to revert.
+ # Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5
+ # raise to 85% (the Codex backend caps both families at 272K, so the
+ # default 50% would compact at ~136K — half the usable context). Gated by
+ # an opt-out config flag so the user can fall back to the global threshold;
+ # when the override fires we stash a one-time notification (replayed on the
+ # first turn) that tells the user what changed and how to revert. The
+ # notice has its own display gate so users can keep the threshold
+ # autoraise without getting the banner on gateway turns.
_codex_gpt55_autoraise = str(
_compression_cfg.get("codex_gpt55_autoraise", True)
).lower() in {"true", "1", "yes"}
+ _codex_gpt55_autoraise_notice = str(
+ _compression_cfg.get("codex_gpt55_autoraise_notice", True)
+ ).lower() in {"true", "1", "yes"}
agent._compression_threshold_autoraised = None
try:
from agent.auxiliary_client import (
_compression_threshold_for_model as _cthresh_fn,
- _is_codex_gpt55 as _is_codex_gpt55_fn,
+ _is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn,
+ _is_codex_spark as _is_codex_spark_fn,
)
_model_cthresh = _cthresh_fn(
agent.model,
agent.provider,
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
)
- if _model_cthresh is not None:
- _prev_threshold = compression_threshold
- compression_threshold = _model_cthresh
- # Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
- # override is a long-standing silent default). Skip the notice when
- # the user's global threshold already meets/exceeds the raised
- # value, since nothing actually changed for them.
- if (
- _is_codex_gpt55_fn(agent.model, agent.provider)
- and _model_cthresh > _prev_threshold + 1e-9
- ):
- agent._compression_threshold_autoraised = {
- "from": _prev_threshold,
- "to": _model_cthresh,
- }
+ # The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark)
+ # apply only when they RAISE (never lower a user's higher global
+ # threshold). The notice is populated only when it actually fires, and
+ # carries the model slug so the banner names the right family. Arcee
+ # Trinity keeps its long-standing unconditional behaviour.
+ compression_threshold, agent._compression_threshold_autoraised = (
+ _resolve_compression_threshold(
+ compression_threshold,
+ _model_cthresh,
+ model=agent.model,
+ is_codex_autoraise=(
+ _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider)
+ or _is_codex_spark_fn(agent.model, agent.provider)
+ ),
+ )
+ )
except Exception:
pass
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
@@ -1299,6 +1562,24 @@ 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
+ )
+ codex_app_server_auto_compaction = str(
+ _compression_cfg.get("codex_app_server_auto", "native") or "native"
+ ).lower()
+ if codex_app_server_auto_compaction not in {"native", "hermes", "off"}:
+ _ra().logger.warning(
+ "Invalid compression.codex_app_server_auto=%r; using 'native'. "
+ "Valid values are: native, hermes, off.",
+ codex_app_server_auto_compaction,
+ )
+ codex_app_server_auto_compaction = "native"
# Read optional explicit context_length override for the auxiliary
# compression model. Custom endpoints often cannot report this via
@@ -1447,6 +1728,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 {}
@@ -1464,15 +1746,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,
@@ -1481,6 +1783,12 @@ def init_agent(
if _selected_engine is not None:
agent.context_compressor = _selected_engine
+ # External engines own compaction policy: the host compression
+ # threshold (including the Codex gpt-5.5 autoraise above) only
+ # configures the built-in ContextCompressor and never reaches the
+ # plugin, so the autoraise notice would announce a change that does
+ # not apply. Drop it. (#44439)
+ agent._compression_threshold_autoraised = None
# Resolve context_length for plugin engines — mirrors switch_model() path
from agent.model_metadata import get_model_context_length
_plugin_ctx_len = get_model_context_length(
@@ -1516,8 +1824,17 @@ 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,
)
+ _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
+ if callable(_bind_session_state):
+ try:
+ _bind_session_state(session_db=session_db, session_id=agent.session_id)
+ except Exception:
+ pass
agent.compression_enabled = compression_enabled
+ agent.compression_in_place = compression_in_place
+ agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@@ -1527,10 +1844,39 @@ 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)."
)
+ # Nous Hermes 3/4 are chat models, not tool-call-tuned. The interactive
+ # CLI already warns via cli.py show_banner() (richer output + /model hint),
+ # so skip platform=="cli" here to avoid emitting the warning twice per
+ # startup. (Gateway/TUI/cron construct with quiet_mode=True and are already
+ # gated off by the `not agent.quiet_mode` check above; this guard's active
+ # job is the CLI dedup, and it leaves the door open for any non-quiet
+ # non-CLI surface to still surface the warning.)
+ if not agent.quiet_mode and (agent.platform or "cli") != "cli":
+ try:
+ from hermes_cli.model_switch import _check_hermes_model_warning
+
+ _hermes_warn = _check_hermes_model_warning(agent.model or "")
+ if _hermes_warn:
+ _user_msg = (
+ "⚠ Nous Research Hermes 3 & 4 models are NOT agentic — they "
+ "lack reliable tool-calling for agent workflows (delegation, "
+ "cron, proactive tools). Consider an agentic model instead "
+ "(Claude, GPT, Gemini, Qwen-Coder, etc.)."
+ )
+ if hasattr(agent, "_emit_warning"):
+ agent._emit_warning(_user_msg)
+ else:
+ print(f"\n{_user_msg}\n", file=sys.stderr)
+ _ra().logger.warning(_hermes_warn)
+ except Exception:
+ pass
+
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand).
# Skip names that are already present — the _ra().get_tool_definitions()
# quiet_mode cache returned a shared list pre-#17335, so a stray
@@ -1560,16 +1906,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:
@@ -1589,6 +1946,8 @@ def init_agent(
working_dir=os.getenv("TERMINAL_CWD") or None,
)
agent._user_turn_count = 0
+ # Copilot x-initiator flag: first API call of a user turn sends "user" (#3040).
+ agent._is_user_initiated_turn = False
# Cumulative token usage for the session
agent.session_prompt_tokens = 0
@@ -1653,29 +2012,53 @@ def init_agent(
agent._ollama_num_ctx,
)
+ # Codex gpt-5.x autoraise notice: show at most once per profile/config
+ # state. Without the persisted marker the notice re-fires on every agent
+ # init — and the gateway rebuilds the agent per inbound message, so Discord
+ # etc. saw it repeatedly (#54432). A change in the raised threshold (or the
+ # autoraised model) updates the marker state and re-notifies once. The
+ # config display gate (compression.codex_gpt55_autoraise_notice) still
+ # suppresses the banner entirely without disabling the threshold autoraise.
+ _autoraise = getattr(agent, "_compression_threshold_autoraised", None)
+ _show_autoraise_notice = (
+ bool(_autoraise)
+ and compression_enabled
+ and _codex_gpt55_autoraise_notice
+ and not _codex_gpt55_autoraise_notice_seen(_autoraise)
+ )
+
if not agent.quiet_mode:
if compression_enabled:
- print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
+ # Report the active engine's own threshold — for a plugin engine
+ # the host compression_threshold is not in effect, and mixing the
+ # two printed a percent that contradicted the token count. (#44439)
+ _active_threshold_pct = getattr(
+ agent.context_compressor, "threshold_percent", compression_threshold
+ )
+ print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
- # One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
- # exact opt-back-out command. Printed inline at startup for CLI users;
- # gateway users get the same text replayed via _compression_warning on
- # turn 1 (set below, after the warning slot is initialized).
- _autoraise = getattr(agent, "_compression_threshold_autoraised", None)
- if _autoraise and compression_enabled:
- print(_build_codex_gpt55_autoraise_notice(_autoraise))
+ # Notice with the exact opt-back-out command. Printed inline at startup
+ # for CLI users; gateway users get the same text replayed via
+ # _compression_warning on turn 1 (set below).
+ if _show_autoraise_notice:
+ print(_build_codex_gpt5_autoraise_notice(_autoraise))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
# in _compression_warning and replayed in the first run_conversation().
agent._compression_warning = None
- # Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
+ # Gateway parity for the Codex gpt-5.x autoraise notice: the startup print
# above only reaches the CLI, so stash the same text here to be replayed
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
- _autoraise = getattr(agent, "_compression_threshold_autoraised", None)
- if _autoraise and compression_enabled:
- agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
+ if _show_autoraise_notice:
+ agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
+
+ # Mark shown so repeated inits in this profile (e.g. every gateway message)
+ # stay silent. Recorded once, whether the notice went to the CLI print or
+ # the gateway replay slot.
+ if _show_autoraise_notice:
+ _record_codex_gpt55_autoraise_notice(_autoraise)
# Lazy feasibility check: deferred to the first turn that approaches the
# compression threshold. Running it eagerly here costs ~400ms cold (network
# probe of the auxiliary provider chain + /models lookup) on every agent
diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py
index 884866dc1173..6f57f83e9774 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
@@ -298,7 +306,13 @@ def _prepend_marker(tool_msg: dict) -> None:
try:
json.loads(arguments)
except json.JSONDecodeError:
- tool_call_id = tool_call.get("id")
+ # Use the canonical ``call_id || id`` precedence so both the
+ # scan for an existing tool result and any inserted stub key
+ # on the same id the rest of the pipeline uses. Keying on bare
+ # ``id`` here would fail to find a result built with ``call_id``
+ # (Codex Responses format) and insert a duplicate stub that
+ # itself becomes an orphan (#58168).
+ tool_call_id = _ra().AIAgent._get_tool_call_id_static(tool_call) or None
function_name = function.get("name", "?")
preview = arguments[:80]
log.warning(
@@ -360,6 +374,18 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
host code) can feed in already-broken histories.
Repairs applied:
+ 0. Consecutive ``assistant`` messages with no intervening
+ ``tool``/``user`` turn — merged into a single assistant turn
+ (union of ``tool_calls``, concatenated ``content``). Strict
+ OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject
+ a history where an ``assistant`` message carrying ``tool_calls``
+ is immediately followed by another ``assistant`` message instead
+ of its ``tool`` results — HTTP 400 "An assistant message with
+ 'tool_calls' must be followed by tool messages…". The split
+ shape is produced by recovery/continuation paths that append an
+ interim assistant turn (thinking-prefill, codex
+ incomplete-continuation) or by host-fed / legacy-persisted /
+ resumed histories. Refs #29148, #49147.
1. Stray ``tool`` messages whose ``tool_call_id`` doesn't match
any preceding assistant tool_call — dropped.
2. Consecutive ``user`` messages — merged with newline separator
@@ -379,12 +405,89 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
repairs = 0
+ # Pass 0: merge consecutive assistant messages. Runs BEFORE Pass 1 so
+ # the merged turn's union of tool_call ids is known when Pass 1
+ # validates which tool-result messages are orphans. Two assistant
+ # messages are only adjacent here when nothing (no tool result, no
+ # user turn) separates them — an intervening ``tool`` message means
+ # two distinct, valid tool-call rounds that must NOT be merged.
+ #
+ # Codex Responses interim turns are exempt: the codex_responses
+ # api_mode legitimately keeps multiple consecutive incomplete
+ # assistant turns in history, each carrying its own encrypted
+ # continuation state (codex_reasoning_items / codex_message_items)
+ # that must be replayed verbatim. Collapsing them corrupts the
+ # Responses replay chain (the duplicate-detection logic at
+ # conversation_loop.py already de-dups identical codex interims).
+ def _is_codex_interim(m: Dict) -> bool:
+ return bool(
+ m.get("codex_reasoning_items")
+ or m.get("codex_message_items")
+ or m.get("finish_reason") == "incomplete"
+ )
+
+ collapsed: List[Dict] = []
+ for msg in messages:
+ if (
+ collapsed
+ and isinstance(msg, dict)
+ and msg.get("role") == "assistant"
+ and isinstance(collapsed[-1], dict)
+ and collapsed[-1].get("role") == "assistant"
+ and not _is_codex_interim(msg)
+ and not _is_codex_interim(collapsed[-1])
+ ):
+ prev = collapsed[-1]
+ # Union tool_calls (preserve order, both may carry them).
+ prev_calls = list(prev.get("tool_calls") or [])
+ new_calls = list(msg.get("tool_calls") or [])
+ if new_calls:
+ prev["tool_calls"] = prev_calls + new_calls
+ elif prev_calls:
+ prev["tool_calls"] = prev_calls
+ # Concatenate plain-text content; leave multimodal (list)
+ # content on either side alone to avoid mangling attachment
+ # blocks — fall back to keeping the existing content.
+ prev_content = prev.get("content")
+ new_content = msg.get("content")
+ if isinstance(prev_content, str) and isinstance(new_content, str):
+ joined = "\n".join(
+ p for p in (prev_content.strip(), new_content.strip()) if p
+ )
+ prev["content"] = joined
+ elif not prev_content and new_content is not None:
+ prev["content"] = new_content
+ # Carry reasoning_content from the later turn only if the
+ # earlier turn lacks it (strict thinking providers require a
+ # reasoning_content on the merged tool-call turn; the first
+ # non-empty one suffices).
+ if not prev.get("reasoning_content") and msg.get("reasoning_content"):
+ prev["reasoning_content"] = msg["reasoning_content"]
+ repairs += 1
+ continue
+ collapsed.append(msg)
+
# Pass 1: drop stray tool messages that don't follow a known
# assistant tool_call_id. Uses a rolling set of known ids refreshed
# on each assistant message.
+ #
+ # Both ``id`` AND ``call_id`` are registered for every assistant
+ # tool_call. In the Codex Responses API format the two differ
+ # (``id`` = ``fc_...`` response-item id, ``call_id`` = ``call_...``
+ # the function-call id), and a tool result's ``tool_call_id`` may be
+ # matched against *either* depending on which code path built it
+ # (the OpenAI-compatible path stores ``tc.id``; codex paths store
+ # ``call_id``). Registering only ``id`` — as this pass did before —
+ # made a valid tool result look orphaned whenever the assistant
+ # tool_call carried a distinct ``call_id`` (or only ``call_id``); the
+ # pass then dropped it, leaving the assistant tool_call unanswered and
+ # producing an HTTP 400 on strict providers (DeepSeek, Kimi). Matching
+ # on the *superset* of both keys achieves the same tolerance as
+ # ``_get_tool_call_id_static``'s ``call_id || id`` — a match set must
+ # accept every legitimate reference, not just the canonical one (#58168).
known_tool_ids: set = set()
filtered: List[Dict] = []
- for msg in messages:
+ for msg in collapsed:
if not isinstance(msg, dict):
filtered.append(msg)
continue
@@ -392,14 +495,23 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
if role == "assistant":
known_tool_ids = set()
for tc in (msg.get("tool_calls") or []):
- tc_id = tc.get("id") if isinstance(tc, dict) else None
- if tc_id:
- known_tool_ids.add(tc_id)
+ if not isinstance(tc, dict):
+ continue
+ for key in ("id", "call_id"):
+ tc_id = tc.get(key)
+ if tc_id:
+ known_tool_ids.add(tc_id)
filtered.append(msg)
elif role == "tool":
tc_id = msg.get("tool_call_id")
if tc_id and tc_id in known_tool_ids:
filtered.append(msg)
+ # Consume the id so a SECOND tool result carrying the same
+ # tool_call_id (duplicate from a retry/crash/session-resume
+ # glitch) falls into the drop branch below instead of being
+ # replayed — strict providers (DeepSeek) reject a duplicate
+ # tool_call_id with HTTP 400 (#58327). Credit: #55436.
+ known_tool_ids.discard(tc_id)
else:
repairs += 1
else:
@@ -617,7 +729,14 @@ def recover_with_credential_pool(
# that seeded the pool.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
- if current_provider and pool_provider and current_provider != pool_provider:
+ # Guard: skip credential pool recovery when the pool is scoped to a
+ # different provider than the agent. Only guard when the pool has a
+ # known provider — an empty pool provider means "unscoped" (applies to
+ # any provider). An empty agent provider is treated as a mismatch
+ # because swapping the pool's credentials would set base_url/api_key
+ # without fixing the empty provider field, leaving the agent in a
+ # corrupted state (provider="" model="").
+ if pool_provider and current_provider != pool_provider:
# Custom endpoints use two naming conventions for the SAME provider:
# the agent carries the generic ``custom`` label while the pool is
# keyed ``custom:`` (see CUSTOM_POOL_PREFIX). A literal string
@@ -655,6 +774,25 @@ def recover_with_credential_pool(
elif status_code in {401, 403}:
effective_reason = FailoverReason.auth
+ if effective_reason == FailoverReason.upstream_rate_limit:
+ # An upstream provider (e.g. DeepSeek behind OpenRouter) is
+ # rate-limiting the aggregator's traffic — the user's credential is
+ # healthy. Do NOT rotate or mark exhausted; let the caller's fallback
+ # path switch to a different model entirely.
+ upstream = (error_context or {}).get("upstream_provider") if error_context else None
+ if upstream:
+ _ra().logger.info(
+ "Upstream provider %s rate-limited via aggregator — skipping "
+ "credential rotation, deferring to fallback chain",
+ upstream,
+ )
+ else:
+ _ra().logger.info(
+ "Upstream aggregator 429 (provider unknown) — skipping "
+ "credential rotation, deferring to fallback chain"
+ )
+ return False, has_retried_429
+
if effective_reason == FailoverReason.billing:
rotate_status = status_code if status_code is not None else 402
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
@@ -775,6 +913,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 +1208,84 @@ 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_provider = str(getattr(entry, "provider", "") or "").strip().lower()
+ primary_provider = str(rt.get("provider") or "").strip().lower()
+ entry_matches_primary = entry_provider == primary_provider
+ # Custom endpoints all carry the generic ``custom`` provider on
+ # the agent while the pool entry is keyed ``custom:`` (see
+ # CUSTOM_POOL_PREFIX). Resolve the primary's base_url to its
+ # ``custom:`` key via the canonical helper and compare
+ # against the entry's key — this mirrors the sibling guard in
+ # ``recover_with_credential_pool`` (see above) and correctly
+ # disambiguates multiple custom providers that share one gateway
+ # base_url. Fixes #56885.
+ from agent.credential_pool import CUSTOM_POOL_PREFIX
+ if (
+ primary_provider == "custom"
+ and entry_provider.startswith(CUSTOM_POOL_PREFIX)
+ ):
+ entry_matches_primary = False
+ try:
+ from agent.credential_pool import get_custom_provider_pool_key
+ primary_base_url = str(rt.get("base_url") or "").strip()
+ primary_key = (
+ get_custom_provider_pool_key(primary_base_url) or ""
+ ).strip().lower()
+ entry_matches_primary = bool(primary_key) and primary_key == entry_provider
+ except Exception:
+ entry_matches_primary = False
+
+ entry_key = (
+ getattr(entry, "runtime_api_key", None)
+ or getattr(entry, "access_token", "")
+ )
+ if entry_key and entry_matches_primary:
+ # ``_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", "?"),
+ )
+ elif entry_key:
+ logger.info(
+ "Restore skipped pool entry %s (%s): provider %s does not match primary provider %s",
+ getattr(entry, "id", "?"),
+ getattr(entry, "label", "?"),
+ entry_provider or "?",
+ primary_provider or "?",
+ )
+
# ── Reset fallback chain for the new turn ──
agent._fallback_activated = False
agent._fallback_index = 0
+ # Reset the stale-call circuit breaker (#58962): the streak measured
+ # the FALLBACK provider we're leaving; the restored primary deserves
+ # a fresh stream attempt before the breaker can trip again.
+ from agent.chat_completion_helpers import _reset_stale_streak
+ _reset_stale_streak(agent)
+
+ # Undo the fallback's identity rewrite so the prompt is
+ # byte-identical to the stored copy again (prefix cache match).
+ from agent.chat_completion_helpers import rewrite_prompt_model_identity
+ rewrite_prompt_model_identity(agent, rt["model"], rt["provider"])
+
logger.info(
"Primary runtime restored for new turn: %s (%s)",
agent.model, agent.provider,
@@ -1216,7 +1452,11 @@ def dump_api_request_debug(
dump_payload["error"] = error_info
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
- dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
+ # Sanitize the session ID into a traversal-free path segment — it can
+ # originate from untrusted input (X-Hermes-Session-Id header), and an
+ # unsanitized "../"-shaped ID would write the dump outside logs_dir.
+ safe_sid = _ra()._safe_session_filename_component(agent.session_id)
+ dump_file = agent.logs_dir / f"request_dump_{safe_sid}_{timestamp}.json"
# Redact secrets before persisting/printing. This dump captures the
# full request body (system prompt, tool defs, context-embedded
@@ -1281,6 +1521,46 @@ def anthropic_prompt_cache_policy(
eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "")
eff_model = (model if model is not None else agent.model) or ""
+ # MoA virtual provider: the agent's model/provider are the preset name and
+ # "moa" — neither matches any caching branch, so the ACTING AGGREGATOR
+ # (often Claude on OpenRouter) silently lost prompt caching entirely
+ # (measured: 85% cache share solo vs 2% on the identical model via MoA —
+ # tens of millions of re-billed input tokens per benchmark run). Resolve
+ # the policy from the preset's real aggregator slot instead.
+ if eff_provider.strip().lower() == "moa":
+ try:
+ from hermes_cli.config import load_config as _load_moa_cfg
+ from hermes_cli.moa_config import resolve_moa_preset
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ _preset = resolve_moa_preset(
+ _load_moa_cfg().get("moa") or {}, eff_model or None
+ )
+ _agg = _preset.get("aggregator") or {}
+ _agg_provider = str(_agg.get("provider") or "").strip()
+ _agg_model = str(_agg.get("model") or "").strip()
+ if _agg_provider and _agg_model:
+ _agg_base_url = ""
+ _agg_api_mode = ""
+ try:
+ _rt = resolve_runtime_provider(
+ requested=_agg_provider, target_model=_agg_model
+ )
+ _agg_base_url = _rt.get("base_url") or ""
+ _agg_api_mode = _rt.get("api_mode") or ""
+ except Exception:
+ pass
+ return anthropic_prompt_cache_policy(
+ agent,
+ provider=_agg_provider,
+ base_url=_agg_base_url,
+ api_mode=_agg_api_mode,
+ model=_agg_model,
+ )
+ except Exception as _moa_exc: # pragma: no cover - defensive
+ logger.debug("MoA aggregator cache-policy resolution failed: %s", _moa_exc)
+ return False, False
+
model_lower = eff_model.lower()
provider_lower = eff_provider.lower()
is_claude = "claude" in model_lower
@@ -1351,6 +1631,7 @@ def anthropic_prompt_cache_policy(
def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: bool) -> Any:
from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls
+ from agent.ssl_verify import resolve_httpx_verify
# Treat client_kwargs as read-only. Callers pass agent._client_kwargs (or shallow
# copies of it) in; any in-place mutation leaks back into the stored dict and is
# reused on subsequent requests. #10933 hit this by injecting an httpx.Client
@@ -1360,6 +1641,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
# copy locks the contract so future transport/keepalive work can't reintroduce
# the same class of bug.
client_kwargs = dict(client_kwargs)
+ ssl_ca_cert = client_kwargs.pop("ssl_ca_cert", None)
+ ssl_verify_cfg = client_kwargs.pop("ssl_verify", None)
+ httpx_verify = resolve_httpx_verify(ca_bundle=ssl_ca_cert, ssl_verify=ssl_verify_cfg)
_validate_proxy_env_urls()
_validate_base_url(client_kwargs.get("base_url"))
if agent.provider == "copilot-acp" or str(client_kwargs.get("base_url", "")).startswith("acp://copilot"):
@@ -1373,22 +1657,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
@@ -1399,7 +1667,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"}
}
if "http_client" not in safe_kwargs:
- keepalive_http = agent._build_keepalive_http_client(base_url)
+ keepalive_http = agent._build_keepalive_http_client(
+ base_url, verify=httpx_verify,
+ )
if keepalive_http is not None:
safe_kwargs["http_client"] = keepalive_http
client = GeminiNativeClient(**safe_kwargs)
@@ -1428,9 +1698,20 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
# Tests in ``tests/run_agent/test_create_openai_client_reuse.py`` and
# ``tests/run_agent/test_sequential_chats_live.py`` pin this invariant.
if "http_client" not in client_kwargs:
- keepalive_http = agent._build_keepalive_http_client(client_kwargs.get("base_url", ""))
+ keepalive_http = agent._build_keepalive_http_client(
+ client_kwargs.get("base_url", ""), verify=httpx_verify,
+ )
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 +1791,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 +1819,48 @@ 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
+
+ # The MoA virtual provider speaks only chat.completions via the
+ # MoAClient facade — the aggregator's real transport
+ # (codex_responses / anthropic_messages) is resolved and applied
+ # *inside* the reference/aggregator fan-out, never on the outer
+ # primary call. determine_api_mode("moa", ...) above may have left
+ # api_mode set to the aggregator's transport; if the conversation
+ # loop sees that, it dispatches client.responses.create (which the
+ # facade has no .responses for) and the call falls through to the
+ # moa://local placeholder → HTTP 404 → fallback to a reference
+ # model. Pin chat_completions here so the primary call always goes
+ # through MoAClient.chat.completions, matching agent_init.py.
+ agent.api_mode = "chat_completions"
+ 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,
@@ -1579,6 +1904,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"api_key": effective_key,
"base_url": effective_base,
}
+ try:
+ from hermes_cli.config import (
+ apply_custom_provider_tls_to_client_kwargs,
+ get_compatible_custom_providers,
+ load_config_readonly,
+ )
+
+ # Read custom_providers from live config (not the init-time
+ # snapshot on ``agent._custom_providers``) so ssl_ca_cert /
+ # ssl_verify edits are honored when switching mid-session,
+ # matching the context-length reload below (#15779).
+ apply_custom_provider_tls_to_client_kwargs(
+ agent._client_kwargs,
+ str(effective_base or ""),
+ get_compatible_custom_providers(load_config_readonly()),
+ )
+ except Exception:
+ logger.debug("custom-provider TLS resolution skipped on switch_model", exc_info=True)
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
if _sm_timeout is not None:
agent._client_kwargs["timeout"] = _sm_timeout
@@ -1655,6 +1998,14 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Invalidate cached system prompt so it rebuilds next turn ──
agent._cached_system_prompt = None
+ # ── Reset the cross-turn stale-call circuit breaker (#58962) ──
+ # The breaker's error text tells the user to "switch models ... then
+ # retry"; without this reset the streak stays latched and the freshly
+ # selected (healthy) provider would keep short-circuiting before any
+ # stream is even attempted.
+ from agent.chat_completion_helpers import _reset_stale_streak
+ _reset_stale_streak(agent)
+
# ── Update _primary_runtime so the change persists across turns ──
_cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None
agent._primary_runtime = {
@@ -1708,6 +2059,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,
@@ -1743,12 +2115,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
except Exception as _mw_err:
logger.debug("tool_request middleware error: %s", _mw_err)
- # Check plugin hooks for a block directive before executing anything.
+ # Check plugin hooks for a block or approval directive before executing.
block_message: Optional[str] = None
if not pre_tool_block_checked:
try:
- from hermes_cli.plugins import get_pre_tool_call_block_message
- block_message = get_pre_tool_call_block_message(
+ from hermes_cli.plugins import resolve_pre_tool_block
+ block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
@@ -1759,7 +2131,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
middleware_trace=list(_tool_middleware_trace),
)
except Exception:
- pass
+ block_message = None
if block_message is not None:
result = json.dumps({"error": block_message}, ensure_ascii=False)
try:
@@ -1839,28 +2211,28 @@ def _execute(next_args: dict) -> Any:
elif function_name == "memory":
def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory")
+ operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool(
action=next_args.get("action"),
target=target,
content=next_args.get("content"),
old_text=next_args.get("old_text"),
+ operations=operations,
store=agent._memory_store,
)
- # Bridge: notify external memory provider of built-in memory writes
- if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
- try:
- agent._memory_manager.on_memory_write(
- next_args.get("action", ""),
- target,
- next_args.get("content", ""),
- metadata=agent._build_memory_write_metadata(
- task_id=effective_task_id,
- tool_call_id=tool_call_id,
- ),
- )
- except Exception:
- pass
+ # 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:
+ 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:
@@ -2037,6 +2409,89 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
filtered.append(msg)
messages = filtered
+ # --- Drop empty / malformed tool_calls arrays on assistant messages ---
+ # An assistant message carrying ``tool_calls: []`` (an empty array) — or a
+ # non-list value under the key — is semantically identical to an assistant
+ # message with no tool calls, but strict OpenAI-compatible providers reject
+ # the empty array outright: DeepSeek v4 returns HTTP 400 "Invalid
+ # 'messages[N].tool_calls': empty array. Expected an array with minimum
+ # length 1, but got an empty array instead." (#58755, follow-up to #56980).
+ # Empty arrays reach here from session resume, host-fed histories, or the
+ # consecutive-assistant merge in ``repair_message_sequence`` (which
+ # preserves a pre-existing ``[]`` on the surviving turn). This is the final
+ # pre-API chokepoint, so normalize defensively — and, per the #56980
+ # review, do it HERE on the per-call copy rather than in
+ # ``repair_message_sequence``, which would destructively rewrite the
+ # persisted trajectory. Shallow-copy the message before dropping the key so
+ # stored history (and prompt caching) stays byte-stable.
+ normalized: List[Dict[str, Any]] = []
+ dropped_empty_tool_calls = 0
+ for msg in messages:
+ if (
+ isinstance(msg, dict)
+ and msg.get("role") == "assistant"
+ and "tool_calls" in msg
+ and not (isinstance(msg["tool_calls"], list) and msg["tool_calls"])
+ ):
+ msg = {k: v for k, v in msg.items() if k != "tool_calls"}
+ dropped_empty_tool_calls += 1
+ normalized.append(msg)
+ if dropped_empty_tool_calls:
+ messages = normalized
+ _ra().logger.debug(
+ "Pre-call sanitizer: dropped empty/invalid tool_calls on %d "
+ "assistant message(s)",
+ dropped_empty_tool_calls,
+ )
+
+ # --- Repair tool_calls whose function.name is empty/missing ---
+ # Some providers (and partially-streamed responses) emit a tool_call with
+ # id="call_xxx" but function.name="". Downstream Responses-API adapters
+ # silently DROP such function_call items while still emitting the matching
+ # function_call_output, producing the gateway's HTTP 400
+ # "No tool call found for function call output with call_id ...".
+ #
+ # We do NOT drop the call: hermes' own dispatch loop intentionally keeps an
+ # empty-name call paired with a synthesized anti-priming tool result
+ # ("tool name was empty", see #47967) so weak models self-correct instead of
+ # being fed the full tool catalog. Dropping the call here would (a) orphan
+ # that result and strip the anti-priming signal, and (b) still leave any
+ # provider-side orphan. Instead, rename the blank name to a non-empty
+ # sentinel so the call and its result stay PAIRED — the adapter no longer
+ # drops the function_call, so there is no orphaned output and no 400, while
+ # the result content the model needs is preserved.
+ _EMPTY_NAME_SENTINEL = "invalid_tool_call"
+ for msg in messages:
+ if msg.get("role") != "assistant":
+ continue
+ tcs = msg.get("tool_calls") or []
+ if not tcs:
+ continue
+ for tc in tcs:
+ if isinstance(tc, dict):
+ fn = tc.get("function")
+ name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None)
+ else:
+ fn = getattr(tc, "function", None)
+ name = getattr(fn, "name", None) if fn else None
+ if isinstance(name, str) and name.strip():
+ continue
+ _ra().logger.warning(
+ "Pre-call sanitizer: repairing tool_call with empty "
+ "function.name -> %r (id=%s)",
+ _EMPTY_NAME_SENTINEL,
+ _ra().AIAgent._get_tool_call_id_static(tc),
+ )
+ if isinstance(fn, dict):
+ fn["name"] = _EMPTY_NAME_SENTINEL
+ elif fn is not None and hasattr(fn, "name"):
+ try:
+ fn.name = _EMPTY_NAME_SENTINEL
+ except Exception:
+ pass
+ elif isinstance(tc, dict):
+ tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"}
+
surviving_call_ids: set = set()
for msg in messages:
if msg.get("role") == "assistant":
@@ -2048,7 +2503,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
result_call_ids: set = set()
for msg in messages:
if msg.get("role") == "tool":
- cid = msg.get("tool_call_id")
+ cid = (msg.get("tool_call_id") or "").strip()
if cid:
result_call_ids.add(cid)
@@ -2057,7 +2512,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
if orphaned_results:
messages = [
m for m in messages
- if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results)
+ if not (m.get("role") == "tool" and (m.get("tool_call_id") or "").strip() in orphaned_results)
]
_ra().logger.debug(
"Pre-call sanitizer: removed %d orphaned tool result(s)",
@@ -2085,17 +2540,74 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
"Pre-call sanitizer: added %d stub tool result(s)",
len(missing_results),
)
+
+ # 3. Deduplicate tool_call_ids. Strict providers (DeepSeek) reject a
+ # payload where the same tool_call_id appears more than once with HTTP 400
+ # "Duplicate value for 'tool_call_id'" (#58327). Duplicates can arise from
+ # retries, crash/resume glitches, or a compression window that re-emits a
+ # tool result. This is the final pre-API chokepoint, so dedup defensively
+ # here even though repair_message_sequence also consumes matched ids.
+ # (a) collapse duplicate tool_calls WITHIN an assistant message
+ # (b) drop later tool result messages reusing an already-seen id
+ seen_assistant_call_ids: set = set()
+ seen_result_call_ids: set = set()
+ deduped: List[Dict[str, Any]] = []
+ removed_dupes = 0
+ for msg in messages:
+ role = msg.get("role")
+ if role == "assistant" and msg.get("tool_calls"):
+ kept_tcs = []
+ for tc in msg.get("tool_calls") or []:
+ cid = _ra().AIAgent._get_tool_call_id_static(tc)
+ if cid and cid in seen_assistant_call_ids:
+ removed_dupes += 1
+ continue
+ if cid:
+ seen_assistant_call_ids.add(cid)
+ kept_tcs.append(tc)
+ if len(kept_tcs) != len(msg.get("tool_calls") or []):
+ msg = {**msg, "tool_calls": kept_tcs}
+ deduped.append(msg)
+ elif role == "tool":
+ cid = (msg.get("tool_call_id") or "").strip()
+ if cid and cid in seen_result_call_ids:
+ removed_dupes += 1
+ continue
+ if cid:
+ seen_result_call_ids.add(cid)
+ deduped.append(msg)
+ else:
+ deduped.append(msg)
+ if removed_dupes:
+ messages = deduped
+ _ra().logger.debug(
+ "Pre-call sanitizer: removed %d duplicate tool_call_id reference(s)",
+ removed_dupes,
+ )
return messages
def looks_like_codex_intermediate_ack(
agent,
- user_message: str,
+ user_message: Any,
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
@@ -2148,17 +2660,74 @@ def looks_like_codex_intermediate_ack(
"path",
)
- user_text = (user_message or "").strip().lower()
+ 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_message`` is typed ``str`` but can arrive as an OpenAI-style
+ # multi-part content list (``[{type:"text",...}, {type:"image_url",...}]``)
+ # for vision requests routed through the OpenAI-compat API server. A
+ # truthy list survives ``(user_message or "")`` and then ``.strip()``
+ # raises ``AttributeError`` — flatten to text first.
+ from agent.codex_responses_adapter import _summarize_user_message_for_log
+
+ user_text = _summarize_user_message_for_log(user_message).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"
@@ -2168,25 +2737,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
@@ -2212,9 +2792,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
@@ -2235,34 +2819,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 4a586d7f0fd7..623560250df4 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.
@@ -808,7 +817,7 @@ def build_anthropic_client(
kwargs["auth_token"] = api_key
kwargs["default_headers"] = {
"anthropic-beta": ",".join(all_betas),
- "user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
+ "user-agent": f"claude-code/{_get_claude_code_version()} (external, cli)",
"x-app": "cli",
}
else:
@@ -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:
@@ -1005,7 +1045,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
data=data,
headers={
"Content-Type": content_type,
- "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
+ "User-Agent": _OAUTH_TOKEN_USER_AGENT,
},
method="POST",
)
@@ -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,10 +1369,29 @@ 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]
+# User-Agent sent on the OAuth *token endpoint* (login exchange + refresh).
+# Anthropic rate-limits (HTTP 429) any token-endpoint request whose UA starts
+# with ``claude-code/`` — verified empirically against platform.claude.com:
+# ``claude-code/2.1.200`` and ``Mozilla/5.0`` -> 429; ``axios/*``, ``node``,
+# and SDK-style UAs -> 400 (reached code validation). The real Claude Code CLI
+# exchanges the auth code with a bare axios client (``axios/``), NOT its
+# ``claude-code/`` inference UA. We mirror that here. NOTE: the *inference* path
+# (build_anthropic_kwargs) still uses the ``claude-code/`` UA + ``x-app: cli`` —
+# that fingerprint is required there and is NOT throttled on the messages API.
+_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
-_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
+def _get_hermes_oauth_file() -> Path:
+ return get_hermes_home() / ".anthropic_oauth.json"
def _generate_pkce() -> tuple:
@@ -1349,18 +1486,37 @@ 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).
+ # UA is _OAUTH_TOKEN_USER_AGENT (a non-claude-code UA) — see the
+ # constant's definition for why the token endpoint must not send
+ # claude-code/ (429 UA-prefix block).
+ result = None
+ last_error = None
+ for endpoint in _OAUTH_TOKEN_URLS:
+ req = urllib.request.Request(
+ endpoint,
+ data=exchange_data,
+ headers={
+ "Content-Type": "application/json",
+ "User-Agent": _OAUTH_TOKEN_USER_AGENT,
+ },
+ 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
@@ -1383,9 +1539,10 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
- if _HERMES_OAUTH_FILE.exists():
+ oauth_file = _get_hermes_oauth_file()
+ if oauth_file.exists():
try:
- data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
+ data = json.loads(oauth_file.read_text(encoding="utf-8"))
if data.get("accessToken"):
return data
except (json.JSONDecodeError, OSError, IOError) as e:
@@ -1749,6 +1906,18 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return None
+def _apply_assistant_cache_control_to_last_cacheable_block(
+ blocks: List[Dict[str, Any]],
+ cache_control: Any,
+) -> None:
+ if not isinstance(cache_control, dict):
+ return
+ for block in reversed(blocks):
+ if isinstance(block, dict) and block.get("type") in {"text", "tool_use"}:
+ block.setdefault("cache_control", dict(cache_control))
+ break
+
+
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.
@@ -1803,6 +1972,9 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
clean["input"] = redacted
replayed.append(clean)
if replayed:
+ _apply_assistant_cache_control_to_last_cacheable_block(
+ replayed, m.get("cache_control")
+ )
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
@@ -1828,6 +2000,9 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"name": fn.get("name", ""),
"input": parsed_args,
})
+ _apply_assistant_cache_control_to_last_cacheable_block(
+ blocks, m.get("cache_control")
+ )
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
# tool-call messages to carry reasoning_content when thinking is
# enabled server-side. Preserve it as a thinking block so Kimi
@@ -1943,57 +2118,81 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
"""Strip tool_use blocks with no matching tool_result, and vice versa.
Context compression or session truncation can remove either side of a
- tool-call pair. Anthropic rejects both orphans with HTTP 400.
-
+ tool-call pair, or insert messages between a tool_use and its result.
+ Anthropic requires each tool_use to have a matching tool_result in the
+ IMMEDIATELY FOLLOWING user message — a global ID match is not enough.
Mutates ``result`` in place.
"""
- # Strip orphaned tool_use blocks (no matching tool_result follows)
- tool_result_ids = set()
- for m in result:
- if m["role"] == "user" and isinstance(m["content"], list):
- for block in m["content"]:
- if block.get("type") == "tool_result":
- tool_result_ids.add(block.get("tool_use_id"))
- for m in result:
- if m["role"] == "assistant" and isinstance(m["content"], list):
- kept = [
- b
- for b in m["content"]
- if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
- ]
- # If stripping an orphaned tool_use mutated a turn that also carries a
- # signed thinking block, that block's Anthropic signature was computed
- # against the ORIGINAL (un-stripped) turn content and is now invalid.
- # Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
- # the latest assistant message cannot be modified". Flag the turn so
- # _manage_thinking_signatures can demote the dead signature instead of
- # replaying it verbatim. See hermes-agent: extended-thinking + parallel
- # tool batch interrupted mid-flight → non-retryable 400 crash-loop.
- if len(kept) != len(m["content"]) and any(
- isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
- for b in m["content"]
- ):
- m["_thinking_signature_invalidated"] = True
- m["content"] = kept
- if not m["content"]:
- m["content"] = [{"type": "text", "text": "(tool call removed)"}]
-
- # Strip orphaned tool_result blocks (no matching tool_use precedes them)
- tool_use_ids = set()
+ # Pass 1: For each assistant message with tool_use blocks, check that
+ # EACH tool_use ID has a matching tool_result in the immediately following
+ # user message. Strip tool_use blocks that lack an adjacent result —
+ # Anthropic rejects non-adjacent pairs with HTTP 400 even when the IDs
+ # match somewhere later in the conversation.
+ for i, m in enumerate(result):
+ if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
+ continue
+ tool_use_ids_in_turn = {
+ b.get("id")
+ for b in m["content"]
+ if isinstance(b, dict) and b.get("type") == "tool_use"
+ }
+ if not tool_use_ids_in_turn:
+ continue
+
+ # Collect result IDs from the immediately following user message only.
+ adjacent_result_ids: set = set()
+ if i + 1 < len(result):
+ nxt = result[i + 1]
+ if nxt.get("role") == "user" and isinstance(nxt.get("content"), list):
+ for block in nxt["content"]:
+ if isinstance(block, dict) and block.get("type") == "tool_result":
+ adjacent_result_ids.add(block.get("tool_use_id"))
+
+ orphaned = tool_use_ids_in_turn - adjacent_result_ids
+ if not orphaned:
+ continue
+
+ kept = [
+ b
+ for b in m["content"]
+ if not (isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") in orphaned)
+ ]
+ # If stripping an orphaned tool_use mutated a turn that also carries a
+ # signed thinking block, that block's Anthropic signature was computed
+ # against the ORIGINAL (un-stripped) turn content and is now invalid.
+ # Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
+ # the latest assistant message cannot be modified". Flag the turn so
+ # _manage_thinking_signatures can demote the dead signature instead of
+ # replaying it verbatim. See hermes-agent: extended-thinking + parallel
+ # tool batch interrupted mid-flight → non-retryable 400 crash-loop.
+ if len(kept) != len(m["content"]) and any(
+ isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
+ for b in m["content"]
+ ):
+ m["_thinking_signature_invalidated"] = True
+ m["content"] = kept if kept else [{"type": "text", "text": "(tool call removed)"}]
+
+ # Pass 2: Rebuild the set of tool_use IDs that survived pass 1, then
+ # strip tool_result blocks that no longer have any matching tool_use
+ # anywhere in the conversation.
+ surviving_tool_use_ids: set = set()
for m in result:
- if m["role"] == "assistant" and isinstance(m["content"], list):
+ if m.get("role") == "assistant" and isinstance(m.get("content"), list):
for block in m["content"]:
- if block.get("type") == "tool_use":
- tool_use_ids.add(block.get("id"))
+ if isinstance(block, dict) and block.get("type") == "tool_use":
+ surviving_tool_use_ids.add(block.get("id"))
+
for m in result:
- if m["role"] == "user" and isinstance(m["content"], list):
- m["content"] = [
- b
- for b in m["content"]
- if b.get("type") != "tool_result" or b.get("tool_use_id") in tool_use_ids
- ]
- if not m["content"]:
- m["content"] = [{"type": "text", "text": "(tool result removed)"}]
+ if m.get("role") != "user" or not isinstance(m.get("content"), list):
+ continue
+ new_content = [
+ b
+ for b in m["content"]
+ if not (isinstance(b, dict) and b.get("type") == "tool_result")
+ or b.get("tool_use_id") in surviving_tool_use_ids
+ ]
+ if len(new_content) != len(m["content"]):
+ m["content"] = new_content if new_content else [{"type": "text", "text": "(tool result removed)"}]
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -2535,3 +2734,56 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
sorted(leaked),
)
return api_kwargs
+
+
+def _is_stream_unavailable_error(exc: Exception) -> bool:
+ """Return True when an Anthropic stream call should fall back to create()."""
+ err_lower = str(exc).lower()
+ if "stream" in err_lower and "not supported" in err_lower:
+ return True
+ if "invokemodelwithresponsestream" in err_lower:
+ from agent.bedrock_adapter import is_streaming_access_denied_error
+
+ return is_streaming_access_denied_error(exc)
+ return False
+
+
+def create_anthropic_message(
+ client: Any,
+ api_kwargs: dict,
+ *,
+ log_prefix: str = "",
+ prefer_stream: bool = True,
+) -> Any:
+ """Create an Anthropic message, aggregating via stream when available.
+
+ Some Anthropic-compatible gateways are SSE-only: they ignore non-streaming
+ requests and return ``text/event-stream`` even for ``messages.create()``.
+ The SDK can surface that as raw text, so callers that expect a Message then
+ crash on ``.content``. Prefer ``messages.stream().get_final_message()`` to
+ match the main turn path, falling back to ``create()`` only for providers
+ that explicitly do not support streaming, such as restricted Bedrock roles.
+ """
+ sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix)
+
+ messages_api = getattr(client, "messages", None)
+ stream_fn = getattr(messages_api, "stream", None)
+ if prefer_stream and callable(stream_fn):
+ stream_kwargs = dict(api_kwargs)
+ stream_kwargs.pop("stream", None)
+ try:
+ with stream_fn(**stream_kwargs) as stream:
+ return stream.get_final_message()
+ except Exception as exc:
+ if not _is_stream_unavailable_error(exc):
+ raise
+ logger.debug(
+ "%sAnthropic Messages stream unavailable; falling back to "
+ "messages.create(): %s",
+ log_prefix,
+ exc,
+ )
+
+ create_kwargs = dict(api_kwargs)
+ create_kwargs.pop("stream", None)
+ return messages_api.create(**create_kwargs)
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 86a1c765a784..d55f7df4fcdb 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,124 @@ 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__)
+# ── resolve_provider_client fall-through dedup ───────────────────────────
+# Both fall-through warning sites in resolve_provider_client (the "unknown
+# provider" and "unhandled auth_type" branches) fire on every retry of a
+# misconfigured provider, spamming the logs. Demote them to logger.debug with
+# per-process dedup: the FIRST occurrence still surfaces (it carries real
+# diagnostic value — a provider-name typo or PROVIDER_REGISTRY/auth_type
+# drift), and identical repeats are suppressed for the lifetime of the
+# process. Two independent sets keep each branch linear and let tests clear
+# them independently.
+_LOGGED_UNKNOWN_PROVIDER_KEYS: set = set()
+_LOGGED_UNHANDLED_AUTHTYPE_KEYS: set = set()
+# Same treatment for the two "registered provider, unsupported sub-branch"
+# routing dead-ends — external-process and OAuth providers that fall through
+# with no matching handler. Keyed by provider name.
+_LOGGED_UNSUPPORTED_EXTPROC_KEYS: set = set()
+_LOGGED_UNSUPPORTED_OAUTH_KEYS: set = set()
+
+
+def _resolve_aux_verify(base_url: Optional[str]) -> Any:
+ """Resolve httpx ``verify`` for an auxiliary-client base_url.
+
+ Mirrors the main client's TLS resolution so auxiliary calls (compression,
+ vision, web_extract, title generation, etc.) honor per-provider
+ ``ssl_ca_cert`` / ``ssl_verify`` config and the ``HERMES_CA_BUNDLE`` /
+ ``SSL_CERT_FILE`` env conventions. Best-effort: any failure falls back to
+ the httpx/certifi default (``True``).
+ """
+ try:
+ from agent.ssl_verify import resolve_httpx_verify
+ from hermes_cli.config import (
+ get_custom_provider_tls_settings,
+ load_config_readonly,
+ )
+
+ tls = get_custom_provider_tls_settings(
+ str(base_url or ""), config=load_config_readonly()
+ )
+ return resolve_httpx_verify(
+ ca_bundle=tls.get("ssl_ca_cert"),
+ ssl_verify=tls.get("ssl_verify"),
+ base_url=str(base_url or ""),
+ )
+ except Exception:
+ return True
+
+
+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,
+ verify=_resolve_aux_verify(base_url),
+ )
+ 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}
+ # Hermes owns auxiliary retry + provider/model fallback policy (the
+ # same-provider transient retry in call_llm plus the except-chain
+ # fallback). The OpenAI SDK's own default (max_retries=2 → up to 3
+ # attempts) silently multiplies the effective wall time of every aux call
+ # by 3× on a slow/hung endpoint, so a 120s timeout can stall ~360s before
+ # Hermes sees a single failure (issue #54465). Disable SDK-internal retries
+ # by default and let Hermes control the budget; explicit callers can still
+ # override via kwargs.
+ kwargs.setdefault("max_retries", 0)
+ 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:
@@ -202,33 +314,64 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
return bare == "trinity-large-thinking"
-# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
+# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5.
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
# chatgpt.com/backend-api/codex/responses is rejected with
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
# default 50% compaction trigger fires at ~136K — wasteful, since the model
# can hold far more raw context before summarization actually buys anything.
-# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
-# sessions use the window they actually have.
-_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
+# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/
+# gpt-5.5 sessions use the window they actually have.
+_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85
+
+# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
+# native 128K context window. The default 50% compaction trigger fires at
+# ~64K — wasting half the usable window, often before the session has enough
+# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so
+# spark sessions use more of the window before summarization, while still
+# leaving ~38K headroom for the summary and continued conversation before
+# the 128K hard limit.
+_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
-def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
- """True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
+def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
+ """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend.
Matches only the Codex OAuth route (provider ``openai-codex``), not the
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
larger context window for the same slug and must keep the user's default
- compaction threshold. ``gpt-5.5-pro`` and dated snapshots
- (``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
- family without re-listing every variant.
+ compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots
+ are matched via prefix so the override tracks both 272K-capped families
+ without re-listing every variant.
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
return False
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
- return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
+ return (
+ bare == "gpt-5.4"
+ or bare.startswith("gpt-5.4-")
+ or bare.startswith("gpt-5.4.")
+ or bare == "gpt-5.5"
+ or bare.startswith("gpt-5.5-")
+ or bare.startswith("gpt-5.5.")
+ )
+
+
+def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool:
+ """True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend.
+
+ The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native
+ 128K context window. Only the Codex OAuth route (provider
+ ``openai-codex``) is matched — the slug is not available on other
+ routes.
+ """
+ prov = (provider or "").strip().lower()
+ if prov != "openai-codex":
+ return False
+ bare = (model or "").strip().lower().rsplit("/", 1)[-1]
+ return bare == "gpt-5.3-codex-spark"
def _fixed_temperature_for_model(
@@ -267,18 +410,26 @@ def _compression_threshold_for_model(
Per-model/route overrides:
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
- - gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window
- at 272K and the default 50% trigger would compact at ~136K. Gated by
- ``allow_codex_gpt55_autoraise`` so the user can opt back down to the
- global default (the caller passes the config flag through here).
+ - gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps
+ both families at 272K and the default 50% trigger would compact at
+ ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key
+ name kept for backward compatibility) so the user can opt back down to
+ the global default (the caller passes the config flag through here).
+ - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model
+ has a native 128K window and the default 50% trigger would compact at
+ ~64K — wasting half the usable context. Not gated by the gpt-5.5
+ opt-out flag: 128K is the model's native window, so the raise is
+ unambiguously correct.
Returns a float in (0, 1] to override the global ``compression.threshold``
config value, or ``None`` to leave the user's config value unchanged.
"""
if _is_arcee_trinity_thinking(model):
return 0.75
- if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
- return _CODEX_GPT55_COMPACTION_THRESHOLD
+ if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider):
+ return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD
+ if _is_codex_spark(model, provider):
+ return _CODEX_SPARK_COMPACTION_THRESHOLD
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
@@ -373,7 +524,19 @@ def _apply_user_default_headers(headers: dict | None) -> dict | None:
"""
try:
from hermes_cli.config import cfg_get, load_config
- user_headers = cfg_get(load_config(), "model", "default_headers")
+ _cfg = load_config()
+ user_headers = cfg_get(_cfg, "model", "default_headers")
+ # ``model.extra_headers`` is an accepted alias (matches the
+ # per-provider ``extra_headers`` key on providers/custom_providers
+ # entries). When both are set they merge, with ``extra_headers``
+ # winning. SECURITY: values may carry credentials — never log them.
+ alias_headers = cfg_get(_cfg, "model", "extra_headers")
+ if isinstance(alias_headers, dict) and alias_headers:
+ merged_user: dict = {}
+ if isinstance(user_headers, dict):
+ merged_user.update(user_headers)
+ merged_user.update(alias_headers)
+ user_headers = merged_user
except Exception:
return headers
if not isinstance(user_headers, dict) or not user_headers:
@@ -620,6 +783,14 @@ def _pool_runtime_api_key(entry: Any) -> str:
def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
if entry is None:
return str(fallback or "").strip().rstrip("/")
+ if getattr(entry, "provider", None) == "nous":
+ # Funnel through the canonical auth-layer reader so the env override
+ # shares one normalization path with the rest of the NOUS resolution.
+ from hermes_cli.auth import _nous_inference_env_override
+
+ env_url = _nous_inference_env_override()
+ if env_url:
+ return env_url
# runtime_base_url handles provider-specific logic (e.g. nous prefers inference_base_url).
# Fall back through inference_base_url and base_url for non-PooledCredential entries.
url = (
@@ -631,6 +802,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
@@ -767,6 +967,32 @@ def create(self, **kwargs) -> Any:
if converted:
resp_kwargs["tools"] = converted
+ # Stable prompt-cache routing for the Codex/Responses aux path, mirroring
+ # the main transport (agent/transports/codex.py::build_kwargs, which sets
+ # prompt_cache_key = _content_cache_key(instructions, tools)). Without
+ # this, MoA acting-aggregator and other auxiliary Responses calls stay
+ # cache-cold while the main Responses transport is warm (issue #53735).
+ # The key is content-addressed from the static prefix (instructions +
+ # tool schemas) so it stays warm across turns/fires. Guard the top-level
+ # field the same way the main transport does: xAI Responses takes the
+ # key in extra_body (not top-level) and GitHub/Copilot Responses opts
+ # out of cache-key routing entirely — for those hosts, skip it here.
+ try:
+ from agent.transports.codex import _content_cache_key
+ from utils import base_url_host_matches
+
+ _host_src = str(getattr(self._client, "base_url", "") or "")
+ _is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai")
+ _is_github = base_url_host_matches(_host_src, "githubcopilot.com")
+ if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs:
+ _cache_key = _content_cache_key(instructions, resp_kwargs.get("tools"))
+ if _cache_key:
+ resp_kwargs["prompt_cache_key"] = _cache_key
+ except Exception:
+ logger.debug(
+ "Codex auxiliary: prompt_cache_key derivation skipped", exc_info=True
+ )
+
# Stream and collect the response
text_parts: List[str] = []
tool_calls_raw: List[Any] = []
@@ -805,7 +1031,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
@@ -997,7 +1227,7 @@ def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
- from agent.anthropic_adapter import build_anthropic_kwargs
+ from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message
from agent.transports import get_transport
messages = kwargs.get("messages", [])
@@ -1011,7 +1241,7 @@ def create(self, **kwargs) -> Any:
if _skip_mt:
max_tokens = None
else:
- max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
+ max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
temperature = kwargs.get("temperature")
normalized_tool_choice = None
@@ -1041,7 +1271,7 @@ def create(self, **kwargs) -> Any:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
- response = self._client.messages.create(**anthropic_kwargs)
+ response = create_anthropic_message(self._client, anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
response, strip_tool_prefix=self._is_oauth
@@ -1126,6 +1356,96 @@ def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"):
self._real_client = sync_wrapper._real_client
+class _BedrockCompletionsAdapter:
+ """Translates ``chat.completions.create(**kwargs)`` into Bedrock Converse."""
+
+ def __init__(self, region: str, model: str):
+ self._region = region
+ self._model = model
+
+ def create(self, **kwargs) -> Any:
+ from agent.bedrock_adapter import call_converse
+
+ messages = kwargs.get("messages", [])
+ model = kwargs.get("model", self._model)
+ max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
+ # OpenAI accepts ``stop`` as str or list; Converse requires a list.
+ stop = kwargs.get("stop")
+ if isinstance(stop, str):
+ stop = [stop]
+ if kwargs.get("tool_choice") is not None:
+ # Converse's toolChoice isn't wired through call_converse();
+ # no in-tree auxiliary caller passes tool_choice today. Surface
+ # the drop instead of silently ignoring it.
+ logger.debug(
+ "BedrockAuxiliaryClient: tool_choice=%r not supported by the "
+ "Converse shim — ignored.", kwargs.get("tool_choice"),
+ )
+ if kwargs.get("stream"):
+ # Converse streaming isn't wired through this shim. Return a
+ # complete response instead — call_llm's streaming consumer
+ # detects a final object and downgrades to non-live output.
+ logger.debug(
+ "BedrockAuxiliaryClient: stream=True requested for %s — "
+ "returning a complete response (Converse shim does not "
+ "stream); caller downgrades to non-streaming.",
+ model,
+ )
+ return call_converse(
+ region=self._region,
+ model=model,
+ messages=messages,
+ tools=kwargs.get("tools"),
+ max_tokens=int(max_tokens) if max_tokens else 4096,
+ temperature=kwargs.get("temperature"),
+ top_p=kwargs.get("top_p"),
+ stop_sequences=stop,
+ )
+
+
+class _BedrockChatShim:
+ def __init__(self, adapter: "_BedrockCompletionsAdapter"):
+ self.completions = adapter
+
+
+class BedrockAuxiliaryClient:
+ """OpenAI-client-compatible wrapper over AWS Bedrock Converse API."""
+
+ def __init__(self, region: str, model: str):
+ self._region = region
+ self._model = model
+ adapter = _BedrockCompletionsAdapter(region, model)
+ self.chat = _BedrockChatShim(adapter)
+ self.api_key = "aws-sdk"
+ self.base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
+
+ def close(self):
+ pass
+
+
+class _AsyncBedrockCompletionsAdapter:
+ def __init__(self, sync_adapter: _BedrockCompletionsAdapter):
+ self._sync = sync_adapter
+
+ async def create(self, **kwargs) -> Any:
+ import asyncio
+ return await asyncio.to_thread(self._sync.create, **kwargs)
+
+
+class _AsyncBedrockChatShim:
+ def __init__(self, adapter: _AsyncBedrockCompletionsAdapter):
+ self.completions = adapter
+
+
+class AsyncBedrockAuxiliaryClient:
+ def __init__(self, sync_wrapper: "BedrockAuxiliaryClient"):
+ sync_adapter = sync_wrapper.chat.completions
+ async_adapter = _AsyncBedrockCompletionsAdapter(sync_adapter)
+ self.chat = _AsyncBedrockChatShim(async_adapter)
+ self.api_key = sync_wrapper.api_key
+ self.base_url = sync_wrapper.base_url
+
+
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
protocol instead of OpenAI chat.completions.
@@ -1181,6 +1501,8 @@ def _maybe_wrap_anthropic(
# Already wrapped — don't double-wrap.
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
+ if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
+ return client_obj
# Other specialized adapters we should never re-dispatch.
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
@@ -1300,6 +1622,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 +1681,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:
@@ -1474,7 +1851,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra = {}
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
- elif base_url_host_matches(base_url, "api.githubcopilot.com"):
+ elif base_url_host_matches(base_url, "githubcopilot.com"):
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
@@ -1491,7 +1868,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
@@ -1514,7 +1891,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra = {}
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
- elif base_url_host_matches(base_url, "api.githubcopilot.com"):
+ elif base_url_host_matches(base_url, "githubcopilot.com"):
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
@@ -1531,7 +1908,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 +1923,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 +2030,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,
),
@@ -1756,6 +2134,76 @@ def _read_main_provider() -> str:
return ""
+def _read_main_api_key() -> str:
+ """Read the user's main model API key from the runtime override or config.
+
+ Mirrors ``_read_main_model`` / ``_read_main_provider``: checks the
+ process-local ``_RUNTIME_MAIN_API_KEY`` override first (set by
+ ``set_runtime_main`` when an AIAgent is active), then falls back to
+ ``model.api_key`` in config.yaml.
+
+ Used by the ``custom`` provider fallback chain so that auxiliary tasks
+ configured with an explicit ``base_url`` but empty ``api_key`` inherit
+ the main model's credentials instead of falling to ``no-key-required``
+ (issue #9318).
+ """
+ override = _RUNTIME_MAIN_API_KEY
+ if isinstance(override, str) and override.strip():
+ return override.strip()
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ model_cfg = cfg.get("model", {})
+ if isinstance(model_cfg, dict):
+ key = model_cfg.get("api_key", "")
+ if isinstance(key, str) and key.strip():
+ return key.strip()
+ except Exception:
+ pass
+ return ""
+
+
+def _read_main_base_url() -> str:
+ """Read the main model's base_url from the runtime override or config.
+
+ Same override-then-config pattern as ``_read_main_api_key``.
+ """
+ override = _RUNTIME_MAIN_BASE_URL
+ if isinstance(override, str) and override.strip():
+ return override.strip()
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ model_cfg = cfg.get("model", {})
+ if isinstance(model_cfg, dict):
+ base = model_cfg.get("base_url", "")
+ if isinstance(base, str) and base.strip():
+ return base.strip()
+ except Exception:
+ pass
+ return ""
+
+
+def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
+ """Return the main api_key only when *aux_base_url* points at the same
+ host as the main model's base_url.
+
+ The #9318 use case is an auxiliary task sharing the main model's
+ self-hosted gateway (same host, different model) with an empty per-task
+ api_key. Inheriting unconditionally would send the main credential to
+ ANY host a misconfigured aux base_url names — a cross-host credential
+ leak. A host mismatch keeps the previous fail-safe behavior
+ (``no-key-required`` → 401).
+ """
+ aux_host = base_url_hostname(aux_base_url)
+ if not aux_host:
+ return ""
+ main_host = base_url_hostname(_read_main_base_url())
+ if not main_host or aux_host != main_host:
+ return ""
+ return _read_main_api_key()
+
+
# Process-local override set by AIAgent at session/turn start. Single-threaded
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
_RUNTIME_MAIN_PROVIDER: str = ""
@@ -1929,7 +2377,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 +2391,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 +2427,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 +2464,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 +2564,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
@@ -2145,19 +2593,34 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
return None, None
pool_present, entry = _select_pool_entry("anthropic")
- if pool_present:
- if entry is None:
- return None, None
+ if pool_present and entry is not None:
token = explicit_api_key or _pool_runtime_api_key(entry)
else:
+ # Pool absent, OR pool present but no usable entry (expired token +
+ # stale refresh_token, all entries exhausted, etc). Fall through to the
+ # legacy resolver instead of hard-failing: a temporarily dead pool
+ # entry must not wedge auxiliary tasks when a valid standalone
+ # credential (ANTHROPIC_TOKEN, credentials file, API key) exists. This
+ # matches the openrouter and codex paths, which already fall back to
+ # their env/auth-store credential on (True, None). Without this, the
+ # goal judge and every other Anthropic-routed side channel died with
+ # "no auxiliary client configured" while the main session stayed
+ # healthy (it resolves the env token directly).
entry = None
token = explicit_api_key or resolve_anthropic_token()
if not token:
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 +2630,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 +2833,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 +2842,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",
@@ -2439,6 +2904,27 @@ def _is_rate_limit_error(exc: Exception) -> bool:
return False
+def _is_timeout_error(exc: Exception) -> bool:
+ """Detect a request timeout — the full-budget stall, distinct from a fast
+ connection drop.
+
+ A timeout burns the entire configured ``timeout`` before surfacing, so a
+ same-provider retry on the critical compression path doubles the
+ user-visible wall time (issue #54465). A streaming-close / dropped
+ connection, by contrast, fails fast and is cheap to retry — those stay on
+ the retry path even for compression.
+ """
+ try:
+ from openai import APITimeoutError
+ if isinstance(exc, APITimeoutError):
+ return True
+ except ImportError:
+ pass
+ if "Timeout" in type(exc).__name__:
+ return True
+ return "timed out" in str(exc).lower()
+
+
def _is_connection_error(exc: Exception) -> bool:
"""Detect connection/network errors that warrant provider fallback.
@@ -2478,7 +2964,7 @@ def _is_connection_error(exc: Exception) -> bool:
def _is_transient_transport_error(exc: Exception) -> bool:
- """Return True for a one-off transport blip worth retrying ONCE on the
+ """Return True for a one-off transport blip worth retrying ON the
same provider before any provider/model fallback.
Covers connection/streaming-close errors (via the canonical
@@ -2496,6 +2982,34 @@ def _is_transient_transport_error(exc: Exception) -> bool:
return isinstance(status, int) and (status == 408 or 500 <= status < 600)
+_DEFAULT_TRANSIENT_RETRIES = 2
+# Base for exponential backoff between transient retries (seconds). Overridable
+# so tests can zero it out and not sleep real wall-clock time.
+_TRANSIENT_RETRY_BACKOFF_BASE = 1.0
+
+
+def _transient_retry_count() -> int:
+ """Number of same-provider retries for a transient transport blip.
+
+ Read from ``auxiliary.transient_retries`` in config.yaml (default 2 →
+ 3 total attempts). Clamped to [0, 6] to bound worst-case wall time. A
+ connection blip to a pinned auxiliary target (e.g. a MoA reference
+ advisor) has no meaningful provider fallback, so a couple of retries with
+ backoff is the difference between recovering and silently losing the call.
+ Best-effort: any config-read failure falls back to the default.
+ """
+ try:
+ from hermes_cli.config import cfg_get, load_config
+
+ val = cfg_get(load_config(), "auxiliary", "transient_retries")
+ if val is None:
+ return _DEFAULT_TRANSIENT_RETRIES
+ n = int(val)
+ return max(0, min(n, 6))
+ except Exception:
+ return _DEFAULT_TRANSIENT_RETRIES
+
+
def _is_auth_error(exc: Exception) -> bool:
"""Detect auth failures that should trigger provider-specific refresh."""
status = getattr(exc, "status_code", None)
@@ -2597,6 +3111,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)
@@ -2700,7 +3287,7 @@ def _recoverable_pool_provider(
return "nous"
if base_url_host_matches(base, "api.anthropic.com"):
return "anthropic"
- if base_url_host_matches(base, "api.githubcopilot.com"):
+ if base_url_host_matches(base, "githubcopilot.com"):
return "copilot"
if base_url_host_matches(base, "api.kimi.com"):
return "kimi-coding"
@@ -2893,6 +3480,21 @@ def _refresh_provider_credentials(provider: str) -> bool:
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
normalized = _normalize_aux_provider(provider)
try:
+ if normalized == "copilot":
+ from hermes_cli.copilot_auth import (
+ _jwt_cache,
+ _token_fingerprint,
+ exchange_copilot_token,
+ resolve_copilot_token,
+ )
+
+ raw_token, _source = resolve_copilot_token()
+ if not str(raw_token or "").strip():
+ return False
+ _jwt_cache.pop(_token_fingerprint(raw_token), None)
+ exchange_copilot_token(raw_token)
+ _evict_cached_clients(normalized)
+ return True
if normalized == "openai-codex":
from hermes_cli.auth import resolve_codex_runtime_credentials
@@ -2905,7 +3507,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():
@@ -2947,6 +3549,152 @@ def _refresh_provider_credentials(provider: str) -> bool:
return False
+def _auth_refresh_provider_for_route(
+ resolved_provider: Optional[str],
+ client_base_url: str,
+) -> str:
+ """Return the provider whose short-lived credentials should be refreshed.
+
+ Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even
+ after _get_cached_client() selects a concrete backend. Infer the backend
+ from the selected client's base URL so auth refresh works for auto →
+ Copilot/Codex/Anthropic/Nous routes too. (#20832)
+ """
+ normalized = _normalize_aux_provider(resolved_provider)
+ if normalized and normalized != "auto":
+ return normalized
+ if base_url_host_matches(client_base_url, "api.githubcopilot.com"):
+ return "copilot"
+ if base_url_host_matches(client_base_url, "chatgpt.com"):
+ return "openai-codex"
+ if base_url_host_matches(client_base_url, "api.anthropic.com"):
+ return "anthropic"
+ if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"):
+ return "nous"
+ return normalized
+
+
+def _call_fallback_candidate_sync(
+ fb_client: Any,
+ fb_model: Optional[str],
+ fb_label: str,
+ *,
+ task: Optional[str],
+ messages: list,
+ temperature: Optional[float],
+ max_tokens: Optional[int],
+ tools: Optional[list],
+ effective_timeout: float,
+ effective_extra_body: dict,
+) -> Optional[Any]:
+ """Call one fallback candidate with stale-credential recovery.
+
+ A fallback candidate can itself carry a stale credential (e.g. an expired
+ ``ANTHROPIC_TOKEN`` picked up by ``_try_anthropic``). Before this helper,
+ such a 401 propagated out of the fallback site and aborted the auxiliary
+ task (for compression: a 60s cooldown + context marker) even though other
+ healthy candidates remained. Live case: a Codex-timeout → Anthropic
+ fallback 401-looped five times in one session (mattalachia debug dump,
+ Jul 2026).
+
+ On an auth error: refresh the candidate's provider credentials and retry
+ once with a rebuilt client; if the retry also auth-fails (non-refreshable
+ expired token), mark the provider unhealthy and return ``None`` so the
+ caller can continue to the next fallback layer. Non-auth errors raise.
+ """
+ fb_base = str(getattr(fb_client, "base_url", "") or "")
+ fb_kwargs = _build_call_kwargs(
+ fb_label, fb_model, messages,
+ temperature=temperature, max_tokens=max_tokens,
+ tools=tools, timeout=effective_timeout,
+ extra_body=effective_extra_body, base_url=fb_base)
+ try:
+ return _validate_llm_response(
+ fb_client.chat.completions.create(**fb_kwargs), task)
+ except Exception as fb_err:
+ if not _is_auth_error(fb_err):
+ raise
+ fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
+ if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
+ retry_client, retry_model = _get_cached_client(fb_provider, fb_model)
+ if retry_client is not None:
+ retry_kwargs = _build_call_kwargs(
+ fb_provider, retry_model or fb_model, messages,
+ temperature=temperature, max_tokens=max_tokens,
+ tools=tools, timeout=effective_timeout,
+ extra_body=effective_extra_body,
+ base_url=str(getattr(retry_client, "base_url", "") or fb_base))
+ try:
+ return _validate_llm_response(
+ retry_client.chat.completions.create(**retry_kwargs), task)
+ except Exception as retry_err:
+ if not _is_auth_error(retry_err):
+ raise
+ # Refresh unavailable or the refreshed credential still 401s —
+ # the token is dead (expired setup token with no refresh token).
+ # Quarantine the candidate so subsequent chain walks skip it, and
+ # let the caller move on instead of aborting the whole task.
+ _mark_provider_unhealthy(fb_provider or fb_label)
+ logger.warning(
+ "Auxiliary %s: fallback candidate %s has a stale/unrefreshable "
+ "credential (%s) — skipping to next fallback",
+ task or "call", fb_label, fb_err,
+ )
+ return None
+
+
+async def _call_fallback_candidate_async(
+ fb_client: Any,
+ fb_model: Optional[str],
+ fb_label: str,
+ *,
+ task: Optional[str],
+ messages: list,
+ temperature: Optional[float],
+ max_tokens: Optional[int],
+ tools: Optional[list],
+ effective_timeout: float,
+ effective_extra_body: dict,
+) -> Optional[Any]:
+ """Async mirror of :func:`_call_fallback_candidate_sync`."""
+ fb_base = str(getattr(fb_client, "base_url", "") or "")
+ fb_kwargs = _build_call_kwargs(
+ fb_label, fb_model, messages,
+ temperature=temperature, max_tokens=max_tokens,
+ tools=tools, timeout=effective_timeout,
+ extra_body=effective_extra_body, base_url=fb_base)
+ try:
+ return _validate_llm_response(
+ await fb_client.chat.completions.create(**fb_kwargs), task)
+ except Exception as fb_err:
+ if not _is_auth_error(fb_err):
+ raise
+ fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
+ if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
+ retry_client, retry_model = _get_cached_client(
+ fb_provider, fb_model, async_mode=True)
+ if retry_client is not None:
+ retry_kwargs = _build_call_kwargs(
+ fb_provider, retry_model or fb_model, messages,
+ temperature=temperature, max_tokens=max_tokens,
+ tools=tools, timeout=effective_timeout,
+ extra_body=effective_extra_body,
+ base_url=str(getattr(retry_client, "base_url", "") or fb_base))
+ try:
+ return _validate_llm_response(
+ await retry_client.chat.completions.create(**retry_kwargs), task)
+ except Exception as retry_err:
+ if not _is_auth_error(retry_err):
+ raise
+ _mark_provider_unhealthy(fb_provider or fb_label)
+ logger.warning(
+ "Auxiliary %s (async): fallback candidate %s has a stale/unrefreshable "
+ "credential (%s) — skipping to next fallback",
+ task or "call", fb_label, fb_err,
+ )
+ return None
+
+
def _try_payment_fallback(
failed_provider: str,
task: str = None,
@@ -3047,6 +3795,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 +3901,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 +3919,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 +3948,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 +4028,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 +4052,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 +4167,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
@@ -3383,6 +4296,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
+ if isinstance(sync_client, BedrockAuxiliaryClient):
+ return AsyncBedrockAuxiliaryClient(sync_client), model
try:
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
@@ -3404,7 +4319,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
sync_base_url = str(sync_client.base_url)
if base_url_host_matches(sync_base_url, "openrouter.ai"):
async_kwargs["default_headers"] = build_or_headers()
- elif base_url_host_matches(sync_base_url, "api.githubcopilot.com"):
+ elif base_url_host_matches(sync_base_url, "githubcopilot.com"):
from hermes_cli.copilot_auth import copilot_request_headers
async_kwargs["default_headers"] = copilot_request_headers(
@@ -3431,6 +4346,13 @@ 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,
+ }
+ # See _create_openai_client: disable SDK-internal retries so Hermes owns
+ # the auxiliary retry/timeout budget (issue #54465).
+ async_kwargs.setdefault("max_retries", 0)
return AsyncOpenAI(**async_kwargs), model
@@ -3523,7 +4445,15 @@ def resolve_provider_client(
# main_model also empty), the branches still hit their own
# missing-credentials returns and ``_resolve_auto`` falls through to
# the Step-2 chain as before.
- if not model:
+ #
+ # Prefer explicit caller model, then provider-scoped aux model, then main model.
+ # Do NOT pre-fill a blank ``auto`` request from the config/main default here.
+ # ``auto`` has its own main-runtime resolver below; pre-filling first can pair
+ # a stale configured model with a live fallback provider (e.g. Claude model
+ # sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto()
+ # return the actual current runtime model when the caller did not explicitly
+ # request one. (# compression-current-model)
+ if not model and provider != "auto":
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
@@ -3641,7 +4571,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),
@@ -3657,7 +4587,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
- # ── xAI Grok OAuth (loopback PKCE → Responses API) ───────────────
+ # ── xAI Grok OAuth (device code → Responses API) ───────────────
# Without this branch, an xai-oauth main provider falls through to the
# generic ``oauth_external`` arm below and returns ``(None, None)``,
# silently re-routing every auxiliary task (compression, web extract,
@@ -3679,11 +4609,14 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
# ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
if provider == "custom":
+ custom_base = ""
+ custom_key = ""
if explicit_base_url:
custom_base = _to_openai_base_url(explicit_base_url).strip()
custom_key = (
(explicit_api_key or "").strip()
or os.getenv("OPENAI_API_KEY", "").strip()
+ or _read_main_api_key_if_same_host(custom_base)
or "no-key-required" # local servers don't need auth
)
if not custom_base:
@@ -3692,6 +4625,19 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
"but base_url is empty"
)
return None, None
+ elif main_runtime:
+ # When main_runtime carries a concrete base_url + api_key for a
+ # named custom provider (custom:), use it directly instead
+ # of re-resolving from the bare "custom" provider name.
+ # Re-resolution loses the provider name and falls back to
+ # OpenRouter or a wrong API-key provider — the main agent already
+ # solved this, we just need to reuse its answer. (#45472)
+ _main_base = str(main_runtime.get("base_url") or "").strip().rstrip("/")
+ _main_key = str(main_runtime.get("api_key") or "").strip()
+ if _main_base and _main_key:
+ custom_base = _main_base
+ custom_key = _main_key
+ if custom_base and custom_key:
final_model = _normalize_resolved_model(
model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini",
provider,
@@ -3702,7 +4648,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
extra["default_query"] = _dq
if base_url_host_matches(custom_base, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
- elif base_url_host_matches(custom_base, "api.githubcopilot.com"):
+ elif base_url_host_matches(custom_base, "githubcopilot.com"):
from hermes_cli.copilot_auth import copilot_request_headers
extra["default_headers"] = copilot_request_headers(
is_agent_turn=True, is_vision=is_vision
@@ -3722,7 +4668,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 +4772,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 +4781,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
@@ -3902,7 +4848,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
pconfig = PROVIDER_REGISTRY.get(provider)
if pconfig is None:
- logger.warning("resolve_provider_client: unknown provider %r", provider)
+ # Demoted from logger.warning to debug; dedup keyed by provider name
+ # so the first occurrence surfaces but repeated retries stay silent.
+ if provider not in _LOGGED_UNKNOWN_PROVIDER_KEYS:
+ _LOGGED_UNKNOWN_PROVIDER_KEYS.add(provider)
+ logger.debug("resolve_provider_client: unknown provider %r", provider)
return None, None
if pconfig.auth_type == "api_key":
@@ -3955,7 +4905,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
headers = {}
if base_url_host_matches(base_url, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
- elif base_url_host_matches(base_url, "api.githubcopilot.com"):
+ elif base_url_host_matches(base_url, "githubcopilot.com"):
from hermes_cli.copilot_auth import copilot_request_headers
headers.update(copilot_request_headers(
@@ -3977,7 +4927,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
@@ -4044,15 +4994,57 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
- logger.warning("resolve_provider_client: external-process provider %s not "
- "directly supported", provider)
+ if provider not in _LOGGED_UNSUPPORTED_EXTPROC_KEYS:
+ _LOGGED_UNSUPPORTED_EXTPROC_KEYS.add(provider)
+ logger.debug("resolve_provider_client: external-process provider %s not "
+ "directly supported", provider)
return None, None
+ elif pconfig.auth_type == "vertex":
+ # Google Vertex AI — Gemini via the OpenAI-compatible endpoint with an
+ # OAuth2 bearer token (NOT a static key). We build a standard OpenAI
+ # client pointed at the runtime-computed Vertex base_url with a fresh
+ # token; no custom SDK or message translation needed.
+ try:
+ from agent.vertex_adapter import get_vertex_config, has_vertex_credentials
+ except ImportError:
+ logger.warning("resolve_provider_client: vertex requested but "
+ "google-auth not installed")
+ return None, None
+
+ if not has_vertex_credentials():
+ logger.debug("resolve_provider_client: vertex requested but "
+ "no GCP credentials found")
+ return None, None
+
+ token, base_url = get_vertex_config()
+ if not token or not base_url:
+ logger.warning("resolve_provider_client: vertex requested but "
+ "could not mint token / resolve project")
+ return None, None
+
+ default_model = "google/gemini-3-flash-preview"
+ final_model = _normalize_resolved_model(model or default_model, provider)
+ try:
+ from openai import OpenAI
+ client = OpenAI(api_key=token, base_url=base_url)
+ except Exception as exc:
+ logger.warning("resolve_provider_client: cannot create Vertex "
+ "client: %s", exc)
+ return None, None
+ logger.debug("resolve_provider_client: vertex (%s)", final_model)
+ return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
+ else (client, final_model))
+
elif pconfig.auth_type == "aws_sdk":
- # AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
- # boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
+ # AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
+ # SDK (prompt caching, thinking); non-Claude models use Converse API.
try:
- from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
+ from agent.bedrock_adapter import (
+ has_aws_credentials,
+ is_anthropic_bedrock_model,
+ resolve_bedrock_region,
+ )
from agent.anthropic_adapter import build_anthropic_bedrock_client
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
@@ -4067,17 +5059,26 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = _normalize_resolved_model(model or default_model, provider)
- try:
- real_client = build_anthropic_bedrock_client(region)
- except ImportError as exc:
- logger.warning("resolve_provider_client: cannot create Bedrock "
- "client: %s", exc)
- return None, None
- client = AnthropicAuxiliaryClient(
- real_client, final_model, api_key="aws-sdk",
- base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
- )
- logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
+ base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
+
+ if is_anthropic_bedrock_model(final_model):
+ try:
+ real_client = build_anthropic_bedrock_client(region)
+ except ImportError as exc:
+ logger.warning("resolve_provider_client: cannot create Bedrock "
+ "client: %s", exc)
+ return None, None
+ client = AnthropicAuxiliaryClient(
+ real_client, final_model, api_key="aws-sdk",
+ base_url=base_url,
+ )
+ logger.debug("resolve_provider_client: bedrock anthropic (%s, %s)",
+ final_model, region)
+ else:
+ client = BedrockAuxiliaryClient(region, final_model)
+ logger.debug("resolve_provider_client: bedrock converse (%s, %s)",
+ final_model, region)
+
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
@@ -4090,12 +5091,20 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
if provider == "xai-oauth":
return resolve_provider_client("xai-oauth", model, async_mode)
# Other OAuth providers not directly supported
- logger.warning("resolve_provider_client: OAuth provider %s not "
- "directly supported, try 'auto'", provider)
+ if provider not in _LOGGED_UNSUPPORTED_OAUTH_KEYS:
+ _LOGGED_UNSUPPORTED_OAUTH_KEYS.add(provider)
+ logger.debug("resolve_provider_client: OAuth provider %s not "
+ "directly supported, try 'auto'", provider)
return None, None
- logger.warning("resolve_provider_client: unhandled auth_type %s for %s",
- pconfig.auth_type, provider)
+ # Demoted from logger.warning to debug; dedup keyed on (auth_type,
+ # provider) so the first occurrence surfaces (real schema-drift bug) but
+ # per-call retries stay silent.
+ _auth_dedup_key = (pconfig.auth_type, provider)
+ if _auth_dedup_key not in _LOGGED_UNHANDLED_AUTHTYPE_KEYS:
+ _LOGGED_UNHANDLED_AUTHTYPE_KEYS.add(_auth_dedup_key)
+ logger.debug("resolve_provider_client: unhandled auth_type %s for %s",
+ pconfig.auth_type, provider)
return None, None
@@ -4340,9 +5349,35 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[
main_provider,
)
else:
+ # Custom endpoints (``custom`` / ``custom:``) carry no
+ # built-in base_url/api_key — resolve_provider_client("custom")
+ # would return None ("no endpoint credentials found") and the
+ # whole chain would fall through to the aggregators, breaking
+ # vision for every user on a custom provider that has no
+ # separate ``auxiliary.vision`` block. Recover the live main
+ # endpoint that ``set_runtime_main()`` recorded for this turn so
+ # Step 1 can build a working client.
+ rpc_base_url = None
+ rpc_api_key = None
+ rpc_api_mode = resolved_api_mode
+ if main_provider == "custom" or main_provider.startswith("custom:"):
+ if _RUNTIME_MAIN_BASE_URL:
+ rpc_base_url = _RUNTIME_MAIN_BASE_URL
+ rpc_api_key = _RUNTIME_MAIN_API_KEY or None
+ rpc_api_mode = resolved_api_mode or _RUNTIME_MAIN_API_MODE or None
+ else:
+ # No live runtime recorded (non-gateway caller): fall
+ # back to resolving the configured custom endpoint.
+ custom_base, custom_key, custom_mode = _resolve_custom_runtime()
+ if custom_base:
+ rpc_base_url = custom_base
+ rpc_api_key = custom_key
+ rpc_api_mode = resolved_api_mode or custom_mode or None
rpc_client, rpc_model = resolve_provider_client(
main_provider, vision_model,
- api_mode=resolved_api_mode,
+ api_mode=rpc_api_mode,
+ explicit_base_url=rpc_base_url,
+ explicit_api_key=rpc_api_key,
is_vision=True)
if rpc_client is not None:
logger.info(
@@ -4428,9 +5463,14 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di
or_key = os.getenv("OPENROUTER_API_KEY")
# Use max_completion_tokens for direct OpenAI-compatible providers that reject
# max_tokens on newer GPT-4o/o-series/GPT-5-style models.
+ _custom_host = base_url_hostname(custom_base) or ""
if (not or_key
and _read_nous_auth() is None
- and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
+ and (
+ _custom_host == "api.openai.com"
+ or _custom_host == "api.githubcopilot.com"
+ or _custom_host.endswith(".githubcopilot.com")
+ )):
return {"max_completion_tokens": value}
# ...and for any caller serving a newer OpenAI-family model by name.
if model_forces_max_completion_tokens(model):
@@ -4471,6 +5511,7 @@ def _client_cache_key(
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
+ model: Optional[str] = None,
) -> tuple:
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
@@ -4479,7 +5520,17 @@ def _client_cache_key(
# old cache shape because the explicit provider/model tuple is sufficient.
task_key = (task or "") if provider == "auto" else ""
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
- return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
+ # The model MUST participate in the key. Two concurrent auxiliary calls to
+ # the SAME provider/base_url/key but DIFFERENT models (e.g. a MoA reference
+ # fan-out running opus + gpt-5.5 in parallel threads) would otherwise share
+ # one cache entry. On a cache MISS both build a client for the same key; the
+ # second's _store_cached_client sees the first as the "old" entry and CLOSES
+ # it — while the first call is still mid-request on it — yielding a spurious
+ # APIConnectionError that fails the sibling advisor (root cause of the run2
+ # double-advisor "Connection error" collapse). Keying on model gives each
+ # model its own client, so concurrent fan-out calls never cross-close.
+ model_key = model or ""
+ return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint, model_key)
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
@@ -4513,7 +5564,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
@@ -4535,6 +5586,7 @@ def _refresh_nous_auxiliary_client(
api_mode=api_mode,
main_runtime=main_runtime,
is_vision=is_vision,
+ model=final_model,
)
_store_cached_client(cache_key, client, final_model, bound_loop=current_loop)
return client, final_model
@@ -4711,6 +5763,7 @@ def _get_cached_client(
main_runtime=main_runtime,
is_vision=is_vision,
task=task,
+ model=model,
)
with _client_cache_lock:
if cache_key in _client_cache:
@@ -4807,9 +5860,10 @@ def _resolve_task_provider_model(
3. "auto" (full auto-detection chain)
Returns (provider, model, base_url, api_key, api_mode) where model may
- be None (use provider default). When base_url is set, provider is forced
- to "custom" and the task uses that direct endpoint. api_mode is one of
- "chat_completions", "codex_responses", or None (auto-detect).
+ be None (use provider default). A bare base_url is treated as custom, but
+ a first-class provider plus base_url keeps the provider identity so its
+ auth, transport, and request-shaping behavior still apply. api_mode is one
+ of "chat_completions", "codex_responses", or None (auto-detect).
"""
cfg_provider = None
cfg_model = None
@@ -4825,6 +5879,16 @@ def _resolve_task_provider_model(
cfg_api_key = str(task_config.get("api_key", "")).strip() or None
cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
+ # 'auto' is a sentinel meaning "inherit from main runtime / auto-detect", not
+ # a literal model id. Without this, a config of `auxiliary..model: auto`
+ # propagates the literal string "auto" to the wire, where the provider returns
+ # a 200 OK with an error-text body (e.g. "the model 'auto' does not exist"),
+ # which downstream consumers like ContextCompressor accept as the task output.
+ # The provider-side 'auto' is handled in _resolve_auto() via main_runtime
+ # fallback, so dropping cfg_model to None here lets that path do its job.
+ if cfg_model and cfg_model.lower() == "auto":
+ cfg_model = None
+
resolved_model = model or cfg_model
resolved_api_mode = cfg_api_mode
@@ -4842,11 +5906,47 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str])
return prov, existing_base
return "custom", existing_base or target_base
+ def _preserve_provider_with_base_url(prov: Optional[str]) -> bool:
+ normalized = str(prov or "").strip().lower()
+ if normalized in {"", "auto", "custom"} or normalized.startswith("custom:"):
+ return False
+ try:
+ from hermes_cli.providers import get_provider
+
+ return get_provider(normalized) is not None
+ except Exception:
+ # Keep the high-risk provider-backed routes safe even if provider
+ # catalog loading is unavailable during early import/test paths.
+ return normalized in {
+ "anthropic",
+ "copilot",
+ "copilot-acp",
+ "minimax-oauth",
+ "nous",
+ "openai-codex",
+ "qwen-oauth",
+ "xai-oauth",
+ }
+
if provider:
provider, base_url = _expand_direct_api_alias(provider, base_url)
if cfg_provider:
cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url)
+ # An explicit provider arg without an explicit base_url must not bypass
+ # the task's configured endpoint: adopt auxiliary..base_url/api_key
+ # when the config targets the same provider (or names none), so the
+ # early `if provider:` return below carries the configured endpoint
+ # instead of falling through to main-runtime resolution (#58515).
+ # An explicit "auto" is excluded — it means "inherit / auto-detect" and
+ # must keep flowing through the existing auto-resolution chain.
+ if provider and provider != "auto" and not base_url and cfg_base_url and cfg_provider in (None, provider):
+ base_url = cfg_base_url
+ if not api_key:
+ api_key = cfg_api_key
+
+ if base_url and _preserve_provider_with_base_url(provider):
+ return provider, resolved_model, base_url, api_key, resolved_api_mode
if base_url:
return "custom", resolved_model, base_url, api_key, resolved_api_mode
if provider:
@@ -4872,6 +5972,17 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str])
_DEFAULT_AUX_TIMEOUT = 30.0
+# Compression summarises large conversation histories; a reasoning auxiliary
+# model (e.g. Codex / GPT-5.5) can legitimately take longer than the default
+# ``auxiliary.compression.timeout`` (120 s), causing the stream to time out and
+# the compressor to fall back to the deterministic context marker (#54915).
+# This is a bounded *floor* applied only to config-derived compression timeouts
+# — it does not affect other auxiliary tasks and does not override an explicit
+# per-call ``timeout=``. A floor is harmless for fast compression models
+# (they finish before the deadline) and is a minimum, so a higher config value
+# is kept unchanged.
+_COMPRESSION_TIMEOUT_FLOOR_SECONDS = 300.0
+
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
"""Return the config dict for auxiliary., or {} when unavailable.
@@ -4931,6 +6042,23 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
return default
+def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
+ """Resolve the effective timeout for an auxiliary LLM call.
+
+ Uses the caller-provided ``timeout`` when given; otherwise reads
+ ``auxiliary.{task}.timeout`` from config via :func:`_get_task_timeout`.
+ For the ``compression`` task only, applies a bounded floor so a reasoning
+ model summarising a large context is not cut off by the default timeout
+ (#54915). The floor is intentionally skipped when the caller passes an
+ explicit ``timeout=`` — explicit per-call deadlines are always honoured —
+ and it is a minimum (``max``), so a config value already above it is kept.
+ """
+ effective = timeout if timeout is not None else _get_task_timeout(task)
+ if timeout is None and task == "compression":
+ effective = max(effective, _COMPRESSION_TIMEOUT_FLOOR_SECONDS)
+ return effective
+
+
def _get_task_extra_body(task: str) -> Dict[str, Any]:
"""Read auxiliary..extra_body and return a shallow copy when valid."""
task_config = _get_auxiliary_task_config(task)
@@ -5096,10 +6224,24 @@ def _build_call_kwargs(
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
# there.
+ #
+ # NVIDIA NIM (integrate.api.nvidia.com and local NIM endpoints) is a
+ # second exception: some models—notably minimaxai/minimax-m3—return HTTP
+ # 200 with an empty choices[] payload when max_tokens is omitted. The main
+ # NVIDIA chat path already sends an output cap via the provider profile;
+ # preserve it on the auxiliary path too.
_effective_base = base_url or (
_current_custom_base_url() if provider == "custom" else ""
)
- if _is_anthropic_compat_endpoint(provider, _effective_base):
+ _provider_norm = str(provider or "").strip().lower()
+ _is_nvidia_nim = (
+ _provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"}
+ or base_url_host_matches(_effective_base, "integrate.api.nvidia.com")
+ )
+ if (
+ _is_anthropic_compat_endpoint(provider, _effective_base)
+ or _is_nvidia_nim
+ ):
kwargs["max_tokens"] = max_tokens
if tools:
@@ -5154,6 +6296,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 +6310,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,
*,
@@ -5174,11 +6377,14 @@ def call_llm(
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
- temperature: float = None,
+ temperature: Optional[float] = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
extra_body: dict = None,
+ api_mode: str = None,
+ stream: bool = False,
+ stream_options: dict = None,
) -> Any:
"""Centralized synchronous LLM call.
@@ -5191,21 +6397,32 @@ def call_llm(
Reads provider:model from config/env. Ignored if provider is set.
provider: Explicit provider override.
model: Explicit model override.
+ api_mode: Explicit API mode override (e.g. "codex_responses",
+ "anthropic_messages"). Takes precedence over task config.
messages: Chat messages list.
temperature: Sampling temperature (None = provider default).
max_tokens: Max output tokens (handles max_tokens vs max_completion_tokens).
tools: Tool definitions (for function calling).
timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config).
extra_body: Additional request body fields.
+ stream: When True, return the raw SDK streaming iterator instead of a
+ validated complete response. The caller is responsible for consuming
+ chunks (and for any fallback). Used by the MoA aggregator so its
+ output can stream to the user.
+ stream_options: Passed through to the request when stream is True
+ (e.g. {"include_usage": True}).
Returns:
- Response object with .choices[0].message.content
+ Response object with .choices[0].message.content, OR — when stream=True —
+ the raw streaming iterator from client.chat.completions.create().
Raises:
RuntimeError: If no provider is configured.
"""
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
task, provider, model, base_url, api_key)
+ if api_mode:
+ resolved_api_mode = api_mode
effective_extra_body = _get_task_extra_body(task)
effective_extra_body.update(extra_body or {})
@@ -5244,21 +6461,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)
@@ -5267,7 +6493,7 @@ def call_llm(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
f"Run: hermes setup")
- effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
+ effective_timeout = _effective_aux_timeout(task, timeout)
# Log what we're about to do — makes auxiliary operations visible
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
@@ -5290,31 +6516,80 @@ def call_llm(
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
+ # Streaming path: return the raw SDK Stream iterator directly. This is used by
+ # the MoA aggregator so its tokens stream to the user. It deliberately skips
+ # _validate_llm_response and the temperature/max_tokens/payment fallback chain
+ # below — those all assume a complete response object, whereas a stream is
+ # consumed chunk-by-chunk by the caller. The caller (the agent's streaming
+ # consumer) owns chunk reassembly, stale-stream detection, and falling back to
+ # a non-streaming call on error. stream_options is best-effort: providers that
+ # reject it surface an error the caller's fallback already handles.
+ if stream:
+ kwargs["stream"] = True
+ if stream_options:
+ kwargs["stream_options"] = stream_options
+ return client.chat.completions.create(**kwargs)
+
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
try:
- # Retry ONCE on the same provider for a one-off transient transport
- # blip (streaming-close / incomplete chunked read / 5xx / 408) before
- # the except-chain below escalates to provider/model fallback. A
- # single dropped connection shouldn't abandon an otherwise-healthy
- # provider. A second failure (or any non-transient error) falls
- # through to ``first_err`` and the existing fallback handling
- # unchanged. This is the unified home for the transient retry that
- # every auxiliary task (compression, memory flush, title-gen,
- # session-search, vision) shares. (PR #16587)
+ # Retry on the same provider for a transient transport blip
+ # (connection reset / streaming-close / incomplete chunked read / 5xx /
+ # 408) before the except-chain below escalates to provider/model
+ # fallback. A dropped connection shouldn't abandon an otherwise-healthy
+ # provider — this especially matters for pinned auxiliary calls like MoA
+ # reference advisors, where "fallback to another provider" is not a
+ # meaningful recovery (the advisor is a specific model), so a transient
+ # blip that isn't retried simply loses that advisor for the turn (root
+ # of the run2 double-advisor "Connection error" collapse — a genuine
+ # upstream blip hitting both parallel advisors at once).
+ #
+ # Attempts are bounded and use exponential backoff. Count is configurable
+ # via auxiliary.transient_retries (default 2 retries → 3 total attempts);
+ # a second/third failure or any non-transient error falls through to
+ # ``first_err`` and the existing fallback handling unchanged. Unified home
+ # for the transient retry every auxiliary task shares. (PR #16587)
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
- logger.info(
- "Auxiliary %s: transient transport error; retrying once on "
- "the same provider before fallback: %s",
- task or "call", transient_err,
- )
- return _validate_llm_response(
- client.chat.completions.create(**kwargs), task)
+ # Compression is on the critical preflight path: a user cannot
+ # continue or resume an oversized session until it compacts. A
+ # same-provider retry on a timeout means another full ``timeout``-
+ # long wall-clock block before the except-chain below can fall
+ # back — doubling the user-visible stall (issue #54465). Skip the
+ # same-provider retry for compression on a full-budget timeout and
+ # fall straight through to provider/model fallback; fast blips (a
+ # streaming-close or a 5xx) still retry, since those are cheap.
+ if task == "compression" and _is_timeout_error(transient_err):
+ logger.info(
+ "Auxiliary compression: timeout on the critical path; "
+ "skipping same-provider retry and falling back: %s",
+ transient_err,
+ )
+ raise
+ _max_transient_retries = _transient_retry_count()
+ _last_transient = transient_err
+ for _attempt in range(1, _max_transient_retries + 1):
+ _backoff = min(_TRANSIENT_RETRY_BACKOFF_BASE * (2.0 ** (_attempt - 1)), 8.0)
+ logger.info(
+ "Auxiliary %s: transient transport error (attempt %d/%d); "
+ "retrying same provider after %.1fs before fallback: %s",
+ task or "call", _attempt, _max_transient_retries, _backoff,
+ _last_transient,
+ )
+ time.sleep(_backoff)
+ try:
+ return _validate_llm_response(
+ client.chat.completions.create(**kwargs), task)
+ except Exception as retry_transient:
+ if not _is_transient_transport_error(retry_transient):
+ raise
+ _last_transient = retry_transient
+ # Retries exhausted — fall through to first_err fallback handling.
+ raise _last_transient
except Exception as first_err:
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
retry_kwargs = dict(kwargs)
@@ -5457,18 +6732,24 @@ def call_llm(
refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry ───────────────────────────────────────
+ auth_refresh_provider = _auth_refresh_provider_for_route(
+ resolved_provider, _base_info)
if (_is_auth_error(first_err)
- and resolved_provider not in {"auto", "", None}
+ and auth_refresh_provider not in {"auto", "", None}
and not client_is_nous):
- if _refresh_provider_credentials(resolved_provider):
+ if _refresh_provider_credentials(auth_refresh_provider):
+ if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
+ # The stale client is cached under the route label
+ # (e.g. "auto"), not the concrete backend we refreshed.
+ _evict_cached_clients(resolved_provider)
logger.info(
"Auxiliary %s: refreshed %s credentials after auth error, retrying",
- task or "call", resolved_provider,
+ task or "call", auth_refresh_provider,
)
return _retry_same_provider_sync(
task=task,
- resolved_provider=resolved_provider,
- resolved_model=resolved_model,
+ resolved_provider=auth_refresh_provider,
+ resolved_model=resolved_model or final_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,
@@ -5553,10 +6834,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 +6859,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 +6887,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",
@@ -5608,14 +6919,28 @@ def call_llm(
resolved_provider, task, reason=reason)
if fb_client is not None:
- fb_kwargs = _build_call_kwargs(
- fb_label, fb_model, messages,
+ fb_resp = _call_fallback_candidate_sync(
+ fb_client, fb_model, fb_label,
+ task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
- tools=tools, timeout=effective_timeout,
- extra_body=effective_extra_body,
- base_url=str(getattr(fb_client, "base_url", "") or ""))
- return _validate_llm_response(
- fb_client.chat.completions.create(**fb_kwargs), task)
+ tools=tools, effective_timeout=effective_timeout,
+ effective_extra_body=effective_extra_body)
+ if fb_resp is not None:
+ return fb_resp
+ # The candidate had a stale/unrefreshable credential and was
+ # quarantined — walk the discovery chain once more; unhealthy
+ # entries are skipped so the next viable candidate serves.
+ fb_client, fb_model, fb_label = _try_payment_fallback(
+ resolved_provider, task, reason="stale fallback credential")
+ if fb_client is not None:
+ fb_resp = _call_fallback_candidate_sync(
+ fb_client, fb_model, fb_label,
+ task=task, messages=messages,
+ temperature=temperature, max_tokens=max_tokens,
+ tools=tools, effective_timeout=effective_timeout,
+ effective_extra_body=effective_extra_body)
+ if fb_resp is not None:
+ return fb_resp
# All fallback layers exhausted — emit a single user-visible
# warning so the operator knows aux task is about to fail.
# (#26882) The error itself is re-raised below.
@@ -5703,7 +7028,7 @@ async def async_call_llm(
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
- temperature: float = None,
+ temperature: Optional[float] = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
@@ -5754,12 +7079,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)
@@ -5768,7 +7102,7 @@ async def async_call_llm(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
f"Run: hermes setup")
- effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
+ effective_timeout = _effective_aux_timeout(task, timeout)
# Pass the client's actual base_url (not just resolved_base_url) so
# endpoint-specific temperature overrides can distinguish
@@ -5794,6 +7128,16 @@ async def async_call_llm(
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
+ # See call_llm(): compression is on the critical preflight path,
+ # so skip the same-provider retry on a full-budget timeout and
+ # fall straight through to fallback (issue #54465).
+ if task == "compression" and _is_timeout_error(transient_err):
+ logger.info(
+ "Auxiliary compression (async): timeout on the critical "
+ "path; skipping same-provider retry and falling back: %s",
+ transient_err,
+ )
+ raise
logger.info(
"Auxiliary %s (async): transient transport error; retrying "
"once on the same provider before fallback: %s",
@@ -5936,18 +7280,24 @@ async def async_call_llm(
await refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
+ auth_refresh_provider = _auth_refresh_provider_for_route(
+ resolved_provider, _client_base)
if (_is_auth_error(first_err)
- and resolved_provider not in {"auto", "", None}
+ and auth_refresh_provider not in {"auto", "", None}
and not client_is_nous):
- if _refresh_provider_credentials(resolved_provider):
+ if _refresh_provider_credentials(auth_refresh_provider):
+ if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
+ # The stale client is cached under the route label
+ # (e.g. "auto"), not the concrete backend we refreshed.
+ _evict_cached_clients(resolved_provider)
logger.info(
"Auxiliary %s (async): refreshed %s credentials after auth error, retrying",
- task or "call", resolved_provider,
+ task or "call", auth_refresh_provider,
)
return await _retry_same_provider_async(
task=task,
- resolved_provider=resolved_provider,
- resolved_model=resolved_model,
+ resolved_provider=auth_refresh_provider,
+ resolved_model=resolved_model or final_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,
@@ -6005,24 +7355,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",
@@ -6051,20 +7424,34 @@ async def async_call_llm(
resolved_provider, task, reason=reason)
if fb_client is not None:
- fb_kwargs = _build_call_kwargs(
- fb_label, fb_model, messages,
- temperature=temperature, max_tokens=max_tokens,
- tools=tools, timeout=effective_timeout,
- extra_body=effective_extra_body,
- base_url=str(getattr(fb_client, "base_url", "") or ""))
# Convert sync fallback client to async
async_fb, async_fb_model = _to_async_client(
fb_client, fb_model or "", is_vision=(task == "vision")
)
- if async_fb_model and async_fb_model != fb_kwargs.get("model"):
- fb_kwargs["model"] = async_fb_model
- return _validate_llm_response(
- await async_fb.chat.completions.create(**fb_kwargs), task)
+ fb_resp = await _call_fallback_candidate_async(
+ async_fb, async_fb_model or fb_model, fb_label,
+ task=task, messages=messages,
+ temperature=temperature, max_tokens=max_tokens,
+ tools=tools, effective_timeout=effective_timeout,
+ effective_extra_body=effective_extra_body)
+ if fb_resp is not None:
+ return fb_resp
+ # Stale/unrefreshable candidate credential — quarantined; walk
+ # the discovery chain once more (unhealthy entries skipped).
+ fb_client, fb_model, fb_label = _try_payment_fallback(
+ resolved_provider, task, reason="stale fallback credential")
+ if fb_client is not None:
+ async_fb, async_fb_model = _to_async_client(
+ fb_client, fb_model or "", is_vision=(task == "vision")
+ )
+ fb_resp = await _call_fallback_candidate_async(
+ async_fb, async_fb_model or fb_model, fb_label,
+ task=task, messages=messages,
+ temperature=temperature, max_tokens=max_tokens,
+ tools=tools, effective_timeout=effective_timeout,
+ effective_extra_body=effective_extra_body)
+ if fb_resp is not None:
+ return fb_resp
# All fallback layers exhausted — warn before re-raising. (#26882)
logger.warning(
"Auxiliary %s (async): %s on %s and all fallbacks exhausted "
diff --git a/agent/background_review.py b/agent/background_review.py
index 2c9703ba68be..3f4e5efcd376 100644
--- a/agent/background_review.py
+++ b/agent/background_review.py
@@ -18,15 +18,141 @@
from __future__ import annotations
-import contextlib
import json
import logging
import os
from typing import Any, Dict, List, Optional
+from agent.thread_scoped_output import thread_scoped_silence
+
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;
@@ -300,6 +426,7 @@ def summarize_background_review_actions(
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
+ "operations": args.get("operations") or [],
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
@@ -322,10 +449,21 @@ def summarize_background_review_actions(
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
continue
+ # ``data`` may not be a dict — some memory/skill tool responses in
+ # older codepaths or wrapper MCP servers return a top-level JSON
+ # list (e.g. ``[{"success": true, ...}]``) or a scalar. The original
+ # isinstance check below silently skips non-dict payloads, which
+ # is correct, but ``data.get("_change")`` further down can still
+ # hand back a list and break ``change.get("description", "")``.
+ # Defensively normalize everything through a dict-typed alias so
+ # the rest of the function can stay terse without per-call
+ # ``isinstance`` guards (#59437).
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
- detail = call_details.get(tcid, {})
+ detail = call_details.get(tcid) or {}
+ if not isinstance(detail, dict):
+ detail = {}
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
@@ -353,11 +491,30 @@ def summarize_background_review_actions(
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
+ # ``operations`` may be anything callable put into the JSON
+ # arguments. Anything non-iterable that isn't a list[str]
+ # of dicts becomes unusable here, so coerce defensively.
+ ops_raw = detail.get("operations")
+ operations: list = (
+ ops_raw if isinstance(ops_raw, list) else []
+ )
max_preview = 120
if is_skill:
- change = data.get("_change", {})
- old_string = change.get("old", "") or detail.get("old_string", "")
- new_string = change.get("new", "") or detail.get("new_string", "")
+ # ``_change`` is a free-form dict the skill tool leaves in
+ # the response. Older / wrapper MCP backends return it
+ # as a list, an int, or a JSON-shaped scalar — normalize
+ # to a dict so the .get() calls downstream don't
+ # AttributeError (#59437).
+ change_raw = data.get("_change")
+ change: dict = (
+ change_raw if isinstance(change_raw, dict) else {}
+ )
+ old_string = (
+ change.get("old", "") or detail.get("old_string", "")
+ )
+ new_string = (
+ change.get("new", "") or detail.get("new_string", "")
+ )
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
@@ -376,6 +533,27 @@ def summarize_background_review_actions(
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
+ elif operations:
+ for op in operations:
+ # Each element must be a dict-of-fields; some
+ # legacy codepaths serialize the entry as a bare
+ # string and the message dict doesn't exist. Skip
+ # non-dict items defensively — they have no
+ # actionable fields anyway (#59437).
+ if not isinstance(op, dict):
+ continue
+ op_act = op.get("action", "")
+ op_content = (op.get("content") or "")
+ op_old = (op.get("old_text") or "")
+ if op_act == "add" and op_content:
+ preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
+ actions.append(f"{label} ➕ {preview}")
+ elif op_act == "replace" and op_content:
+ preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
+ actions.append(f"{label} ✏️ {preview}")
+ elif op_act == "remove" and op_old:
+ preview = op_old[:60] + ("…" if len(op_old) > 60 else "")
+ actions.append(f"{label} ➖ {preview}")
elif action == "add" and content:
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
actions.append(f"{label} ➕ {preview}")
@@ -391,6 +569,7 @@ def summarize_background_review_actions(
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
+ or "applied" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):
@@ -459,9 +638,15 @@ def _bg_review_auto_deny(command, description, **kwargs):
review_agent = None
review_messages: List[Dict] = []
try:
- with open(os.devnull, "w", encoding="utf-8") as _devnull, \
- contextlib.redirect_stdout(_devnull), \
- contextlib.redirect_stderr(_devnull):
+ # Silence stdout/stderr for THIS worker thread only. A process-global
+ # ``contextlib.redirect_stdout(devnull)`` here would also blank
+ # ``sys.stdout``/``sys.stderr`` for every other thread — including a
+ # gateway event-loop thread driving a Telegram long-poll — for the full
+ # duration of the review (tens of seconds), swallowing their console
+ # output (#55769 / #55925). ``thread_scoped_silence`` routes only this
+ # thread's writes to devnull and leaves all other threads on the real
+ # streams.
+ with thread_scoped_silence():
# Inherit the parent agent's live runtime (provider, model,
# base_url, api_key, api_mode) so the fork uses the exact
# same credentials the main turn is using. Without this,
@@ -470,18 +655,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
@@ -501,14 +681,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),
@@ -517,11 +697,32 @@ def _bg_review_auto_deny(command, description, **kwargs):
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"
+ # The review fork pins the parent's cached system prompt and keeps
+ # ``tools[]`` byte-identical to the parent so its outbound request
+ # hits the same provider cache prefix (see the toolset-parity note
+ # above). The between-turns MCP refresh in build_turn_context would
+ # add late-connecting MCP tools to this fork and break that parity,
+ # so opt the review fork out of it.
+ review_agent._skip_mcp_refresh = True
review_agent._memory_store = agent._memory_store
review_agent._memory_enabled = agent._memory_enabled
review_agent._user_profile_enabled = agent._user_profile_enabled
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
+ # PERSISTENCE ISOLATION (the curator-takeover root cause): the fork
+ # shares the parent's session_id (set below, for prompt-cache
+ # warmth), so without this it would write its harness turn ("Review
+ # the conversation above and update the skill library…") + its own
+ # response straight into the user's REAL session in state.db. On the
+ # user's next live turn the agent re-reads that injected user message
+ # as a standing instruction and "becomes" the curator, refusing the
+ # actual task. _persist_disabled hard-stops every DB write/lazy-open
+ # path (_flush_messages_to_session_db, _ensure_db_session,
+ # _get_session_db_for_recall); the review writes only to the skill
+ # and memory stores via its tools, which is all it needs.
+ review_agent._persist_disabled = True
+ review_agent._session_db = None
+ review_agent._session_json_enabled = False
# Suppress all status/warning emits from the fork so the
# user only sees the final successful-action summary.
# Without this, mid-review "Iteration budget exhausted",
@@ -540,16 +741,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
@@ -568,10 +781,17 @@ def _bg_review_auto_deny(command, description, **kwargs):
clear_thread_tool_whitelist,
)
+ # Gate the built-in memory tool on the profile's memory_enabled flag.
+ # Hardcoding ["memory", "skills"] granted the review LLM the MEMORY.md
+ # read/write tool even when a profile set memory_enabled: false,
+ # contaminating a memory-disabled profile (#54937 layer 2).
+ review_toolsets = ["skills"]
+ if review_agent._memory_enabled or review_agent._user_profile_enabled:
+ review_toolsets.insert(0, "memory")
review_whitelist = {
t["function"]["name"]
for t in get_tool_definitions(
- enabled_toolsets=["memory", "skills"],
+ enabled_toolsets=review_toolsets,
quiet_mode=True,
)
}
@@ -583,6 +803,20 @@ def _bg_review_auto_deny(command, description, **kwargs):
),
)
try:
+ from tools.skill_manager_tool import _reset_background_review_read_marks
+
+ _reset_background_review_read_marks()
+ except Exception:
+ pass
+
+ 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
@@ -590,7 +824,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()
@@ -620,11 +854,29 @@ def _bg_review_auto_deny(command, description, **kwargs):
# the review agent inherits that history and would otherwise
# re-surface stale "created"/"updated" messages from the prior
# conversation as if they just happened (issue #14944).
- actions = summarize_background_review_actions(
- review_messages,
- messages_snapshot,
- notification_mode=getattr(agent, "memory_notifications", "on"),
- )
+ #
+ # Wrapped in try/except: a buggy/legacy tool response shape
+ # (e.g. ``_change`` returned as a list instead of a dict, #59437)
+ # must NOT take down the whole review with an AttributeError,
+ # since the caller's outer except logs only "Background
+ # memory/skill review failed" and discards every successful
+ # action the fork DID complete before the crash. Coerce an
+ # exception into an empty actions list so the partial valid
+ # actions from earlier in the messages are returned instead.
+ try:
+ actions = summarize_background_review_actions(
+ review_messages,
+ messages_snapshot,
+ notification_mode=getattr(agent, "memory_notifications", "on"),
+ )
+ except Exception as e:
+ logger.warning(
+ "summarize_background_review_actions returned partial results "
+ "after exception (treating as empty); suppressing AttributeError "
+ "that previously aborted the entire review (#59437): %s",
+ e,
+ )
+ actions = []
if actions:
summary = " · ".join(dict.fromkeys(actions))
@@ -644,16 +896,14 @@ def _bg_review_auto_deny(command, description, **kwargs):
logger.warning("Background memory/skill review failed: %s", e)
agent._emit_auxiliary_failure("background review", e)
finally:
- # Safety-net cleanup for the exception path. Normal
- # completion already shut down inside redirect_stdout above.
- # Re-open devnull here so any teardown output (Honcho flush,
- # Hindsight sync, background thread joins) stays silent even
- # on the exception path where redirect_stdout already exited.
+ # Safety-net cleanup for the exception path. Normal completion already
+ # shut down inside the thread-scoped silence above. Re-enter the
+ # thread-scoped silence here so teardown output (Honcho flush, Hindsight
+ # sync, background thread joins) stays quiet even on the exception path,
+ # without blanking other threads' streams.
if review_agent is not None:
try:
- with open(os.devnull, "w", encoding="utf-8") as _fn, \
- contextlib.redirect_stdout(_fn), \
- contextlib.redirect_stderr(_fn):
+ with thread_scoped_silence():
try:
review_agent.shutdown_memory_provider()
except Exception:
diff --git a/agent/billing_view.py b/agent/billing_view.py
new file mode 100644
index 000000000000..ef97c8d0d645
--- /dev/null
+++ b/agent/billing_view.py
@@ -0,0 +1,295 @@
+"""Surface-agnostic core for the Phase 2b terminal-billing screens.
+
+One fetch/parse per concern, consumed identically by the CLI handler
+(``cli.py::_show_billing``), the TUI JSON-RPC methods
+(``tui_gateway/server.py``), and any other surface. Mirrors the proven
+``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
+into a frozen dataclass; **fail open** — when not logged in or the portal is
+unreachable, return a struct with ``logged_in=False`` and let the surface degrade
+gracefully (never crash).
+
+Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
+We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
+"""
+
+from __future__ import annotations
+
+import logging
+import uuid
+from dataclasses import dataclass, field
+from decimal import Decimal, InvalidOperation
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Decimal money helpers
+# =============================================================================
+
+
+def parse_money(value: Any) -> Optional[Decimal]:
+ """Parse a server money value (decimal string) into :class:`Decimal`.
+
+ Returns None for missing/invalid input. Never raises. Accepts str/int (and,
+ defensively, float — though the server always sends strings).
+ """
+ if value is None:
+ return None
+ try:
+ # Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
+ return Decimal(str(value).strip())
+ except (InvalidOperation, ValueError, TypeError):
+ return None
+
+
+def format_money(value: Optional[Decimal]) -> str:
+ """Format a Decimal as ``$X`` / ``$X.YY`` for display.
+
+ Whole dollars show no decimals; any fractional amount shows exactly 2dp:
+ ``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
+ ``Decimal("0.01")`` → ``"$0.01"``.
+ """
+ if value is None:
+ return "—"
+ if value == value.to_integral_value():
+ # Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
+ return f"${format(value.to_integral_value(), 'f')}"
+ # Fractional — always show 2dp.
+ return f"${format(value.quantize(Decimal('0.01')), 'f')}"
+
+
+# =============================================================================
+# Parsed sub-structures
+# =============================================================================
+
+
+@dataclass(frozen=True)
+class CardInfo:
+ brand: str
+ last4: str
+
+ @property
+ def masked(self) -> str:
+ return f"{self.brand} ····{self.last4}"
+
+
+@dataclass(frozen=True)
+class MonthlyCap:
+ limit_usd: Optional[Decimal] = None
+ spent_this_month_usd: Optional[Decimal] = None
+ is_default_ceiling: bool = False
+
+
+@dataclass(frozen=True)
+class AutoReload:
+ enabled: bool = False
+ threshold_usd: Optional[Decimal] = None
+ reload_to_usd: Optional[Decimal] = None
+
+
+@dataclass(frozen=True)
+class BillingState:
+ """Parsed ``GET /api/billing/state`` — the overview screen's data.
+
+ Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
+ portal is unreachable.
+ """
+
+ logged_in: bool
+ org_id: Optional[str] = None
+ org_slug: Optional[str] = None
+ org_name: Optional[str] = None
+ role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
+ balance_usd: Optional[Decimal] = None
+ cli_billing_enabled: bool = False
+ charge_presets: tuple[Decimal, ...] = ()
+ min_usd: Optional[Decimal] = None
+ max_usd: Optional[Decimal] = None
+ card: Optional[CardInfo] = None
+ monthly_cap: Optional[MonthlyCap] = None
+ auto_reload: Optional[AutoReload] = None
+ portal_url: Optional[str] = None
+ # When the fetch failed (vs cleanly not-logged-in), the message for the surface.
+ error: Optional[str] = None
+
+ @property
+ def is_admin(self) -> bool:
+ """True for OWNER/ADMIN — the roles that can manage billing."""
+ return (self.role or "").upper() in ("OWNER", "ADMIN")
+
+ @property
+ def can_charge(self) -> bool:
+ """True when the UI should offer charge/auto-reload actions.
+
+ Admin role AND the per-org kill-switch on. (The server still enforces;
+ this is just for graying out actions the user can't take.)
+ """
+ return self.is_admin and self.cli_billing_enabled
+
+
+def _parse_card(raw: Any) -> Optional[CardInfo]:
+ if not isinstance(raw, dict):
+ return None
+ brand = raw.get("brand")
+ last4 = raw.get("last4")
+ if isinstance(brand, str) and isinstance(last4, str):
+ return CardInfo(brand=brand, last4=last4)
+ return None
+
+
+def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
+ if not isinstance(raw, dict):
+ return None
+ return MonthlyCap(
+ limit_usd=parse_money(raw.get("limitUsd")),
+ spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
+ is_default_ceiling=bool(raw.get("isDefaultCeiling")),
+ )
+
+
+def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
+ if not isinstance(raw, dict):
+ return None
+ return AutoReload(
+ enabled=bool(raw.get("enabled")),
+ threshold_usd=parse_money(raw.get("thresholdUsd")),
+ reload_to_usd=parse_money(raw.get("reloadToUsd")),
+ )
+
+
+def billing_state_from_payload(
+ payload: dict[str, Any], *, portal_url: Optional[str] = None
+) -> BillingState:
+ """Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
+ raw_org = payload.get("org")
+ org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
+ raw_bounds = payload.get("bounds")
+ bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
+
+ presets: list[Decimal] = []
+ for item in payload.get("chargePresets") or ():
+ parsed = parse_money(item)
+ if parsed is not None:
+ presets.append(parsed)
+
+ return BillingState(
+ logged_in=True,
+ org_id=org.get("id"),
+ org_slug=org.get("slug"),
+ org_name=org.get("name"),
+ role=org.get("role"),
+ balance_usd=parse_money(payload.get("balanceUsd")),
+ cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
+ charge_presets=tuple(presets),
+ min_usd=parse_money(bounds.get("minUsd")),
+ max_usd=parse_money(bounds.get("maxUsd")),
+ card=_parse_card(payload.get("card")),
+ monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
+ auto_reload=_parse_auto_reload(payload.get("autoReload")),
+ portal_url=portal_url,
+ )
+
+
+# =============================================================================
+# Fail-open builders (the surface front doors)
+# =============================================================================
+
+
+def build_billing_state(*, timeout: float = 15.0) -> BillingState:
+ """Fetch + parse ``/api/billing/state``. Fail-open.
+
+ Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
+ failure, returns ``logged_in=False`` with ``error`` set so the surface can show
+ a clear message rather than crashing.
+ """
+ try:
+ from hermes_cli.nous_billing import (
+ BillingAuthError,
+ BillingError,
+ _absolutize_portal_url,
+ get_billing_state,
+ resolve_portal_base_url,
+ )
+ except Exception:
+ return BillingState(logged_in=False, error="billing client unavailable")
+
+ try:
+ payload = get_billing_state(timeout=timeout)
+ except BillingAuthError:
+ return BillingState(logged_in=False)
+ except BillingError as exc:
+ logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
+ return BillingState(logged_in=False, error=str(exc))
+ except Exception:
+ logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
+ return BillingState(logged_in=False, error="could not load billing state")
+
+ # Prefer a server-supplied portalUrl if present (resolved to absolute in case
+ # it's relative); else build the standard one.
+ raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
+ portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
+ if not portal_url:
+ try:
+ portal_url = _fallback_portal_url(resolve_portal_base_url())
+ except Exception:
+ portal_url = None
+
+ return billing_state_from_payload(payload, portal_url=portal_url)
+
+
+def _fallback_portal_url(base: str) -> str:
+ """Standard billing deep-link when the server omits ``portalUrl``."""
+ return f"{base.rstrip('/')}/billing?topup=open"
+
+
+# =============================================================================
+# Idempotency
+# =============================================================================
+
+
+def new_idempotency_key() -> str:
+ """Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
+
+ The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
+ per confirmed purchase and reuse it across retries so a double-submit collapses
+ to a single charge. Never reuse a key across different amounts (the server
+ returns 409 idempotency_conflict).
+ """
+ return str(uuid.uuid4())
+
+
+# =============================================================================
+# Amount validation (Screen 3 custom input)
+# =============================================================================
+
+
+@dataclass(frozen=True)
+class AmountValidation:
+ ok: bool
+ amount: Optional[Decimal] = None
+ error: Optional[str] = None
+
+
+def validate_charge_amount(
+ raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
+) -> AmountValidation:
+ """Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
+
+ Mirrors the server's accept/reject so the UI can give instant feedback rather
+ than round-tripping a sure-to-fail charge. The server is still authoritative.
+ """
+ cleaned = (raw or "").strip().lstrip("$").strip()
+ amount = parse_money(cleaned)
+ if amount is None:
+ return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
+ if amount <= 0:
+ return AmountValidation(ok=False, error="Amount must be greater than $0")
+ # multipleOf 0.01 — reject sub-cent precision.
+ if amount != amount.quantize(Decimal("0.01")):
+ return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
+ if min_usd is not None and amount < min_usd:
+ return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
+ if max_usd is not None and amount > max_usd:
+ return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
+ return AmountValidation(ok=True, amount=amount)
diff --git a/agent/bounded_response.py b/agent/bounded_response.py
new file mode 100644
index 000000000000..e5177bc8a2b7
--- /dev/null
+++ b/agent/bounded_response.py
@@ -0,0 +1,148 @@
+"""Bounded reads of HTTP error response bodies.
+
+When a provider returns a non-OK status on a *streaming* request, Hermes reads
+the response body to build a useful diagnostic error. A bare ``response.read()``
+on a streaming httpx response is unbounded in two dangerous ways:
+
+1. A server can declare (or stream) an arbitrarily large body, so the read can
+ balloon memory.
+2. A server can open the body and then stall forever (no ``Content-Length``,
+ no further bytes), so the read hangs the agent indefinitely.
+
+Both are realistic against a misbehaving proxy, a hijacked endpoint, or a
+provider having a bad day. The diagnostic body is only ever shown to the user
+truncated to a few hundred characters, so reading megabytes — or blocking
+forever — buys nothing.
+
+``read_streaming_error_body`` bounds the read to a byte cap and enforces a
+hard wall-clock deadline, returning the decoded text snippet. Callers pass the
+returned text into their existing error builders instead of touching
+``response.text`` (which would be unbounded / would raise after a partial
+stream read).
+
+A subtlety the implementation must respect: ``httpx``'s ``iter_bytes()`` blocks
+*inside* the C/socket read while waiting for the next chunk. A wall-clock check
+placed only between yielded chunks cannot interrupt a server that opens the
+body and then stalls mid-chunk — control never returns to Python until httpx's
+own (often 30s+) read timeout fires. To guarantee a bounded stop regardless of
+socket behavior, the read runs on a daemon worker thread and the caller waits
+on it with a hard deadline; on timeout we close the response (which unblocks /
+cancels the read) and return whatever partial bytes were collected.
+
+Ported and adapted from openclaw/openclaw#95108 ("bound Anthropic error
+streams"), generalized to cover Hermes's three streaming error-body sites
+(native Gemini, Gemini Cloud Code, Antigravity Cloud Code).
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from typing import List, Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# Defaults chosen to comfortably hold any real provider error envelope (Google
+# RPC error JSON, Anthropic error JSON) while rejecting pathological bodies.
+DEFAULT_ERROR_BODY_MAX_BYTES = 64 * 1024
+# Hard wall-clock deadline for the whole bounded read. A streaming error body
+# that does not finish within this window is abandoned and the connection is
+# closed; we keep whatever partial bytes arrived.
+DEFAULT_ERROR_BODY_TIMEOUT_S = 10.0
+
+
+def read_streaming_error_body(
+ response: httpx.Response,
+ *,
+ max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
+ timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
+) -> str:
+ """Read a non-OK streaming response body with a byte cap and a hard deadline.
+
+ Returns the decoded body text (UTF-8, errors replaced), truncated to
+ ``max_bytes``. Never raises: any transport error, stall, or oversize
+ condition is swallowed and the best-effort partial text (or an empty
+ string) is returned, because this runs on the error path and must not
+ mask the original HTTP failure with a read error.
+
+ The byte cap protects against huge bodies; the wall-clock deadline (enforced
+ via a worker thread so it can interrupt a socket read that stalls mid-chunk)
+ protects against bodies that open and then hang.
+ """
+ chunks: List[bytes] = []
+ state = {"truncated": False}
+ done = threading.Event()
+
+ def _drain() -> None:
+ total = 0
+ try:
+ for chunk in response.iter_bytes():
+ if not chunk:
+ continue
+ remaining = max_bytes - total
+ if remaining <= 0:
+ state["truncated"] = True
+ break
+ if len(chunk) > remaining:
+ chunks.append(chunk[:remaining])
+ total += remaining
+ state["truncated"] = True
+ break
+ chunks.append(chunk)
+ total += len(chunk)
+ except Exception as exc: # noqa: BLE001 - error path must not raise
+ logger.debug("bounded error-body read failed: %s", exc)
+ finally:
+ done.set()
+
+ worker = threading.Thread(
+ target=_drain, name="bounded-error-body-read", daemon=True
+ )
+ worker.start()
+ finished = done.wait(timeout=timeout_s)
+
+ if not finished:
+ logger.debug(
+ "bounded error-body read: hard timeout after %.1fs (%d bytes so far)",
+ timeout_s,
+ sum(len(c) for c in chunks),
+ )
+ # Closing the response cancels the in-flight socket read, letting the
+ # worker thread unwind. We do not join (it is a daemon and may be
+ # blocked in C); the partial `chunks` collected so far are returned.
+ _safe_close(response)
+ else:
+ _safe_close(response)
+
+ if state["truncated"]:
+ logger.debug(
+ "bounded error-body read: capped at %d bytes (max=%d)",
+ sum(len(c) for c in chunks),
+ max_bytes,
+ )
+ return b"".join(chunks).decode("utf-8", errors="replace")
+
+
+def _safe_close(response: httpx.Response) -> None:
+ try:
+ response.close()
+ except Exception: # noqa: BLE001
+ pass
+
+
+def read_error_body_or_default(
+ response: httpx.Response,
+ *,
+ max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
+ timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
+) -> Optional[str]:
+ """Like ``read_streaming_error_body`` but returns ``None`` on empty body.
+
+ Convenience for callers that distinguish "no body" from "empty string".
+ """
+ text = read_streaming_error_body(
+ response, max_bytes=max_bytes, timeout_s=timeout_s
+ )
+ return text or None
diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py
index 1ee1702b45e8..9be0a7fed52e 100644
--- a/agent/chat_completion_helpers.py
+++ b/agent/chat_completion_helpers.py
@@ -28,15 +28,28 @@
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
+from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
from agent.message_sanitization import (
_sanitize_surrogates,
_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 +128,42 @@ def _is_openai_codex_backend(agent) -> bool:
)
+def openai_codex_stale_timeout_floor(est_tokens: int) -> float:
+ """Minimum wall-clock stale timeout for openai-codex by estimated context.
+
+ Gateway/Telegram sessions routinely ship ~15–25k tokens of tools +
+ instructions before the first user message. Subscription-backed Codex can
+ legitimately spend several minutes in backend admission/prefill at that
+ size; the generic 90s non-stream stale default aborts healthy calls. The
+ floor engages above 10k estimated tokens so those gateway-scale payloads
+ are covered; smaller requests keep the generic default.
+ """
+ if est_tokens > 100_000:
+ return 1200.0
+ if est_tokens > 50_000:
+ return 900.0
+ if est_tokens > 10_000:
+ return 600.0
+ return 0.0
+
+
+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)))
@@ -122,6 +171,52 @@ def _env_float(name: str, default: float) -> float:
return default
+# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
+# A session wedged against an unresponsive provider hits the stale detector
+# on every call and loops forever (observed: 494 consecutive failures over
+# 3+ days, each burning the full stale timeout × retries with no response).
+# The agent carries ``_consecutive_stale_streams``: incremented on every
+# stale kill, reset only when a call actually completes (or when the
+# provider is swapped — switch_model / try_activate_fallback /
+# restore_primary_runtime — since the streak measured the OLD provider).
+# Past the give-up threshold, calls abort immediately with an actionable
+# error instead of re-waiting out the stale timeout.
+
+def _stale_streak(agent) -> int:
+ try:
+ return int(getattr(agent, "_consecutive_stale_streams", 0) or 0)
+ except Exception:
+ return 0
+
+
+def _bump_stale_streak(agent) -> None:
+ try:
+ agent._consecutive_stale_streams = _stale_streak(agent) + 1
+ except Exception:
+ pass
+
+
+def _reset_stale_streak(agent) -> None:
+ try:
+ agent._consecutive_stale_streams = 0
+ except Exception:
+ pass
+
+
+def _check_stale_giveup(agent) -> None:
+ """Raise immediately when the consecutive-stale streak is past the
+ give-up threshold — no network attempt, no stale-timeout wait."""
+ _giveup = env_int("HERMES_STREAM_STALE_GIVEUP", 5)
+ _streak = _stale_streak(agent)
+ if _giveup > 0 and _streak >= _giveup:
+ raise RuntimeError(
+ "Provider has been unresponsive (no response received) for "
+ f"{_streak} consecutive stale attempts — aborting this call to "
+ "avoid an indefinite stall. Switch models or start a new "
+ "session, then retry."
+ )
+
+
def interruptible_api_call(agent, api_kwargs: dict):
"""
Run the API call in a background thread so the main conversation loop
@@ -137,6 +232,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
provider fallback.
"""
result = {"response": None, "error": None}
+
+ # Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
+ # of the guard in interruptible_streaming_api_call. Quiet-mode /
+ # subagent / no-stream-consumer sessions take THIS path, and a wedged
+ # unattended session here has the same infinite stale-retry class.
+ _check_stale_giveup(agent)
+
request_client_holder = {"client": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
@@ -229,6 +331,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(
@@ -281,12 +388,9 @@ def _call():
_openai_codex_backend = _is_openai_codex_backend(agent)
_est_tokens_for_codex_watchdog = estimate_request_context_tokens(api_kwargs)
if _codex_watchdog_enabled and _openai_codex_backend:
- if _est_tokens_for_codex_watchdog > 100_000:
- _stale_timeout = max(_stale_timeout, 1200.0)
- elif _est_tokens_for_codex_watchdog > 50_000:
- _stale_timeout = max(_stale_timeout, 900.0)
- elif _est_tokens_for_codex_watchdog > 25_000:
- _stale_timeout = max(_stale_timeout, 600.0)
+ _codex_floor = openai_codex_stale_timeout_floor(_est_tokens_for_codex_watchdog)
+ if _codex_floor:
+ _stale_timeout = max(_stale_timeout, _codex_floor)
if _est_tokens_for_codex_watchdog > 100_000:
_codex_idle_timeout_default = 180.0
@@ -309,7 +413,7 @@ def _call():
if _ttfb_timeout <= 0:
_ttfb_enabled = False
elif _openai_codex_backend:
- _ttfb_disable_above = _env_float("HERMES_CODEX_TTFB_DISABLE_ABOVE_TOKENS", 25_000.0)
+ _ttfb_disable_above = _env_float("HERMES_CODEX_TTFB_DISABLE_ABOVE_TOKENS", 10_000.0)
_ttfb_strict = os.environ.get("HERMES_CODEX_TTFB_STRICT", "").strip().lower() in {
"1", "true", "yes", "on"
}
@@ -506,6 +610,9 @@ def _call():
_close_request_client_once("stale_call_kill")
except Exception:
pass
+ # Circuit breaker (#58962): count the stale kill. See the
+ # canonical comment block above ``_stale_streak()``.
+ _bump_stale_streak(agent)
agent._touch_activity(
f"stale non-streaming call killed after {int(_elapsed)}s"
)
@@ -548,6 +655,10 @@ def _call():
raise InterruptedError("Agent interrupted during API call")
if result["error"] is not None:
raise result["error"]
+ # Success — clear the circuit breaker (#58962): the provider proved
+ # responsive. See the canonical comment block above ``_stale_streak()``.
+ if result["response"] is not None:
+ _reset_stale_streak(agent)
return result["response"]
@@ -597,7 +708,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_ct = agent._get_transport()
is_github_responses = (
base_url_host_matches(agent.base_url, "models.github.ai")
- or base_url_host_matches(agent.base_url, "api.githubcopilot.com")
+ or base_url_host_matches(agent.base_url, "githubcopilot.com")
)
is_codex_backend = (
agent.provider == "openai-codex"
@@ -667,7 +778,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_is_or = agent._is_openrouter_url()
_is_gh = (
base_url_host_matches(agent._base_url_lower, "models.github.ai")
- or base_url_host_matches(agent._base_url_lower, "api.githubcopilot.com")
+ or base_url_host_matches(agent._base_url_lower, "githubcopilot.com")
)
_is_nous = "nousresearch" in agent._base_url_lower
_is_nvidia = "integrate.api.nvidia.com" in agent._base_url_lower
@@ -698,21 +809,34 @@ 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:
_prefs["data_collection"] = agent.provider_data_collection
- # Claude max-output override on aggregators
+ # Anthropic-compatible max-output fallback (last resort only — applied in
+ # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding
+ # an explicit value). Model-gated, not URL-gated: any chat-completions
+ # proxy serving a Claude/MiniMax/Qwen3 model needs max_tokens, because the
+ # Anthropic Messages API treats it as mandatory and proxies that omit it
+ # (AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) default as low
+ # as 4096 output tokens — easily exhausted by thinking + large tool calls
+ # like write_file/patch. OpenRouter/Nous were the only routes covered
+ # before; gating on _ANTHROPIC_OUTPUT_LIMITS membership covers them all.
_ant_max = None
- if (_is_or or _is_nous) and "claude" in (agent.model or "").lower():
- try:
- from agent.anthropic_adapter import _get_anthropic_max_output
+ try:
+ from agent.anthropic_adapter import (
+ _get_anthropic_max_output,
+ _ANTHROPIC_OUTPUT_LIMITS,
+ )
+ _model_norm = (agent.model or "").lower().replace(".", "-")
+ if any(key in _model_norm for key in _ANTHROPIC_OUTPUT_LIMITS):
_ant_max = _get_anthropic_max_output(agent.model)
- except Exception:
- pass
+ except Exception:
+ pass
# Qwen session metadata
_qwen_meta = None
@@ -1015,18 +1139,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 +1171,64 @@ 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 _fallback_entry_key(fb: dict) -> tuple[str, str, str]:
+ return (
+ str(fb.get("provider") or "").strip().lower(),
+ str(fb.get("model") or "").strip(),
+ str(fb.get("base_url") or "").strip().rstrip("/"),
+ )
+
+
+def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]:
+ """Return a skip reason for fallback entries known to be unusable locally."""
+ fb_provider = (fb.get("provider") or "").strip().lower()
+ if fb_provider != "nous":
+ return None
+ try:
+ from hermes_cli.auth import get_provider_auth_state
+
+ state = get_provider_auth_state("nous") or {}
+ except Exception as exc:
+ return f"nous_auth_unreadable:{type(exc).__name__}"
+ access_value = state.get("access_token")
+ refresh_value = state.get("refresh_token")
+ has_access = isinstance(access_value, str) and bool(access_value.strip())
+ has_refresh = isinstance(refresh_value, str) and bool(refresh_value.strip())
+ if not (has_access or has_refresh):
+ return "nous_token_missing"
+ return None
+
+
+
def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool:
"""Switch to the next fallback model/provider in the chain.
@@ -1054,7 +1241,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
auth resolution and client construction — no duplicated provider→key
mappings.
"""
- if reason in {FailoverReason.rate_limit, FailoverReason.billing}:
+ if reason in {FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit}:
# Only start cooldown when leaving the primary provider. If we're
# already on a fallback and chain-switching, the primary wasn't the
# source of the 429 so the cooldown should not be reset/extended.
@@ -1064,14 +1251,47 @@ 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, FailoverReason.upstream_rate_limit}
+ ):
+ _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_key = _fallback_entry_key(fb)
+ unavailable = getattr(agent, "_unavailable_fallback_keys", None)
+ if unavailable is None:
+ unavailable = set()
+ agent._unavailable_fallback_keys = unavailable
+ if fb_key in unavailable:
+ logger.debug("Fallback skip: %s previously marked unavailable", fb_key)
+ return agent._try_activate_fallback(reason)
fb_provider = (fb.get("provider") or "").strip().lower()
fb_model = (fb.get("model") or "").strip()
if not fb_provider or not fb_model:
- return agent._try_activate_fallback() # skip invalid, try next
+ return agent._try_activate_fallback(reason) # skip invalid, try next
+
+ local_skip_reason = _fallback_entry_unavailable_without_network(agent, fb)
+ if local_skip_reason:
+ unavailable.add(fb_key)
+ logger.warning(
+ "Fallback skip: %s/%s is not locally usable (%s); suppressing for this session",
+ fb_provider,
+ fb_model,
+ local_skip_reason,
+ )
+ return agent._try_activate_fallback(reason)
# Skip entries that resolve to the current (provider, model) — falling
# back to the same backend that just failed loops the failure. Compare
@@ -1086,7 +1306,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
"Fallback skip: chain entry %s/%s matches current provider/model",
fb_provider, fb_model,
)
- return agent._try_activate_fallback()
+ return agent._try_activate_fallback(reason)
if (
fb_base_url_for_dedup
and current_base_url
@@ -1097,7 +1317,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
"Fallback skip: chain entry base_url %s matches current backend",
fb_base_url_for_dedup,
)
- return agent._try_activate_fallback()
+ return agent._try_activate_fallback(reason)
# Use centralized router for client construction.
# raw_codex=True because the main agent needs direct responses.stream()
@@ -1128,7 +1348,8 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
logger.warning(
"Fallback to %s failed: provider not configured",
fb_provider)
- return agent._try_activate_fallback() # try next in chain
+ unavailable.add(fb_key)
+ return agent._try_activate_fallback(reason) # try next in chain
try:
from hermes_cli.model_normalize import normalize_model_for_provider
@@ -1145,7 +1366,17 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
_fb_is_azure = agent._is_azure_openai_url(fb_base_url)
if fb_provider == "openai-codex":
fb_api_mode = "codex_responses"
- elif fb_provider == "anthropic" or fb_base_url.rstrip("/").lower().endswith("/anthropic"):
+ elif (
+ fb_provider == "anthropic"
+ or fb_base_url.rstrip("/").lower().endswith("/anthropic")
+ or base_url_hostname(fb_base_url) == "api.anthropic.com"
+ ):
+ # Custom providers (e.g. cron-anthropic) point at the native
+ # api.anthropic.com host with no "/anthropic" path suffix, so the
+ # name/suffix checks above miss them and they default to
+ # chat_completions → POST /v1/chat/completions → 404. Match the
+ # host the same way determine_api_mode() and _detect_api_mode_for_url()
+ # do on the primary path. (#32243, #49247)
fb_api_mode = "anthropic_messages"
elif _fb_is_azure:
# Azure OpenAI serves gpt-5.x on /chat/completions — does NOT
@@ -1181,14 +1412,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 +1432,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 +1536,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}"
@@ -1295,10 +1548,17 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
"Fallback activated: %s → %s (%s)",
old_model, fb_model, fb_provider,
)
+ # Reset the stale-call circuit breaker (#58962): the streak measured
+ # the OLD provider's unresponsiveness. Carrying it over would
+ # short-circuit the freshly activated fallback before it gets a
+ # single stream attempt.
+ _reset_stale_streak(agent)
return True
except Exception as e:
+ if fb_provider == "nous":
+ unavailable.add(fb_key)
logger.error("Failed to activate fallback %s: %s", fb_model, e)
- return agent._try_activate_fallback() # try next in chain
+ return agent._try_activate_fallback(reason) # try next in chain
@@ -1330,8 +1590,10 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
# hand-builds messages and calls chat.completions.create() directly,
# bypassing the transport — so mirror that sanitization here:
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
+ # timestamp (preserved on gateway user replay entries for the
+ # stale-confirmation expiry check — #47868 rejection class),
# and every Hermes-internal underscore-prefixed scaffolding key.
- for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
+ for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
api_msg.pop(schema_foreign, None)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
@@ -1425,8 +1687,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()
@@ -1683,11 +1946,27 @@ def _on_reasoning(text):
t.join(timeout=0.3)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call")
+ # Worker exited before the poll loop observed the interrupt flag. The
+ # Bedrock stream callback breaks out and returns a PARTIAL response
+ # without raising on interrupt (see bedrock_adapter.py
+ # stream_converse_with_callbacks / on_interrupt_check), so result[
+ # "response"] is populated with error=None and the in-loop raise above
+ # never fires. Re-check here so /stop is not silently swallowed on the
+ # Bedrock path — mirrors the post-worker guard on the main streaming
+ # loop. (#59999 area)
+ if agent._interrupt_requested:
+ raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
if result["error"] is not None:
raise result["error"]
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
+
+ # Cross-turn stale-stream circuit breaker (#58962) — see the canonical
+ # comment block above ``_stale_streak()``. Raises past the give-up
+ # threshold instead of burning another stale-timeout×retries cycle.
+ _check_stale_giveup(agent)
+
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
@@ -1761,14 +2040,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
@@ -1805,7 +2084,6 @@ def _call_chat_completions():
stream_kwargs = {
**api_kwargs,
"stream": True,
- "stream_options": {"include_usage": True},
"timeout": _httpx.Timeout(
connect=_conn_cap,
read=_stream_read_timeout,
@@ -1813,6 +2091,14 @@ def _call_chat_completions():
pool=_conn_cap,
),
}
+ # OpenAI's `stream_options={"include_usage": True}` drives usage
+ # accounting on OpenAI-compatible endpoints (incl. the Gemini OpenAI
+ # compat shim and aggregators like OpenRouter). Google's *native*
+ # Gemini REST endpoint rejects the keyword outright
+ # (`Completions.create() got an unexpected keyword argument
+ # 'stream_options'`), so omit it only for that endpoint.
+ if not is_native_gemini_base_url(agent.base_url):
+ stream_kwargs["stream_options"] = {"include_usage": True}
request_client = _set_request_client(
agent._create_request_openai_client(
reason="chat_completion_stream_request",
@@ -1830,6 +2116,49 @@ def _call_chat_completions():
request_client_holder["diag"] = _diag
stream = request_client.chat.completions.create(**stream_kwargs)
+ # Some OpenAI-compatible adapters (for example copilot-acp, and the MoA
+ # openai-codex aggregator) accept stream=True but still return a
+ # completed response object rather than an iterator of chunks. Treat
+ # that as "streaming unsupported" for the rest of this session instead
+ # of crashing on ``for chunk in stream`` with ``'types.SimpleNamespace'
+ # object is not iterable`` (#11732, #55933).
+ #
+ # Discriminate on the mere PRESENCE of a ``choices`` attribute, not on
+ # it being a non-empty list: an adapter may hand back a completed
+ # response whose ``choices`` is ``None`` or empty (an error /
+ # content-filter / terminal frame), and every such shape is still a
+ # whole response — not a token stream — that would crash iteration just
+ # the same. A genuine provider stream (SDK ``Stream`` object,
+ # generator) exposes no ``choices`` attribute, so it is left untouched.
+ if hasattr(stream, "choices"):
+ logger.info(
+ "Streaming request returned a final response object instead of "
+ "an iterator; switching %s/%s to non-streaming for this session.",
+ agent.provider or "unknown",
+ agent.model or "unknown",
+ )
+ agent._disable_streaming = True
+ # An empty/None ``choices`` carries no message to surface; return the
+ # completed object as-is so the outer loop's normal invalid-response
+ # validation (conversation_loop.py) handles it via the retry path,
+ # never ``for chunk in stream``.
+ choices = stream.choices
+ first_choice = choices[0] if isinstance(choices, (list, tuple)) and choices else None
+ message = getattr(first_choice, "message", None)
+ if message is not None:
+ reasoning_text = (
+ getattr(message, "reasoning_content", None)
+ or getattr(message, "reasoning", None)
+ )
+ if isinstance(reasoning_text, str) and reasoning_text:
+ _fire_first_delta()
+ agent._fire_reasoning_delta(reasoning_text)
+ content = getattr(message, "content", None)
+ if isinstance(content, str) and content:
+ _fire_first_delta()
+ agent._fire_stream_delta(content)
+ return stream
+
# Capture rate limit headers from the initial HTTP response.
# The OpenAI SDK Stream object exposes the underlying httpx
# response via .response before any chunks are consumed.
@@ -1948,15 +2277,23 @@ def _call_chat_completions():
idx = _active_slot_by_idx[raw_idx]
if idx not in tool_calls_acc:
+ # Poolside may send integer id instead of string
+ _tc_id = tc_delta.id
+ if isinstance(_tc_id, int):
+ _tc_id = str(_tc_id)
tool_calls_acc[idx] = {
- "id": tc_delta.id or "",
+ "id": _tc_id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
"extra_content": None,
}
entry = tool_calls_acc[idx]
- if tc_delta.id:
- entry["id"] = tc_delta.id
+ if tc_delta.id is not None:
+ _new_id = tc_delta.id
+ if isinstance(_new_id, int):
+ _new_id = str(_new_id)
+ if _new_id:
+ entry["id"] = _new_id
if tc_delta.function:
if tc_delta.function.name:
# Use assignment, not +=. Function names are
@@ -1972,7 +2309,7 @@ def _call_chat_completions():
entry["function"]["arguments"] += tc_delta.function.arguments
extra = getattr(tc_delta, "extra_content", None)
if extra is None and hasattr(tc_delta, "model_extra"):
- extra = (tc_delta.model_extra or {}).get("extra_content")
+ extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content")
if extra is not None:
if hasattr(extra, "model_dump"):
extra = extra.model_dump()
@@ -2213,7 +2550,15 @@ def _call_anthropic():
_fire_first_delta()
agent._fire_reasoning_delta(thinking_text)
- # Return the native Anthropic Message for downstream processing
+ # Return the native Anthropic Message for downstream processing.
+ # If the stream was interrupted (the event loop broke out above on
+ # agent._interrupt_requested), do NOT call get_final_message() — on
+ # a partially-consumed stream the SDK may hang draining remaining
+ # events or return a Message with incomplete tool_use blocks (partial
+ # JSON in `input`). The outer poll loop raises InterruptedError, so
+ # this return value is discarded anyway.
+ if agent._interrupt_requested:
+ return None
return stream.get_final_message()
def _call():
@@ -2358,12 +2703,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 +2763,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 +2867,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 +2887,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()
@@ -2574,12 +2944,22 @@ def _call():
_close_request_client_once("stale_stream_kill")
except Exception:
pass
+ # Circuit breaker (#58962): count the stale kill. See the
+ # canonical comment block above ``_stale_streak()``.
+ _bump_stale_streak(agent)
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
- 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()
@@ -2606,6 +2986,13 @@ def _call():
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")
+ # Worker thread exited before the main thread's poll loop could check
+ # the interrupt flag. If the worker returned early due to an interrupt
+ # (e.g. _call_anthropic() detected _interrupt_requested and returned
+ # None), the InterruptedError above was never raised. Re-check the
+ # flag here so /stop is not silently swallowed. (#59999 area)
+ if agent._interrupt_requested:
+ raise InterruptedError("Agent interrupted during streaming API call (post-worker)")
if result["error"] is not None:
if deltas_were_sent["yes"]:
# Streaming failed AFTER some tokens were already delivered to
@@ -2655,7 +3042,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,7 +3074,18 @@ def _call():
usage=None,
_dropped_tool_names=_partial_names or None,
)
+ if _content_filter_terminated:
+ _stub._content_filter_terminated = True
+ # Partial-stream stub: chunks WERE received (deltas fired), so
+ # the provider is demonstrably responsive — clear the circuit
+ # breaker (#58962) just like the full-success return below.
+ _reset_stale_streak(agent)
+ return _stub
raise result["error"]
+ # Success — clear the circuit breaker (#58962): the provider proved
+ # responsive. See the canonical comment block above ``_stale_streak()``.
+ if result["response"] is not None:
+ _reset_stale_streak(agent)
return result["response"]
# ── Provider fallback ──────────────────────────────────────────────────
diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py
index b8479141db10..4d138ce6e631 100644
--- a/agent/codex_responses_adapter.py
+++ b/agent/codex_responses_adapter.py
@@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
return converted or None
+# Provider-executed built-in tool *declaration* types accepted on the
+# Responses ``tools`` array. These are declared by ``type`` alone (no
+# client-side name/parameters schema) and run server-side — the provider
+# owns the implementation and reports progress via the matching ``*_call``
+# output items. Hermes injects xAI's native ``web_search`` for the xAI
+# transport (see agent/transports/codex.py); the rest are listed so the
+# preflight validator passes them through rather than rejecting them as
+# "unsupported type". Mirrors the ``*_call`` item-type set used in
+# _normalize_codex_response.
+_RESPONSES_BUILTIN_TOOL_TYPES = {
+ "web_search",
+ "web_search_preview",
+ "file_search",
+ "code_interpreter",
+ "image_generation",
+ "computer_use_preview",
+ "local_shell",
+}
+
+
# ---------------------------------------------------------------------------
# Message format conversion
# ---------------------------------------------------------------------------
@@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
- if tool.get("type") != "function":
+
+ tool_type = tool.get("type")
+
+ # Provider-executed built-in tools (xAI native web_search, code
+ # interpreter, etc.) are declared by ``type`` alone and carry no
+ # ``name``/``parameters`` schema — the provider owns the
+ # implementation. Pass them through verbatim instead of forcing
+ # them through the function-tool validation below (which would
+ # otherwise reject them with "unsupported type"). See
+ # agent/transports/codex.py for where xAI's native web_search is
+ # injected.
+ if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
+ normalized_tools.append(dict(tool))
+ continue
+
+ if tool_type != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
name = tool.get("name")
@@ -1086,6 +1121,33 @@ def _normalize_codex_response(
saw_final_answer_phase = False
saw_reasoning_item = False
+ # Server-side built-in tool calls (xAI's native web_search, code
+ # interpreter, etc.) are executed by the provider and reported as
+ # discrete ``*_call`` output items. xAI's /v1/responses surface
+ # (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
+ # these items at ``status="in_progress"`` even when the overall
+ # ``response.status == "completed"`` — the search ran to completion
+ # server-side, the per-item status simply isn't reconciled. These
+ # are NOT a signal that the model's turn is unfinished, so they must
+ # not flip ``has_incomplete_items``. Only the response-level status
+ # and genuine model output items (message/reasoning/function_call)
+ # govern the incomplete verdict. Without this guard, any turn where
+ # grok-composer invokes server-side search is misclassified as
+ # ``finish_reason="incomplete"`` and burns 3 fruitless continuation
+ # retries before failing with "Codex response remained incomplete
+ # after 3 continuation attempts". client-side function/custom tool
+ # calls keep their own in_progress handling below (they are skipped,
+ # not awaited).
+ _SERVER_SIDE_TOOL_CALL_TYPES = {
+ "web_search_call",
+ "file_search_call",
+ "code_interpreter_call",
+ "image_generation_call",
+ "computer_call",
+ "local_shell_call",
+ "mcp_call",
+ }
+
for item in output:
item_type = getattr(item, "type", None)
item_status = getattr(item, "status", None)
@@ -1094,22 +1156,38 @@ def _normalize_codex_response(
else:
item_status = None
- if item_status in {"queued", "in_progress", "incomplete"}:
+ if (
+ item_status in {"queued", "in_progress", "incomplete"}
+ and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
+ ):
has_incomplete_items = True
saw_streaming_or_item_incomplete = True
if item_type == "message":
item_phase = getattr(item, "phase", None)
normalized_phase = None
+ is_commentary_phase = False
if isinstance(item_phase, str):
normalized_phase = item_phase.strip().lower()
if normalized_phase in {"commentary", "analysis"}:
saw_commentary_phase = True
+ is_commentary_phase = True
elif normalized_phase in {"final_answer", "final"}:
saw_final_answer_phase = True
message_text = _extract_responses_message_text(item)
if message_text:
- content_parts.append(message_text)
+ # Responses ``commentary``/``analysis`` phase text is mid-turn
+ # preamble/progress narration, never the turn's final answer
+ # (Codex CLI excludes it from last-message extraction; issues
+ # #24933 / #41293). Keep it out of assistant content so it
+ # can't be concatenated into — or leak as — the final response,
+ # but surface it through the reasoning channel so the CLI/
+ # gateway display it like thinking text. The exact message
+ # item is still preserved below for replay/cache continuity.
+ if is_commentary_phase:
+ reasoning_parts.append(message_text)
+ else:
+ content_parts.append(message_text)
raw_message_item: Dict[str, Any] = {
"type": "message",
"role": "assistant",
@@ -1204,7 +1282,11 @@ def _normalize_codex_response(
))
final_text = "\n".join([p for p in content_parts if p]).strip()
- if not final_text and hasattr(response, "output_text"):
+ if (
+ not final_text
+ and hasattr(response, "output_text")
+ and not (saw_commentary_phase and not saw_final_answer_phase)
+ ):
out_text = getattr(response, "output_text", "")
if isinstance(out_text, str):
final_text = out_text.strip()
diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py
index 7f175fff97fa..2077d2fddcab 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
@@ -173,6 +228,76 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
}
+def _record_codex_app_server_compaction(
+ agent,
+ turn,
+ *,
+ approx_tokens: int | None = None,
+ force: bool = False,
+) -> bool:
+ """Record a Codex-native context compaction boundary in Hermes state.
+
+ The app-server owns the compacted thread context, so Hermes should not
+ rewrite local transcript rows here; state.db records the boundary via the
+ session event/usage counters while preserving the visible transcript.
+ """
+ if not force and not getattr(turn, "compacted", False):
+ return False
+
+ thread_id = getattr(turn, "thread_id", None) or ""
+ turn_id = getattr(turn, "turn_id", None) or ""
+ logger.info(
+ "codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
+ getattr(agent, "session_id", None) or "none",
+ thread_id,
+ turn_id,
+ force,
+ )
+ if not force:
+ try:
+ from agent.conversation_compression import COMPACTION_STATUS
+
+ agent._emit_status(COMPACTION_STATUS)
+ except Exception:
+ pass
+
+ compressor = getattr(agent, "context_compressor", None)
+ if compressor is not None:
+ compressor.compression_count = getattr(
+ compressor, "compression_count", 0
+ ) + 1
+ compressor.last_compression_rough_tokens = approx_tokens or 0
+ if not getattr(turn, "token_usage_last", None):
+ compressor.last_prompt_tokens = -1
+ compressor.last_completion_tokens = 0
+ compressor.awaiting_real_usage_after_compression = True
+
+ agent._last_compaction_in_place = False
+ try:
+ if getattr(agent, "event_callback", None):
+ agent.event_callback(
+ "session:compress",
+ {
+ "platform": getattr(agent, "platform", None) or "",
+ "session_id": getattr(agent, "session_id", None) or "",
+ "old_session_id": "",
+ "in_place": False,
+ "compression_count": getattr(
+ compressor, "compression_count", 0
+ )
+ if compressor is not None
+ else 0,
+ "runtime": "codex_app_server",
+ "thread_id": thread_id,
+ "turn_id": turn_id,
+ },
+ )
+ except Exception:
+ logger.debug("event_callback error on codex session:compress", exc_info=True)
+
+ return True
+
+
def run_codex_app_server_turn(
agent,
*,
@@ -189,13 +314,18 @@ def run_codex_app_server_turn(
Called from run_conversation() when agent.api_mode == "codex_app_server".
Returns the same dict shape as the chat_completions path.
"""
- from agent.transports.codex_app_server_session import CodexAppServerSession
+ from agent.transports.codex_app_server_session import (
+ CodexAppServerSession,
+ _ServerRequestRouting,
+ )
# Lazy session: one CodexAppServerSession per AIAgent instance.
# 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 +334,52 @@ def run_codex_app_server_turn(
approval_callback = _get_approval_callback()
except Exception:
approval_callback = None
+
+ # Gateway / cron contexts have no UI to surface codex's approval
+ # requests through, so codex app-server exec / apply_patch requests
+ # fail closed (silently decline) by default. When the user has
+ # explicitly opted out of Hermes approvals — via `approvals.mode: off`
+ # in config, the /yolo session toggle, or --yolo / HERMES_YOLO_MODE —
+ # honor that and let codex's own sandbox permission profile
+ # (~/.codex/config.toml) be the policy gate instead of double-gating
+ # with a missing Hermes UI. Defaults (manual/smart/unset) preserve the
+ # current fail-closed behavior — this is a no-op for those users.
+ auto_approve_requests = False
+ try:
+ from tools.approval import is_approval_bypass_active
+
+ auto_approve_requests = is_approval_bypass_active()
+ except Exception:
+ logger.debug(
+ "codex app-server: approval-bypass lookup failed; "
+ "keeping fail-closed default",
+ exc_info=True,
+ )
+
+ 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,
+ request_routing=_ServerRequestRouting(
+ auto_approve_exec=auto_approve_requests,
+ auto_approve_apply_patch=auto_approve_requests,
+ ),
+ on_event=_on_codex_event,
)
# NOTE: the user message is ALREADY appended to messages by the
@@ -258,6 +431,28 @@ def run_codex_app_server_turn(
if turn.projected_messages:
messages.extend(turn.projected_messages)
+ # Persist the newly-projected assistant/tool messages ourselves.
+ # This path is an early return that bypasses conversation_loop, whose
+ # normal per-step _persist_session() calls would otherwise flush them.
+ # The inbound user turn was already flushed at turn start
+ # (turn_context.py _persist_session), and _flush_messages_to_session_db
+ # is idempotent via the intrinsic _DB_PERSISTED_MARKER — so this writes
+ # ONLY the new codex projected rows and does NOT re-write the user turn.
+ # Keeping the agent as the sole persister lets us return
+ # agent_persisted=True below, so the gateway skips its own DB write and
+ # we avoid the #860/#42039 duplicate user-message write (append_message
+ # is a raw INSERT with no dedup, so a gateway re-write would duplicate
+ # the already-flushed user turn). See gateway/run.py agent_persisted.
+ if getattr(agent, "_session_db", None) is not None:
+ try:
+ agent._flush_messages_to_session_db(messages)
+ except Exception:
+ logger.debug(
+ "codex app-server projected-message flush failed",
+ exc_info=True,
+ )
+
+
# Counter ticks for the agent-improvement loop.
# _turns_since_memory and _user_turn_count are ALREADY incremented
# in the run_conversation() pre-loop block (lines ~11793-11817) so we
@@ -268,6 +463,7 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
+ _record_codex_app_server_compaction(agent, turn)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
@@ -290,6 +486,7 @@ def run_codex_app_server_turn(
original_user_message=original_user_message,
final_response=turn.final_text,
interrupted=False,
+ messages=messages,
)
except Exception:
logger.debug("external memory sync raised", exc_info=True)
@@ -318,6 +515,18 @@ def run_codex_app_server_turn(
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"error": turn.error,
+ # The codex app-server runtime IS an early-return path that bypasses
+ # conversation_loop, but we flush the projected assistant/tool messages
+ # ourselves above (see the _flush_messages_to_session_db call after
+ # messages.extend). The inbound user turn was already flushed at turn
+ # start (turn_context._persist_session) and the flush dedups via
+ # _DB_PERSISTED_MARKER, so state.db ends up with each real message
+ # exactly once and session_search / conversation-distill see the full
+ # gateway conversation. Report agent_persisted=True so the gateway
+ # skips its own append_to_transcript DB write — writing again there
+ # would re-INSERT the already-flushed user turn (append_message has no
+ # dedup), reintroducing the #860 / #42039 duplicate-write bug.
+ "agent_persisted": True,
"codex_thread_id": turn.thread_id,
"codex_turn_id": turn.turn_id,
**usage_result,
@@ -362,6 +571,14 @@ def _event_field(event: Any, name: str, default: Any = None) -> Any:
return value if value is not None else default
+def _item_field(item: Any, name: str, default: Any = None) -> Any:
+ """Field access for nested Response items (attr-style SDK object or dict)."""
+ value = getattr(item, name, None)
+ if value is None and isinstance(item, dict):
+ value = item.get(name, default)
+ return value if value is not None else default
+
+
def _raise_stream_error(event: Any) -> None:
"""Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame.
@@ -424,6 +641,7 @@ def _consume_codex_event_stream(
collected_text_deltas: List[str] = []
has_tool_calls = False
first_delta_fired = False
+ active_message_phase: str | None = None
terminal_status: str = "completed"
terminal_usage: Any = None
terminal_response_id: str = None
@@ -457,9 +675,35 @@ def _consume_codex_event_stream(
if event_type == "error":
_raise_stream_error(event)
+ # Track the phase of the active streamed message item. Codex/Harmony
+ # ``commentary``/``analysis`` text is mid-turn preamble/progress
+ # narration, never the final answer. We still collect completed output
+ # items for replay, but route those deltas to the reasoning callback so
+ # they display like thinking text instead of assistant content.
+ if event_type == "response.output_item.added":
+ item = _event_field(event, "item")
+ item_type = _item_field(item, "type", "")
+ if item_type == "message":
+ phase = _item_field(item, "phase", None)
+ active_message_phase = phase.strip().lower() if isinstance(phase, str) else None
+ else:
+ active_message_phase = None
+ if "function_call" in str(item_type):
+ has_tool_calls = True
+ continue
+
if "output_text.delta" in event_type or event_type == "response.output_text.delta":
delta_text = _event_field(event, "delta", "")
- if delta_text:
+ is_commentary_delta = active_message_phase in {"commentary", "analysis"}
+ if delta_text and is_commentary_delta:
+ # Commentary streams through the reasoning channel, not the
+ # visible answer stream (and stays out of output_text).
+ if on_reasoning_delta is not None:
+ try:
+ on_reasoning_delta(delta_text)
+ except Exception:
+ logger.debug("Codex stream on_reasoning_delta raised", exc_info=True)
+ elif delta_text:
collected_text_deltas.append(delta_text)
if not has_tool_calls:
if not first_delta_fired:
diff --git a/agent/coding_context.py b/agent/coding_context.py
index ede0dc1528ab..00f6d996d478 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 = (
@@ -298,6 +353,29 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
return "auto"
+def _coding_instructions(config: Optional[dict[str, Any]]) -> str:
+ """Standing operator instructions for the coding posture (config).
+
+ ``agent.coding_instructions`` — a string or list of strings appended to the
+ coding brief as an extra stable system block, so a user can pin project-wide
+ coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve;
+ clean the diff before committing") without editing the shipped brief.
+ Cache-safe: resolved once per session into the stable system-prompt tier,
+ like the rest of the posture.
+ """
+ if config is None:
+ try:
+ from hermes_cli.config import load_config
+
+ config = load_config()
+ except Exception:
+ config = {}
+ raw = ((config or {}).get("agent", {}) or {}).get("coding_instructions", "")
+ if isinstance(raw, (list, tuple)):
+ return "\n".join(str(item).strip() for item in raw if str(item).strip())
+ return str(raw or "").strip()
+
+
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
if cwd:
return Path(cwd).expanduser()
@@ -368,10 +446,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
@@ -398,6 +482,9 @@ class RuntimeMode:
# only to steer edit-format guidance toward the model's family — see
# ``_edit_format_line``. Fixed for the session, so cache-safe.
model: Optional[str] = None
+ # Standing operator instructions (``agent.coding_instructions``), appended
+ # as an extra stable system block. Empty unless the user configures it.
+ instructions: str = ""
@property
def kind(self) -> str:
@@ -444,6 +531,10 @@ def system_blocks(self) -> list[str]:
workspace = build_coding_workspace_block(self.cwd)
if workspace:
blocks.append(workspace)
+ # Operator instructions ride their own block so the brief (block 0) stays
+ # byte-stable and cache-keyed independently of user config.
+ if self.instructions:
+ blocks.append(f"Operator instructions (from config):\n{self.instructions}")
return blocks
def compact_skill_categories(self) -> frozenset[str]:
@@ -496,6 +587,7 @@ def resolve_runtime_mode(
cwd=resolved_cwd,
config_mode=mode,
model=model,
+ instructions=_coding_instructions(config),
)
@@ -588,12 +680,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 +729,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 +774,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_breakdown.py b/agent/context_breakdown.py
new file mode 100644
index 000000000000..0e2eb772f2ff
--- /dev/null
+++ b/agent/context_breakdown.py
@@ -0,0 +1,156 @@
+"""Live session context-window breakdown for UI surfaces.
+
+Estimates how the next provider request is composed: system prompt tiers,
+tool schemas, and conversation history. Uses the same rough char/4 heuristic
+as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align
+with compression thresholds — not exact tokenizer counts.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+_SKILLS_BLOCK_RE = re.compile(r".*?", re.DOTALL)
+
+_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
+
+_CATEGORY_COLORS = {
+ "system_prompt": "var(--context-usage-system)",
+ "tool_definitions": "var(--context-usage-tools)",
+ "rules": "var(--context-usage-rules)",
+ "skills": "var(--context-usage-skills)",
+ "mcp": "var(--context-usage-mcp)",
+ "subagent_definitions": "var(--context-usage-subagents)",
+ "memory": "var(--context-usage-memory)",
+ "conversation": "var(--context-usage-conversation)",
+}
+
+
+def _chars_to_tokens(text: str) -> int:
+ if not text:
+ return 0
+ return (len(text) + 3) // 4
+
+
+def _json_tokens(value: Any) -> int:
+ if not value:
+ return 0
+ return _chars_to_tokens(json.dumps(value, ensure_ascii=False))
+
+
+def _tool_name(tool: dict) -> str:
+ fn = tool.get("function") if isinstance(tool, dict) else None
+ if isinstance(fn, dict):
+ return str(fn.get("name") or "")
+ return str(tool.get("name") or "")
+
+
+def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
+ builtin: List[dict] = []
+ mcp: List[dict] = []
+ subagent: List[dict] = []
+ for tool in tools:
+ name = _tool_name(tool)
+ if name.startswith("mcp_"):
+ mcp.append(tool)
+ elif name in _SUBAGENT_TOOL_NAMES:
+ subagent.append(tool)
+ else:
+ builtin.append(tool)
+ return builtin, mcp, subagent
+
+
+def _memory_blocks(agent: Any) -> Tuple[str, str]:
+ memory_block = ""
+ user_block = ""
+ store = getattr(agent, "_memory_store", None)
+ if store is None:
+ return memory_block, user_block
+ try:
+ if getattr(agent, "_memory_enabled", True):
+ memory_block = store.format_for_system_prompt("memory") or ""
+ if getattr(agent, "_user_profile_enabled", True):
+ user_block = store.format_for_system_prompt("user") or ""
+ except Exception:
+ pass
+ return memory_block, user_block
+
+
+def _strip_blocks(text: str, *blocks: str) -> str:
+ out = text
+ for block in blocks:
+ if block:
+ out = out.replace(block, "")
+ return out.strip()
+
+
+def compute_session_context_breakdown(
+ agent: Any,
+ messages: Optional[List[dict]] = None,
+) -> Dict[str, Any]:
+ """Return a Cursor-style context usage breakdown for one live agent."""
+ from agent.model_metadata import estimate_messages_tokens_rough
+ from agent.system_prompt import build_system_prompt_parts
+
+ parts = build_system_prompt_parts(agent)
+ stable = parts.get("stable", "") or ""
+ context = parts.get("context", "") or ""
+ volatile = parts.get("volatile", "") or ""
+
+ skills_match = _SKILLS_BLOCK_RE.search(stable)
+ skills_index = skills_match.group(0) if skills_match else ""
+
+ memory_block, user_block = _memory_blocks(agent)
+ memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip()
+
+ system_core = _strip_blocks(stable, skills_index)
+ system_tail = _strip_blocks(volatile, memory_block, user_block)
+ system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip()
+
+ tools = list(getattr(agent, "tools", None) or [])
+ builtin_tools, mcp_tools, subagent_tools = _split_tools(tools)
+
+ conversation_tokens = estimate_messages_tokens_rough(messages or [])
+
+ categories = [
+ ("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)),
+ ("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)),
+ ("rules", "Rules", _chars_to_tokens(context)),
+ ("skills", "Skills", _chars_to_tokens(skills_index)),
+ ("mcp", "MCP", _json_tokens(mcp_tools)),
+ ("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)),
+ ("memory", "Memory", _chars_to_tokens(memory_text)),
+ ("conversation", "Conversation", conversation_tokens),
+ ]
+
+ estimated_total = sum(tokens for _, _, tokens in categories)
+
+ comp = getattr(agent, "context_compressor", None)
+ context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0
+ measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0
+ context_used = measured_used if measured_used > 0 else estimated_total
+ context_percent = (
+ max(0, min(100, round(context_used / context_max * 100)))
+ if context_max
+ else 0
+ )
+
+ return {
+ "categories": [
+ {
+ "color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"),
+ "id": category_id,
+ "label": label,
+ "tokens": tokens,
+ }
+ for category_id, label, tokens in categories
+ if tokens > 0
+ ],
+ "context_max": context_max,
+ "context_percent": context_percent,
+ "context_used": context_used,
+ "estimated_total": estimated_total,
+ "model": getattr(agent, "model", "") or "",
+ }
diff --git a/agent/context_compressor.py b/agent/context_compressor.py
index 16db1bedc30f..45eb25e1ca0b 100644
--- a/agent/context_compressor.py
+++ b/agent/context_compressor.py
@@ -19,11 +19,12 @@
import hashlib
import json
import logging
+import sqlite3
import re
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,
@@ -83,6 +84,46 @@
# poisoning every subsequent request in the session — a bare key like
# "is_compressed_summary" would reach the wire and trip exactly that.
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
+_DB_PERSISTED_MARKER = "_db_persisted"
+
+
+def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]:
+ """Copy a message for compaction assembly without persistence markers.
+
+ Live cached-gateway transcripts stamp ``_db_persisted`` during incremental
+ flushes. Shallow ``.copy()`` propagates that marker into the post-rotation
+ compressed list, so ``_flush_messages_to_session_db`` skips every row when
+ writing to the new child session (#57491).
+
+ This strips at the copy site (clearest intent, and cheap), but the
+ authoritative guarantee is the single terminal sweep in ``compress()``
+ (``_strip_persistence_markers``): no message may leave ``compress()``
+ carrying ``_db_persisted`` regardless of how many intermediate copy sites
+ a future refactor adds.
+ """
+ fresh = msg.copy()
+ fresh.pop(_DB_PERSISTED_MARKER, None)
+ return fresh
+
+
+def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
+ """Enforce the compaction invariant: no assembled message carries a
+ session-store persistence marker.
+
+ ``compress()`` copies protected head/tail messages out of the live
+ cached-gateway transcript, which stamps ``_db_persisted`` on every message
+ over the life of the session. If any copied dict keeps that marker, the
+ rotation flush to the child session skips it and the compacted transcript is
+ lost from ``state.db`` (#57491). Stripping at each copy site is necessary
+ but *positional* — a copy site added after the assembly loops would re-leak.
+ This single terminal sweep makes the guarantee structural instead: run it
+ once on the fully-assembled list so the invariant holds no matter where the
+ copies happened. Mutates in place (the dicts are compaction-local copies).
+ """
+ for msg in messages:
+ if isinstance(msg, dict):
+ msg.pop(_DB_PERSISTED_MARKER, None)
+
# Appended to every standalone summary message (and to the merged-into-tail
# prefix) so the model has an unambiguous "summary ends here" boundary.
@@ -94,6 +135,15 @@
"respond to the message below, not the summary above ---"
)
+# When the summary must be merged into the first tail message (the alternation
+# corner case where a standalone summary role would collide with both head and
+# tail), the tail message's own prior content is preserved BEFORE the summary,
+# wrapped in these delimiters so the model doesn't read it as a fresh message.
+# The summary prefix therefore lands AFTER _MERGED_SUMMARY_DELIMITER rather than
+# at the start of the message, so _is_context_summary_content must look past it.
+_MERGED_PRIOR_CONTEXT_HEADER = "[PRIOR CONTEXT — for reference only; not a new message]"
+_MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]"
+
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
@@ -248,6 +298,62 @@ def _content_length_for_budget(raw_content: Any) -> int:
return total
+def _serialized_length_for_budget(value: Any) -> int:
+ """Return a stable char-length for non-content replay/metadata fields."""
+ if value is None or value == "":
+ return 0
+ if isinstance(value, str):
+ return len(value)
+ try:
+ return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str))
+ except (TypeError, ValueError):
+ return len(str(value))
+
+
+# Provider replay/metadata fields that ride the wire on every request but are
+# invisible to ``msg["content"]``/``msg["tool_calls"]`` accounting. Codex
+# Responses sessions in particular carry ``codex_reasoning_items`` blobs of
+# ``encrypted_content`` that can dominate the serialized session (a measured
+# 214-turn session held ~115K tokens / 27% of its payload there — #55572).
+_REPLAY_BUDGET_KEYS = (
+ "reasoning",
+ "reasoning_content",
+ "reasoning_details",
+ "codex_reasoning_items",
+ "codex_message_items",
+)
+
+
+def _estimate_msg_budget_tokens(msg: dict) -> int:
+ """Token estimate for one message in the tail-protection budget walks.
+
+ 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.
+
+ Also counts provider replay fields (``codex_reasoning_items`` etc. —
+ see ``_REPLAY_BUDGET_KEYS``). The preflight "should I compress?"
+ estimator sees the full message shape, so the tail walk must use the
+ same size class; otherwise an assistant message with tiny visible
+ content but large hidden replay blobs is protected as if it were small,
+ the post-compression session stays near the context limit, and
+ compaction re-fires continuously (#55572). Accounting-only: replay
+ fields are never mutated or pruned here.
+ """
+ content_len = _content_length_for_budget(msg.get("content") or "")
+ tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead
+ for tc in msg.get("tool_calls") or []:
+ if isinstance(tc, dict):
+ tokens += len(str(tc)) // _CHARS_PER_TOKEN
+ for key in _REPLAY_BUDGET_KEYS:
+ tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN
+ return tokens
+
+
def _content_text_for_contains(content: Any) -> str:
"""Return a best-effort text view of message content.
@@ -619,26 +725,146 @@ def on_session_reset(self) -> None:
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
+ self._last_summary_error = None
+ self._last_compress_aborted = False
self.last_real_prompt_tokens = 0
self.last_compression_rough_tokens = 0
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
- """Clear per-session compaction state at a real session boundary.
-
- ``_previous_summary`` is per-session iterative-summary state. It is
- cleared on ``on_session_reset()`` (/new, /reset), but session *end*
- (CLI exit, gateway expiry, session-id rotation) goes through
- ``on_session_end()`` instead — which inherited a no-op from
- ``ContextEngine``. Without clearing here, a cron/background session's
- summary could survive on a reused compressor instance and leak into the
- next live session via the ``_generate_summary()`` iterative-update path
- (#38788). ``compress()`` already guards the leak at the point of use;
- this is defense-in-depth that drops the stale summary the moment the
- owning session ends.
+ """Clear all per-session compaction state at a real session boundary.
+
+ Session end (CLI exit, gateway expiry, session-id rotation) goes
+ through this method rather than ``on_session_reset()`` (/new, /reset).
+ The original fix (#38788) only cleared ``_previous_summary``, but the
+ same cross-session contamination risk applies to every per-session
+ variable that ``on_session_reset()`` clears: stale
+ ``_ineffective_compression_count`` can suppress compression in a
+ subsequent live session; ``_summary_failure_cooldown_until`` can block
+ summary generation; ``_last_compress_aborted`` can make callers think
+ compression is still aborted; ``_last_aux_model_failure_*`` can surface
+ stale error warnings; ``_last_summary_dropped_count`` /
+ ``_last_summary_fallback_used`` can produce misleading user warnings.
+
+ ``compress()`` already guards ``_previous_summary`` leakage at the
+ point of use; this is defense-in-depth that resets the full per-session
+ surface the moment the owning session ends.
"""
self._previous_summary = None
+ self._last_summary_error = None
+ self._last_summary_dropped_count = 0
+ self._last_summary_fallback_used = False
+ self._last_aux_model_failure_error = None
+ self._last_aux_model_failure_model = None
+ self._last_compression_savings_pct = 100.0
+ self._ineffective_compression_count = 0
+ self._summary_failure_cooldown_until = 0.0
+ self._last_compress_aborted = False
+ self._context_probed = False
+ self._context_probe_persistable = False
+ self.last_real_prompt_tokens = 0
+ self.last_compression_rough_tokens = 0
+ self.last_rough_tokens_when_real_prompt_fit = 0
+ self.awaiting_real_usage_after_compression = False
+
+ def bind_session_state(self, session_db: Any = None, session_id: str = "") -> None:
+ """Bind the current session row so durable cooldowns can round-trip."""
+ self._session_db = session_db
+ self._session_id = session_id or ""
+ self._summary_failure_cooldown_until = 0.0
+ self._last_summary_error = None
+ self.get_active_compression_failure_cooldown()
+
+ def on_session_start(self, session_id: str, **kwargs) -> None:
+ """Bind session-scoped compression state for a new or resumed session."""
+ super().on_session_start(session_id, **kwargs)
+ self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id)
+
+ def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]:
+ """Return the live compression-failure cooldown for the bound session."""
+ now_mono = time.monotonic()
+ if self._summary_failure_cooldown_until > now_mono:
+ return {
+ "cooldown_until": time.time() + (
+ self._summary_failure_cooldown_until - now_mono
+ ),
+ "remaining_seconds": self._summary_failure_cooldown_until - now_mono,
+ "error": self._last_summary_error,
+ }
+
+ session_db = getattr(self, "_session_db", None)
+ session_id = getattr(self, "_session_id", "")
+ if not session_db or not session_id:
+ return None
+
+ getter = getattr(session_db, "get_compression_failure_cooldown", None)
+ if getter is None:
+ return None
+ try:
+ state = getter(session_id)
+ except sqlite3.Error as exc:
+ logger.debug("compression failure cooldown lookup failed: %s", exc)
+ return None
+ except Exception:
+ return None
+ if not state:
+ return None
+
+ remaining_seconds = float(state.get("remaining_seconds") or 0.0)
+ if remaining_seconds <= 0:
+ return None
+
+ self._summary_failure_cooldown_until = now_mono + remaining_seconds
+ self._last_summary_error = state.get("error")
+ return {
+ "cooldown_until": float(state.get("cooldown_until") or 0.0),
+ "remaining_seconds": remaining_seconds,
+ "error": self._last_summary_error,
+ }
+
+ def _record_compression_failure_cooldown(
+ self,
+ cooldown_seconds: float,
+ error: Optional[str],
+ ) -> None:
+ cooldown_until = time.time() + cooldown_seconds
+ self._summary_failure_cooldown_until = time.monotonic() + cooldown_seconds
+ self._last_summary_error = error
+
+ session_db = getattr(self, "_session_db", None)
+ session_id = getattr(self, "_session_id", "")
+ if not session_db or not session_id:
+ return
+
+ recorder = getattr(session_db, "record_compression_failure_cooldown", None)
+ if recorder is None:
+ return
+ try:
+ recorder(session_id, cooldown_until, error)
+ except sqlite3.Error as exc:
+ logger.debug("compression failure cooldown persist failed: %s", exc)
+ except Exception as exc:
+ logger.debug("compression failure cooldown persist failed (non-sqlite): %s", exc)
+
+ def _clear_compression_failure_cooldown(self) -> None:
+ self._summary_failure_cooldown_until = 0.0
+ self._last_summary_error = None
+
+ session_db = getattr(self, "_session_db", None)
+ session_id = getattr(self, "_session_id", "")
+ if not session_db or not session_id:
+ return
+
+ clearer = getattr(session_db, "clear_compression_failure_cooldown", None)
+ if clearer is None:
+ return
+ try:
+ clearer(session_id)
+ except sqlite3.Error as exc:
+ logger.debug("compression failure cooldown clear failed: %s", exc)
+ except Exception as exc:
+ logger.debug("compression failure cooldown clear failed (non-sqlite): %s", exc)
def update_model(
self,
@@ -648,6 +874,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 +883,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 +899,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 +1002,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 +1014,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 +1035,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
@@ -742,6 +1070,8 @@ def __init__(
self.awaiting_real_usage_after_compression = False
self.summary_model = summary_model_override or ""
+ self._session_db: Any = None
+ self._session_id: str = ""
# Stores the previous compaction summary for iterative updates
self._previous_summary: Optional[str] = None
@@ -761,7 +1091,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 +1141,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:
@@ -822,6 +1180,23 @@ def should_compress(self, prompt_tokens: int = None) -> bool:
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
if tokens < self.threshold_tokens:
return False
+ # Do not trigger compression while the summary LLM is in cooldown.
+ # On a 429/transient failure _generate_summary() sets a cooldown and
+ # returns None; compress() then inserts a static fallback marker and
+ # returns. Tokens stay above threshold, so without this guard every
+ # subsequent turn re-fires _compress_context() — re-inserting the
+ # marker and re-entering the loop, making the CLI appear frozen until
+ # the cooldown expires (issue #11529). Manual /compress passes
+ # force=True, which clears this cooldown in compress() before running,
+ # so it still retries immediately.
+ _cooldown_remaining = self._summary_failure_cooldown_until - time.monotonic()
+ if _cooldown_remaining > 0:
+ if not self.quiet_mode:
+ logger.debug(
+ "Compression deferred — summary LLM in cooldown for %.0fs more",
+ _cooldown_remaining,
+ )
+ return False
# Anti-thrashing: back off if recent compressions were ineffective
if self._ineffective_compression_count >= 2:
if not self.quiet_mode:
@@ -891,13 +1266,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 +1614,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 +1629,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)}
@@ -1300,7 +1674,7 @@ def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None:
self._last_aux_model_failure_error = _err_text
self._last_aux_model_failure_model = self.summary_model
self.summary_model = "" # empty = use main model
- self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately
+ self._clear_compression_failure_cooldown() # no cooldown — retry immediately
def _generate_summary(
self,
@@ -1511,30 +1885,76 @@ def _generate_summary(
}
if self.summary_model:
call_kwargs["model"] = self.summary_model
- response = call_llm(**call_kwargs)
- content = response.choices[0].message.content
+ # 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)
+ # ``_validate_llm_response`` only guarantees ``choices[0].message``
+ # exists, not that it's an object with ``.content``. Some
+ # OpenAI-compatible proxies / local backends return a dict- or
+ # str-shaped message; coerce defensively instead of crashing.
+ message = response.choices[0].message
+ if isinstance(message, dict):
+ content = message.get("content")
+ else:
+ content = getattr(message, "content", message)
# 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())
# Store for iterative updates on next compaction
self._previous_summary = summary
- self._summary_failure_cooldown_until = 0.0
+ self._clear_compression_failure_cooldown()
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._record_compression_failure_cooldown(
+ _SUMMARY_FAILURE_COOLDOWN_SECONDS,
+ "no auxiliary LLM provider configured",
+ )
+ 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 +1991,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 "
@@ -1620,11 +2060,20 @@ def _generate_summary(
# streaming premature-close) — shorter cooldown for JSON decode and
# streaming-closed since those conditions can self-resolve quickly.
_transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60
- self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown
err_text = str(e).strip() or e.__class__.__name__
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
+ self._record_compression_failure_cooldown(_transient_cooldown, err_text)
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.",
@@ -1644,6 +2093,13 @@ def _strip_summary_prefix(summary: str) -> str:
stale directive it carried stays embedded in the body.
"""
text = (summary or "").strip()
+ # Merge-into-tail summaries wrap prior tail content before the summary
+ # body. Drop everything up to and including the delimiter so only the
+ # real summary body is carried forward on re-compaction — otherwise the
+ # [PRIOR CONTEXT] header and stale tail content leak into the next
+ # summarizer prompt.
+ if _MERGED_SUMMARY_DELIMITER in text:
+ text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].strip()
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
if text.startswith(prefix):
text = text[len(prefix):].lstrip()
@@ -1664,6 +2120,13 @@ def _with_summary_prefix(cls, summary: str) -> str:
@staticmethod
def _is_context_summary_content(content: Any) -> bool:
text = _content_text_for_contains(content).lstrip()
+ # Merge-into-tail summaries wrap prior tail content before the summary,
+ # so the handoff prefix lands after _MERGED_SUMMARY_DELIMITER rather than
+ # at the start. Detect the summary in that region too, otherwise callers
+ # (auto-focus skip, carry-forward summary find, last-real-user anchor)
+ # mistake a merged summary message for a real user turn.
+ if _MERGED_SUMMARY_DELIMITER in text:
+ text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip()
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@@ -1750,8 +2213,16 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,
The API rejects this because every tool_call must be followed by
a tool result with the matching call_id.
- This method removes orphaned results and inserts stub results for
- orphaned calls so the message list is always well-formed.
+ This method removes orphaned results and strips orphaned tool_calls
+ from assistant messages so the message list is always well-formed.
+
+ Previous approach inserted stub ``role="tool"`` results for orphaned
+ tool_calls. That caused a secondary failure: the pre-API
+ ``repair_message_sequence()`` uses ``tc.get("id")`` to track known
+ call IDs while this sanitizer uses ``call_id || id``. When the two
+ disagree (Codex Responses API format: ``id != call_id``), stubs get
+ silently dropped by the repair pass, re-exposing the original orphans.
+ Stripping at the source avoids this entire class of mismatch.
"""
surviving_call_ids: set = set()
for msg in messages:
@@ -1778,24 +2249,34 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,
if not self.quiet_mode:
logger.info("Compression sanitizer: removed %d orphaned tool result(s)", len(orphaned_results))
- # 2. Add stub results for assistant tool_calls whose results were dropped
+ # 2. Strip orphaned tool_calls from assistant messages whose results
+ # were dropped. Stripping is preferred over inserting stub results
+ # because stubs can be dropped by downstream repair_message_sequence
+ # when call_id != id (Codex Responses API format), re-exposing orphans.
missing_results = surviving_call_ids - result_call_ids
if missing_results:
- patched: List[Dict[str, Any]] = []
for msg in messages:
- patched.append(msg)
- if msg.get("role") == "assistant":
- for tc in msg.get("tool_calls") or []:
- cid = self._get_tool_call_id(tc)
- if cid in missing_results:
- patched.append({
- "role": "tool",
- "content": "[Result from earlier conversation — see context summary above]",
- "tool_call_id": cid,
- })
- messages = patched
+ if msg.get("role") != "assistant":
+ continue
+ tcs = msg.get("tool_calls")
+ if not tcs:
+ continue
+ kept = [tc for tc in tcs if self._get_tool_call_id(tc) not in missing_results]
+ if len(kept) != len(tcs):
+ if kept:
+ msg["tool_calls"] = kept
+ else:
+ msg.pop("tool_calls", None)
+ # Ensure the assistant message still has visible
+ # content so the API does not reject an empty turn.
+ content = msg.get("content")
+ if not content or (isinstance(content, str) and not content.strip()):
+ msg["content"] = "(tool call removed)"
if not self.quiet_mode:
- logger.info("Compression sanitizer: added %d stub tool result(s)", len(missing_results))
+ logger.info(
+ "Compression sanitizer: stripped %d orphaned tool_call(s) from assistant messages",
+ len(missing_results),
+ )
return messages
@@ -1809,6 +2290,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 +2318,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
@@ -1860,9 +2363,21 @@ def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) ->
def _find_last_user_message_idx(
self, messages: List[Dict[str, Any]], head_end: int
) -> int:
- """Return the index of the last user-role message at or after *head_end*, or -1."""
+ """Return the index of the last user-role message at or after *head_end*, or -1.
+
+ A context-compaction handoff banner can be inserted as a ``role="user"``
+ message (see the summary-role selection in ``compress``). It is internal
+ continuity state, not a real user turn, so it must not be picked as the
+ tail anchor — otherwise ``_ensure_last_user_message_in_tail`` protects
+ the summary and rolls the genuine last user message into the next
+ compaction, re-triggering the active-task loss the anchor exists to
+ prevent.
+ """
for i in range(len(messages) - 1, head_end - 1, -1):
- if messages[i].get("role") == "user":
+ msg = messages[i]
+ if msg.get("role") == "user" and not self._is_context_summary_content(
+ msg.get("content")
+ ):
return i
return -1
@@ -1986,6 +2501,17 @@ def _ensure_last_user_message_in_tail(
(``messages[cut_idx:]``), walk ``cut_idx`` back to include it. We
then re-align backward one more time to avoid splitting any
tool_call/result group that immediately precedes the user message.
+
+ Causal Coupling guard (#22523): the final ``max(last_user_idx,
+ head_end + 1)`` clamp can push the cut *past* the user message when
+ the user sits at ``head_end`` (the first compressible index) — the
+ only case where ``head_end + 1 > last_user_idx``. That splits the
+ turn-pair: the user lands in the compressed region without its
+ assistant reply, so the summariser records it as a pending ask and
+ the next session re-executes the already-completed task. When this
+ split is unavoidable, push the cut *forward* to ``pair_end`` so the
+ full pair (user + reply + tool results) is summarised together and
+ correctly marked as completed.
"""
last_user_idx = self._find_last_user_message_idx(messages, head_end)
if last_user_idx < 0:
@@ -2010,7 +2536,50 @@ def _ensure_last_user_message_in_tail(
cut_idx,
)
# Safety: never go back into the head region.
- return max(last_user_idx, head_end + 1)
+ adjusted = max(last_user_idx, head_end + 1)
+ if adjusted > last_user_idx:
+ # The clamp would leave the user in the compressed region without
+ # its reply. Keep the pair intact by pushing the cut forward past
+ # the whole (user + assistant + tool results) turn-pair so it is
+ # summarised as a completed unit rather than a dangling ask.
+ pair_end = self._find_turn_pair_end(messages, last_user_idx)
+ if not self.quiet_mode:
+ logger.debug(
+ "Causal Coupling: cut would split turn-pair at user %d; "
+ "pushing cut forward to pair_end %d so the completed pair "
+ "is summarised together (#22523)",
+ last_user_idx,
+ pair_end,
+ )
+ return max(pair_end, head_end + 1)
+ return adjusted
+
+ def _find_turn_pair_end(
+ self,
+ messages: List[Dict[str, Any]],
+ user_idx: int,
+ ) -> int:
+ """Return the index *after* the complete turn-pair starting at *user_idx*.
+
+ A turn-pair is: ``user`` -> ``assistant`` [-> zero-or-more ``tool``
+ results]. Returns the index of the first message that does *not*
+ belong to the pair, i.e. the natural cut point that keeps the pair
+ intact on one side of the boundary.
+
+ If *user_idx* is the last message (no assistant reply yet), returns
+ ``user_idx + 1`` so the user message itself is minimally covered.
+ """
+ n = len(messages)
+ idx = user_idx + 1
+ if idx >= n:
+ return idx # user is the very last message — no reply yet
+ if messages[idx].get("role") != "assistant":
+ return idx # no assistant reply immediately following
+ idx += 1
+ # Include any tool results that belong to this assistant turn.
+ while idx < n and messages[idx].get("role") == "tool":
+ idx += 1
+ return idx
def _find_tail_cut_by_tokens(
self, messages: List[Dict[str, Any]], head_end: int,
@@ -2055,14 +2624,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 +2650,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,12 +2734,22 @@ 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
+ # NOTE: do NOT reset _last_summary_auth_failure or
+ # _last_summary_network_failure here. These flags are set by
+ # _generate_summary() on a terminal failure and are already cleared on
+ # a successful summary. Resetting them eagerly defeats the cooldown
+ # protection: _generate_summary() returns None from the cooldown
+ # early-return without re-asserting these flags, so the abort guard
+ # below would see False and fall through to the destructive
+ # static-fallback — the exact data-loss #29559 describes. Letting them
+ # persist across compress() calls is safe because a successful summary
+ # always clears both.
# Manual /compress (force=True) bypasses the failure cooldown so the
# user can retry immediately after an auto-compress abort. Without
# this, /compress would silently no-op for 30-60s after a failure.
- if force and self._summary_failure_cooldown_until > 0.0:
- self._summary_failure_cooldown_until = 0.0
+ if force:
+ self._clear_compression_failure_cooldown()
n_messages = len(messages)
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
_min_for_compress = self._protect_head_size(messages) + 3 + 1
@@ -2293,25 +2859,59 @@ 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
compressed = []
for i in range(compress_start):
- msg = messages[i].copy()
+ msg = _fresh_compaction_message_copy(messages[i])
if i == 0 and msg.get("role") == "system":
existing = msg.get("content")
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]"
@@ -2339,9 +2939,44 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
_merge_summary_into_tail = False
last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user"
first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user"
+ # When the only protected head message is the system prompt, the
+ # summary becomes the first *visible* message in the API request
+ # (most adapters — Anthropic, Bedrock — send the system prompt as
+ # a separate ``system`` parameter, not inside ``messages[]``).
+ # Anthropic unconditionally rejects requests whose first message
+ # is not role=user, so we must pin the summary to "user" and
+ # prevent the flip logic below from reverting it (#52160).
+ _force_user_leading = last_head_role == "system"
+ # Zero-user-turn guard (#58753). The #52160 guard above only fires
+ # when the system prompt sits *inside* ``messages`` (the gateway
+ # ``/compress`` path). The main auto-compression path passes the
+ # transcript WITHOUT the system prompt (it is prepended at
+ # request-build time), so ``last_head_role`` defaults to "user" and
+ # the summary is emitted as role="assistant". On a session whose only
+ # genuine user turn falls into the compressed middle — e.g. a
+ # ``hermes kanban`` worker seeded with a single short
+ # ``"work kanban task "`` prompt followed by nothing but
+ # assistant/tool turns — that leaves the compressed transcript with
+ # ZERO user-role messages. OpenAI-compatible backends (vLLM/Qwen)
+ # reject such a request with a non-retryable
+ # ``400 No user query found in messages``, crashing the worker with no
+ # possible recovery (every resume replays the same poisoned history).
+ # If no user-role message survives in either the protected head or the
+ # preserved tail, the summary MUST carry role="user" so the request
+ # always has at least one user turn.
+ if not _force_user_leading:
+ _user_survives = any(
+ messages[i].get("role") == "user"
+ for i in range(0, compress_start)
+ ) or any(
+ messages[i].get("role") == "user"
+ for i in range(compress_end, n_messages)
+ )
+ if not _user_survives:
+ _force_user_leading = True
# Pick a role that avoids consecutive same-role with both neighbors.
# Priority: avoid colliding with head (already committed), then tail.
- if last_head_role in {"assistant", "tool"}:
+ if last_head_role in {"assistant", "tool"} or _force_user_leading:
summary_role = "user"
else:
summary_role = "assistant"
@@ -2349,7 +2984,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
# collide with the head, flip it.
if summary_role == first_tail_role:
flipped = "assistant" if summary_role == "user" else "user"
- if flipped != last_head_role:
+ if flipped != last_head_role and not _force_user_leading:
summary_role = flipped
else:
# Both roles would create consecutive same-role messages
@@ -2376,12 +3011,27 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
})
for i in range(compress_end, n_messages):
- msg = messages[i].copy()
+ msg = _fresh_compaction_message_copy(messages[i])
if _merge_summary_into_tail and i == compress_end:
- merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
+ # Merge the summary into the first tail message, but place
+ # the END MARKER at the very end so the model sees an
+ # unambiguous boundary. Old tail content is preserved as
+ # reference material BEFORE the summary, clearly delimited
+ # so it is not mistaken for a new message to respond to.
+ # Uses _append_text_to_content to safely handle both
+ # string and multimodal-list content types.
+ # Fixes ghost-message leakage across compaction boundaries
+ # where old head messages survived verbatim and appeared
+ # before the summary.
+ old_content = msg.get("content", "")
+ suffix = (
+ "\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n"
+ + summary + "\n\n"
+ + _SUMMARY_END_MARKER
+ )
msg["content"] = _append_text_to_content(
- msg.get("content"),
- merged_prefix,
+ _append_text_to_content(old_content, suffix, prepend=False),
+ _MERGED_PRIOR_CONTEXT_HEADER + "\n",
prepend=True,
)
# Mark the merged message so frontends can identify it as
@@ -2423,4 +3073,10 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
)
logger.info("Compression #%d complete", self.compression_count)
+ # Enforced invariant (#57491): no compacted message may leave compress()
+ # carrying a session-store persistence marker. The per-site strips above
+ # are positional; this single terminal sweep makes it structural so a
+ # future copy site cannot re-leak the marker into the child-session flush.
+ _strip_persistence_markers(compressed)
+
return compressed
diff --git a/agent/context_engine.py b/agent/context_engine.py
index 79c31fb48e6c..ba2da561fa11 100644
--- a/agent/context_engine.py
+++ b/agent/context_engine.py
@@ -194,12 +194,17 @@ def get_status(self) -> Dict[str, Any]:
Default returns the standard fields run_agent.py expects.
"""
+ # Clamp the -1 "compression just ran, awaiting real usage" sentinel
+ # (set by conversation_compression) to 0 so status readers don't see a
+ # raw -1 or a negative usage_percent on the transitional turn. Mirrors
+ # the CLI/gateway status-bar paths (cli.py, tui_gateway/server.py).
+ last_prompt = self.last_prompt_tokens if self.last_prompt_tokens > 0 else 0
return {
- "last_prompt_tokens": self.last_prompt_tokens,
+ "last_prompt_tokens": last_prompt,
"threshold_tokens": self.threshold_tokens,
"context_length": self.context_length,
"usage_percent": (
- min(100, self.last_prompt_tokens / self.context_length * 100)
+ min(100, last_prompt / self.context_length * 100)
if self.context_length else 0
),
"compression_count": self.compression_count,
diff --git a/agent/context_references.py b/agent/context_references.py
index 6307033d2706..eea16ae52b44 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(
@@ -151,13 +152,24 @@ async def preprocess_context_references_async(
blocks: list[str] = []
injected_tokens = 0
- for ref in refs:
- warning, block = await _expand_reference(
- ref,
- cwd_path,
- url_fetcher=url_fetcher,
- allowed_root=allowed_root_path,
+ # Expand all references concurrently. Each _expand_reference is independent
+ # (no shared state during expansion) — a message with several @url: refs
+ # would otherwise pay one full web_extract round-trip per ref in series.
+ # gather preserves positional order, so we reassemble warnings/blocks in the
+ # original ref order exactly as the prior serial loop did; the token-budget
+ # check below is unchanged (it runs once, after all refs are expanded).
+ expanded = await asyncio.gather(
+ *(
+ _expand_reference(
+ ref,
+ cwd_path,
+ url_fetcher=url_fetcher,
+ allowed_root=allowed_root_path,
+ )
+ for ref in refs
)
+ )
+ for warning, block in expanded:
if warning:
warnings.append(warning)
if block:
@@ -290,6 +302,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 +311,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
@@ -325,9 +339,9 @@ async def _fetch_url_content(
async def _default_url_fetcher(url: str) -> str:
from tools.web_tools import web_extract_tool
- raw = await web_extract_tool([url], format="markdown", use_llm_processing=True)
+ raw = await web_extract_tool([url], format="markdown")
payload = json.loads(raw)
- docs = payload.get("data", {}).get("documents", [])
+ docs = payload.get("results", [])
if not docs:
return ""
doc = docs[0]
@@ -367,6 +381,37 @@ def _ensure_reference_path_allowed(path: Path) -> None:
continue
raise ValueError("path is a sensitive credential or internal Hermes path and cannot be attached")
+ # Anchor to the canonical read deny-list (agent/file_safety.get_read_block_error),
+ # the single source of truth used by the file/terminal read path. The narrow
+ # list above predates that guard and never caught the real credential stores:
+ # provider keys (auth.json), Anthropic OAuth tokens (.anthropic_oauth.json),
+ # MCP OAuth material (mcp-tokens/), webhook HMAC secrets, and project-local
+ # .env files. That gap matters because the gateway feeds UNTRUSTED remote
+ # message text into reference expansion, so `@file:~/.hermes/auth.json` from a
+ # chat peer would otherwise read the operator's keys straight into context.
+ # Routing through the canonical guard closes the gap today and keeps this path
+ # protected automatically whenever that deny-list grows.
+ try:
+ from agent.file_safety import get_read_block_error
+
+ if get_read_block_error(str(path)) is not None:
+ raise ValueError(
+ "path is a sensitive credential or internal Hermes path and cannot be attached"
+ )
+ except ValueError:
+ raise
+ except Exception:
+ # Fail CLOSED on the security path. This guard exists specifically to
+ # cover credential stores the narrow list above misses (auth.json,
+ # .anthropic_oauth.json, mcp-tokens/, ...). If the canonical lookup
+ # ever fails, silently falling through would re-open that exact hole —
+ # the gateway feeds untrusted remote text here, so a probe could then
+ # attach the operator's keys. Refuse instead: a spurious block on a
+ # legitimate file is a recoverable annoyance; a leaked credential is not.
+ raise ValueError(
+ "path could not be verified against the credential deny-list and cannot be attached"
+ )
+
def _strip_trailing_punctuation(value: str) -> str:
stripped = value.rstrip(TRAILING_PUNCTUATION)
@@ -483,6 +528,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 +537,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 318e67d0faf2..843960cc2818 100644
--- a/agent/conversation_compression.py
+++ b/agent/conversation_compression.py
@@ -32,6 +32,7 @@
import os
import tempfile
import uuid
+import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Tuple
@@ -71,6 +72,85 @@ def _compression_lock_holder(agent: Any) -> str:
)
+class _CompressionLockLeaseRefresher:
+ def __init__(
+ self,
+ db: Any,
+ session_id: str,
+ holder: str,
+ ttl_seconds: float,
+ refresh_interval_seconds: float | None = None,
+ ) -> None:
+ self._db = db
+ self._session_id = session_id
+ self._holder = holder
+ self._ttl_seconds = ttl_seconds
+ if refresh_interval_seconds is None:
+ refresh_interval_seconds = max(1.0, min(60.0, ttl_seconds / 2.0))
+ self._refresh_interval_seconds = max(0.1, float(refresh_interval_seconds))
+ # Tolerate transient refresh failures for at most one lease's worth of
+ # time, so the give-up window is genuinely bounded by the TTL the
+ # acquirer set (a single blip recovers on the next tick; a persistent
+ # failure stops before the lease could outlive its TTL). Floor of 1 so a
+ # degenerate interval >= ttl still tolerates one blip.
+ self._max_consecutive_failures = max(
+ 1, int(self._ttl_seconds / self._refresh_interval_seconds)
+ )
+ self._stop = threading.Event()
+ self._thread = threading.Thread(
+ target=self._run,
+ name="compression-lock-refresh",
+ daemon=True,
+ )
+
+ def start(self) -> "_CompressionLockLeaseRefresher":
+ self._thread.start()
+ return self
+
+ def stop(self) -> None:
+ self._stop.set()
+ # join() may time out while the refresher is mid-UPDATE; that's safe —
+ # it's a daemon thread, and a late refresh on an already-released lock
+ # matches rowcount 0 (a no-op). stop() returning does not guarantee the
+ # thread has fully quiesced, only that we've signalled it and waited
+ # briefly.
+ if self._thread.is_alive() and threading.current_thread() is not self._thread:
+ self._thread.join(timeout=1.0)
+
+ def _run(self) -> None:
+ # A single falsy refresh must NOT permanently kill the lease: a
+ # transient DB blip (write contention escaping _execute_write's retry
+ # budget, a momentary "database is locked") returns False just like a
+ # genuine lost-ownership, but only the latter should stop the loop.
+ # Tolerate consecutive failures for at most one lease's worth of time
+ # (_max_consecutive_failures = ttl / interval), so a one-off blip
+ # recovers on the next tick while the total give-up window stays bounded
+ # by the TTL the acquirer set — the lock can never be held past its TTL
+ # by a stuck refresher.
+ consecutive_failures = 0
+ while not self._stop.wait(self._refresh_interval_seconds):
+ try:
+ refreshed = self._db.refresh_compression_lock(
+ self._session_id,
+ self._holder,
+ ttl_seconds=self._ttl_seconds,
+ )
+ except Exception as exc:
+ logger.debug("compression lock refresh raised: %s", exc)
+ refreshed = False
+ if refreshed:
+ consecutive_failures = 0
+ continue
+ consecutive_failures += 1
+ if consecutive_failures >= self._max_consecutive_failures:
+ logger.debug(
+ "compression lock refresh failed %d times in a row; "
+ "stopping lease refresher for session %s",
+ consecutive_failures, self._session_id,
+ )
+ break
+
+
def check_compression_model_feasibility(agent: Any) -> None:
"""Warn at session start if the auxiliary compression model's context
window is smaller than the main model's compression threshold.
@@ -90,6 +170,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 +178,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 +186,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 +368,70 @@ 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 _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None:
+ """Preserve a real user turn when a compressor returns assistant/tool-only context.
+
+ On repeated compaction the protected head decays to the system prompt only,
+ the middle summary can land as ``role="assistant"``, and a tool-heavy tail
+ can be all assistant/tool — so the compacted transcript can legitimately
+ contain zero user messages. Strict chat templates (LM Studio / llama.cpp
+ Jinja) then fail with "No user query found in messages" (#55677).
+
+ The restored turn is appended at the END: the guard only runs when
+ ``compressed`` currently ends with an assistant/tool message (any existing
+ user turn — including a todo-snapshot append — short-circuits the
+ ``any()`` check), so appending a user message never creates consecutive
+ same-role messages. ``_fresh_compaction_message_copy`` copies the message
+ and strips the ``_db_persisted`` marker so the rotation/in-place flush
+ still persists the restored row to the new session (#57491).
+
+ If the pre-compression transcript itself carried no user turn at all
+ (near-impossible — every real conversation opens with a user request —
+ but kept as a defensive backstop), a minimal continuation marker is
+ appended instead so strict templates still see a user message.
+ """
+ if any(isinstance(msg, dict) and msg.get("role") == "user" for msg in compressed):
+ return
+ from agent.context_compressor import _fresh_compaction_message_copy
+
+ for msg in reversed(original_messages):
+ if not isinstance(msg, dict) or msg.get("role") != "user":
+ continue
+ compressed.append(_fresh_compaction_message_copy(msg))
+ return
+ compressed.append({
+ "role": "user",
+ "content": (
+ "Continue from the compressed conversation context above. "
+ "This marker exists because the compacted transcript contained "
+ "no preserved user turn."
+ ),
+ })
+
+
def compress_context(
agent: Any,
messages: list,
@@ -311,6 +465,21 @@ def compress_context(
prompt — the session is NOT rotated. Callers should detect the
no-op via ``len(returned) == len(input)`` and stop the retry loop.
"""
+ # Codex app-server sessions: the codex agent owns the real thread context;
+ # Hermes' summarizer would only rewrite a local mirror without shrinking
+ # the actual thread (#36801). Route compaction to the app server's own
+ # thread/compact mechanism. Behavior is controlled by
+ # ``compression.codex_app_server_auto`` (native|hermes|off).
+ if getattr(agent, "api_mode", None) == "codex_app_server":
+ return _compress_context_via_codex_app_server(
+ agent,
+ messages,
+ system_message,
+ approx_tokens=approx_tokens,
+ task_id=task_id,
+ force=force,
+ )
+
# Lazy feasibility check — run the auxiliary-provider probe + context
# length lookup just-in-time on the first compression attempt instead of
# at AIAgent.__init__. Saves ~400ms cold off every short session that
@@ -328,6 +497,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,
@@ -377,11 +556,17 @@ def compress_context(
# and proceed with compression. Skipping the lock risks a rare
# concurrent-compression session fork; an infinite no-progress loop
# that never compresses at all is strictly worse.
+ try:
+ _lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0)
+ except (TypeError, ValueError):
+ _lock_ttl = 300.0
+ _lock_refresh_interval = getattr(agent, "_compression_lock_refresh_interval", None)
+ _lock_refresher: Optional[_CompressionLockLeaseRefresher] = None
if _lock_db is not None and _lock_sid:
_lock_holder = _compression_lock_holder(agent)
try:
_lock_acquired = _lock_db.try_acquire_compression_lock(
- _lock_sid, _lock_holder
+ _lock_sid, _lock_holder, ttl_seconds=_lock_ttl
)
except Exception as _lock_err:
# Broken/absent lock subsystem (version skew, etc.). Log once
@@ -424,9 +609,19 @@ def compress_context(
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
return messages, _existing_sp
+ if _lock_holder is not None:
+ _lock_refresher = _CompressionLockLeaseRefresher(
+ _lock_db,
+ _lock_sid,
+ _lock_holder,
+ _lock_ttl,
+ _lock_refresh_interval,
+ ).start()
def _release_lock() -> None:
"""Release the lock keyed on the OLD session_id (before rotation)."""
+ if _lock_refresher is not None:
+ _lock_refresher.stop()
if _lock_db is not None and _lock_sid and _lock_holder:
try:
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
@@ -445,7 +640,11 @@ def _release_lock() -> None:
except TypeError:
# Plugin context engine with strict signature that doesn't accept
# focus_topic / force — fall back to calling without them.
- compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
+ try:
+ compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
+ except BaseException:
+ _release_lock()
+ raise
except BaseException:
# ANY exception during compress() must release the lock so the
# session isn't permanently blocked from future compression.
@@ -458,199 +657,449 @@ def _release_lock() -> None:
# session has logically ended), and let auto-compress callers detect
# the no-op via len(returned) == len(input).
if getattr(agent.context_compressor, "_last_compress_aborted", False):
- _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error"
- if getattr(agent, "_last_compression_summary_warning", None) != _err:
- agent._last_compression_summary_warning = _err
- agent._emit_warning(
- f"⚠ Compression aborted: {_err}. "
- "No messages were dropped — conversation continues unchanged. "
- "Run /compress to retry, or /new to start a fresh session."
- )
- _existing_sp = getattr(agent, "_cached_system_prompt", None)
- if not _existing_sp:
- _existing_sp = agent._build_system_prompt(system_message)
- _release_lock() # compression aborted — no rotation will happen
- return messages, _existing_sp
-
- summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
- if summary_error:
- if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
- agent._last_compression_summary_warning = summary_error
- agent._emit_warning(
- f"⚠ Compression summary failed: {summary_error}. "
- "Inserted a fallback context marker."
- )
- else:
- # No hard failure — but did the configured aux model error out
- # and get recovered by retrying on main? Surface that so users
- # know their auxiliary.compression.model setting is broken even
- # though compression succeeded.
- _aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
- _aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None)
- if _aux_fail_model:
- # Dedup on (model, error) so we don't spam on every compaction
- _aux_key = (_aux_fail_model, _aux_fail_err)
- if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key:
- agent._last_aux_fallback_warning_key = _aux_key
+ try:
+ _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error"
+ if getattr(agent, "_last_compression_summary_warning", None) != _err:
+ agent._last_compression_summary_warning = _err
+ agent._emit_warning(
+ f"⚠ Compression aborted: {_err}. "
+ "No messages were dropped — conversation continues unchanged. "
+ "Run /compress to retry, or /new to start a fresh session."
+ )
+ _existing_sp = getattr(agent, "_cached_system_prompt", None)
+ if not _existing_sp:
+ _existing_sp = agent._build_system_prompt(system_message)
+ return messages, _existing_sp
+ finally:
+ _release_lock()
+
+ try:
+ summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
+ if summary_error:
+ if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
+ agent._last_compression_summary_warning = summary_error
agent._emit_warning(
- f"ℹ Configured compression model '{_aux_fail_model}' failed "
- f"({_aux_fail_err or 'unknown error'}). Recovered using main model — "
- "check auxiliary.compression.model in config.yaml."
+ f"⚠ Compression summary failed: {summary_error}. "
+ "Inserted a fallback context marker."
)
+ else:
+ # No hard failure — but did the configured aux model error out
+ # and get recovered by retrying on main? Surface that so users
+ # know their auxiliary.compression.model setting is broken even
+ # though compression succeeded.
+ _aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
+ _aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None)
+ if _aux_fail_model:
+ # Dedup on (model, error) so we don't spam on every compaction
+ _aux_key = (_aux_fail_model, _aux_fail_err)
+ if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key:
+ agent._last_aux_fallback_warning_key = _aux_key
+ agent._emit_warning(
+ f"ℹ Configured compression model '{_aux_fail_model}' failed "
+ f"({_aux_fail_err or 'unknown error'}). Recovered using main model — "
+ "check auxiliary.compression.model in config.yaml."
+ )
- todo_snapshot = agent._todo_store.format_for_injection()
- if todo_snapshot:
- compressed.append({"role": "user", "content": todo_snapshot})
+ todo_snapshot = agent._todo_store.format_for_injection()
+ if todo_snapshot:
+ compressed.append({"role": "user", "content": todo_snapshot})
+ _ensure_compressed_has_user_turn(messages, compressed)
- agent._invalidate_system_prompt()
- new_system_prompt = agent._build_system_prompt(system_message)
- agent._cached_system_prompt = new_system_prompt
+ agent._invalidate_system_prompt()
+ new_system_prompt = agent._build_system_prompt(system_message)
+ agent._cached_system_prompt = new_system_prompt
- 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.
- agent.commit_memory_session(messages)
- 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.
+ if agent._session_db:
try:
- from gateway.session_context import set_current_session_id
+ # 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)
- 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:
- 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)
- 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.
- try:
+ 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:
+ 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)
+ agent._last_flushed_db_idx = 0
+ except Exception as e:
+ # 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")
- if _old_sid 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,
- conversation_id=getattr(agent, "_gateway_session_key", None),
+ _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:
+ 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=_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 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:
+ if _is_boundary and agent._memory_manager:
+ agent._memory_manager.on_session_switch(
+ agent.session_id or "",
+ 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).
+ # 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:
+ _cc_msg = (
+ f"{agent.log_prefix}⚠️ Session compressed {_cc} times — "
+ f"accuracy may degrade. Consider /new to start fresh."
)
- 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.
+ 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. 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 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.
+ _compressed_est = estimate_request_tokens_rough(
+ compressed,
+ system_prompt=new_system_prompt or "",
+ tools=agent.tools or None,
+ )
+ agent.context_compressor.last_compression_rough_tokens = _compressed_est
+ agent.context_compressor.last_prompt_tokens = -1
+ agent.context_compressor.last_completion_tokens = 0
+ agent.context_compressor.awaiting_real_usage_after_compression = True
+
+ # Clear the file-read dedup cache. After compression the original
+ # read content is summarised away — if the model re-reads the same
+ # file it needs the full content, not a "file unchanged" stub.
+ try:
+ from tools.file_tools import reset_file_dedup
+ reset_file_dedup(task_id)
+ except Exception:
+ pass
+
+ logger.info(
+ "context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
+ agent.session_id or "none", _pre_msg_count, len(compressed),
+ f"{_compressed_est:,}",
+ )
+ return compressed, new_system_prompt
+ finally:
+ # Release the lock on the OLD session_id only AFTER rotation completed
+ # and all post-rotation bookkeeping (memory manager, context engine,
+ # file dedup) ran. A concurrent path that wakes up the moment we
+ # release will see the NEW session_id in state.db / SessionEntry and
+ # acquire on that — no race against our just-finished work.
+ _release_lock()
+
+
+def _compress_context_via_codex_app_server(
+ agent: Any,
+ messages: list,
+ system_message: Optional[str],
+ *,
+ approx_tokens: Optional[int] = None,
+ task_id: str = "default",
+ force: bool = False,
+) -> Tuple[list, str]:
+ """Route compaction to Codex app-server for Codex-owned threads.
+
+ Hermes' normal compressor rewrites the local OpenAI-style transcript.
+ That does not shrink the actual Codex app-server thread context. For this
+ runtime, ask Codex to compact its own thread and keep Hermes' transcript
+ unchanged.
+ """
+ auto_mode = str(
+ getattr(agent, "codex_app_server_auto_compaction", "native") or "native"
+ ).lower()
+ if auto_mode not in {"native", "hermes", "off"}:
+ auto_mode = "native"
+ if not force and auto_mode != "hermes":
+ logger.info(
+ "codex app-server compaction skipped: mode=%s force=false "
+ "(session=%s messages=%d tokens=~%s)",
+ auto_mode,
+ getattr(agent, "session_id", None) or "none",
+ len(messages),
+ f"{approx_tokens:,}" if approx_tokens else "unknown",
+ )
+ existing_prompt = getattr(agent, "_cached_system_prompt", None)
+ if not existing_prompt:
+ existing_prompt = agent._build_system_prompt(system_message)
+ return messages, existing_prompt
+
+ codex_session = getattr(agent, "_codex_session", None)
+ if codex_session is None:
+ logger.info(
+ "codex app-server compaction skipped: no active codex thread "
+ "(session=%s messages=%d tokens=~%s)",
+ getattr(agent, "session_id", None) or "none",
+ len(messages),
+ f"{approx_tokens:,}" if approx_tokens else "unknown",
+ )
+ existing_prompt = getattr(agent, "_cached_system_prompt", None)
+ if not existing_prompt:
+ existing_prompt = agent._build_system_prompt(system_message)
+ return messages, existing_prompt
+
+ logger.info(
+ "codex app-server compaction started: session=%s messages=%d tokens=~%s",
+ getattr(agent, "session_id", None) or "none",
+ len(messages),
+ f"{approx_tokens:,}" if approx_tokens else "unknown",
+ )
try:
- _old_sid = locals().get("old_session_id")
- if _old_sid and agent._memory_manager:
- agent._memory_manager.on_session_switch(
- agent.session_id or "",
- parent_session_id=_old_sid,
- reset=False,
- reason="compression",
+ agent._emit_status(COMPACTION_STATUS)
+ except Exception:
+ pass
+
+ result = codex_session.compact_thread()
+ if getattr(result, "should_retire", False):
+ try:
+ codex_session.close()
+ except Exception:
+ pass
+ agent._codex_session = None
+
+ if getattr(result, "error", None):
+ try:
+ agent._emit_warning(
+ f"⚠ Codex app-server compaction failed: {result.error}"
)
- except Exception as _me_err:
- logger.debug("memory manager on_session_switch (compression): %s", _me_err)
-
- # Warn on repeated compressions (quality degrades with each pass)
- _cc = agent.context_compressor.compression_count
- if _cc >= 2:
- agent._vprint(
- f"{agent.log_prefix}⚠️ Session compressed {_cc} times — "
- f"accuracy may degrade. Consider /new to start fresh.",
+ except Exception:
+ pass
+ existing_prompt = getattr(agent, "_cached_system_prompt", None)
+ if not existing_prompt:
+ existing_prompt = agent._build_system_prompt(system_message)
+ return messages, existing_prompt
+
+ try:
+ from agent.codex_runtime import (
+ _record_codex_app_server_compaction,
+ _record_codex_app_server_usage,
+ )
+
+ _record_codex_app_server_compaction(
+ agent,
+ result,
+ approx_tokens=approx_tokens,
force=True,
)
+ if getattr(result, "token_usage_last", None):
+ _record_codex_app_server_usage(agent, result)
+ except Exception:
+ logger.debug("codex compaction bookkeeping failed", exc_info=True)
- # 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")
- 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 "",
- "compression_count": agent.context_compressor.compression_count,
- })
- except Exception as e:
- logger.debug("event_callback error on session:compress: %s", e)
-
- # 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.
- _compressed_est = estimate_request_tokens_rough(
- compressed,
- system_prompt=new_system_prompt or "",
- tools=agent.tools or None,
- )
- agent.context_compressor.last_compression_rough_tokens = _compressed_est
- agent.context_compressor.last_prompt_tokens = -1
- agent.context_compressor.last_completion_tokens = 0
- agent.context_compressor.awaiting_real_usage_after_compression = True
-
- # Clear the file-read dedup cache. After compression the original
- # read content is summarised away — if the model re-reads the same
- # file it needs the full content, not a "file unchanged" stub.
try:
from tools.file_tools import reset_file_dedup
+
reset_file_dedup(task_id)
except Exception:
pass
logger.info(
- "context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
- agent.session_id or "none", _pre_msg_count, len(compressed),
- f"{_compressed_est:,}",
+ "codex app-server compaction done: session=%s thread=%s turn=%s",
+ getattr(agent, "session_id", None) or "none",
+ getattr(result, "thread_id", None) or "",
+ getattr(result, "turn_id", None) or "",
)
- # Release the lock on the OLD session_id only AFTER rotation completed
- # and all post-rotation bookkeeping (memory manager, context engine,
- # file dedup) ran. A concurrent path that wakes up the moment we
- # release will see the NEW session_id in state.db / SessionEntry and
- # acquire on that — no race against our just-finished work.
- _release_lock()
- return compressed, new_system_prompt
+ existing_prompt = getattr(agent, "_cached_system_prompt", None)
+ if not existing_prompt:
+ existing_prompt = agent._build_system_prompt(system_message)
+ return messages, existing_prompt
def try_shrink_image_parts_in_messages(
@@ -666,10 +1115,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.
@@ -702,33 +1152,58 @@ def try_shrink_image_parts_in_messages(
# actually brought under the target.
unshrinkable_oversized = 0
- def _shrink_data_url(url: str) -> Optional[str]:
- """Return a smaller data URL, or None if shrink can't help."""
- if not isinstance(url, str) or not url.startswith("data:"):
+ def _decode_pixels(data_url: str) -> Optional[tuple]:
+ """Return ``(width, height)`` of a base64 data URL, or None on failure.
+
+ Soft-depends on Pillow; returns None (caller falls back to a
+ bytes-only check) if Pillow is missing or the payload is corrupt.
+ """
+ try:
+ import base64 as _b64_dim
+ import io as _io_dim
+ header_d, _, data_d = data_url.partition(",")
+ if not data_d or not data_url.startswith("data:"):
+ return None
+ from PIL import Image as _PILImage
+ with _PILImage.open(_io_dim.BytesIO(_b64_dim.b64decode(data_d))) as _img:
+ return _img.size
+ except Exception:
return None
- # Check both byte size AND pixel dimensions.
+ def _shrink_data_url(url: str) -> tuple:
+ """Return ``(resized_url, unshrinkable)`` for a data URL.
+
+ ``resized_url`` is a smaller/dimension-correct data URL, or None when
+ no rewrite was applied. ``unshrinkable`` is True only when the image
+ exceeded a constraint (byte-size or dimensions) and the resize failed
+ to satisfy *that same* constraint — so the caller knows retrying is
+ pointless even if a different image in the request shrank.
+ """
+ if not isinstance(url, str) or not url.startswith("data:"):
+ return None, False
+
+ # Determine which constraint is binding. The accept/reject gate below
+ # MUST be checked against the same axis that triggered the shrink: a
+ # downscaled screenshot PNG routinely re-encodes to *more* bytes than
+ # the original (PNG compression is non-monotonic in image size — a
+ # smaller raster with LANCZOS resampling noise compresses worse than a
+ # larger smooth one). Rejecting a pixel-correct downscale purely
+ # because its bytes grew permanently wedges sessions on the Anthropic
+ # many-image 2000px path (#48013).
needs_shrink = len(url) > target_bytes # over byte budget
+ triggered_by = "bytes" if needs_shrink else None
if not needs_shrink:
- # Even if bytes are fine, check pixel dimensions against the
- # provider's reported per-side cap. A screenshot can be tiny in
- # bytes yet too large in pixels.
- try:
- import base64 as _b64_dim
- header_d, _, data_d = url.partition(",")
- if not data_d:
- return None
- raw_d = _b64_dim.b64decode(data_d)
- from PIL import Image as _PILImage
- import io as _io_dim
- with _PILImage.open(_io_dim.BytesIO(raw_d)) as _img:
- if max(_img.size) <= max_dimension:
- return None # both bytes and pixels are fine
- needs_shrink = True # pixels exceed limit, force shrink
- except Exception:
- # If we can't check dimensions (Pillow unavailable, corrupt
- # image, etc.), fall back to byte-only check.
- return None
+ # Bytes are fine — check pixel dimensions against the provider's
+ # reported per-side cap. A screenshot can be tiny in bytes yet
+ # too large in pixels.
+ dims = _decode_pixels(url)
+ if dims is None:
+ # Pillow missing or corrupt data — fall back to byte-only.
+ return None, False
+ if max(dims) <= max_dimension:
+ return None, False # both bytes and pixels are within limits
+ needs_shrink = True
+ triggered_by = "dimension"
try:
header, _, data = url.partition(",")
@@ -760,13 +1235,67 @@ def _shrink_data_url(url: str) -> Optional[str]:
Path(tmp.name).unlink(missing_ok=True)
except Exception:
pass
- if not resized or len(resized) >= len(url):
- # Shrink didn't help (or made it bigger — corrupt input?).
- return None
- return resized
+ if not resized:
+ # Resize returned nothing — Pillow couldn't help.
+ return None, True
+ if triggered_by == "bytes":
+ # Byte budget is the binding constraint — bytes must shrink.
+ if len(resized) >= len(url):
+ return None, True # re-encode made it bigger
+ # The per-side dimension cap is ALSO an active provider
+ # constraint on this request (the caller passes the parsed cap
+ # to both this helper and the resizer). _resize_image_for_vision
+ # returns a best-effort, possibly-over-cap blob when it
+ # exhausts its halving budget — it freezes the long side once
+ # the short side hits its 64px floor, so a very-high-aspect
+ # image can stay over the cap even after bytes shrank. If the
+ # output is still over the cap, retrying would re-400 on
+ # dimensions; treat it as unshrinkable. (Skip when dims can't
+ # be decoded — preserves historical byte-only behaviour.)
+ new_dims = _decode_pixels(resized)
+ if new_dims is not None and max(new_dims) > max_dimension:
+ return None, True
+ return resized, False
+ # triggered_by == "dimension": the per-side cap is binding. The
+ # re-encode may have grown in bytes; accept it as long as it is now
+ # within the dimension cap. Verify the new dimensions when we can.
+ new_dims = _decode_pixels(resized)
+ if new_dims is not None:
+ if max(new_dims) <= max_dimension:
+ return resized, False
+ # Still over the per-side cap — the resize didn't satisfy it.
+ return None, True
+ # Couldn't verify the re-encode's dimensions (corrupt output or
+ # Pillow gone mid-call). Fall back to the historical "bytes must
+ # shrink" gate so we never accept an unverifiable, byte-larger blob.
+ if len(resized) >= len(url):
+ return None, True
+ return resized, False
except Exception as exc:
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):
@@ -778,6 +1307,16 @@ def _shrink_data_url(url: str) -> Optional[str]:
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")
@@ -785,20 +1324,18 @@ def _shrink_data_url(url: str) -> Optional[str]:
# OpenAI Responses: {"image_url": "data:..."}
if isinstance(image_value, dict):
url = image_value.get("url", "")
- resized = _shrink_data_url(url)
+ resized, unshrinkable = _shrink_data_url(url)
if resized:
image_value["url"] = resized
changed_count += 1
- elif isinstance(url, str) and url.startswith("data:") \
- and len(url) > target_bytes:
+ elif unshrinkable:
unshrinkable_oversized += 1
elif isinstance(image_value, str):
- resized = _shrink_data_url(image_value)
+ resized, unshrinkable = _shrink_data_url(image_value)
if resized:
part["image_url"] = resized
changed_count += 1
- elif image_value.startswith("data:") \
- and len(image_value) > target_bytes:
+ elif unshrinkable:
unshrinkable_oversized += 1
if changed_count:
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index 099cefd36e15..cbcb85617013 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,
@@ -50,12 +52,18 @@
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
get_context_length_from_provider_error,
+ is_output_cap_error,
parse_available_output_tokens_from_error,
save_context_length,
)
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,
+ is_zai_coding_overload_error,
+ jittered_backoff,
+ zai_coding_overload_retry_ceiling,
+)
from agent.trajectory import has_incomplete_scratchpad
from agent.usage_pricing import estimate_usage_cost, normalize_usage
from hermes_constants import PARTIAL_STREAM_STUB_ID
@@ -202,6 +210,26 @@ def _billing_or_entitlement_message(
provider_label = (provider or "").strip() or "the selected provider"
model_label = (model or "").strip() or "the selected model"
+
+ # Anthropic Claude Pro/Max OAuth subscriptions surface exhaustion of the
+ # metered "extra usage" bucket as a hard 400 ("You're out of extra
+ # usage"). Point at the exact settings page and note the cycle-reset
+ # option, since the generic "add credits with that provider" line doesn't
+ # apply to a subscription — the user waits for the reset or switches to an
+ # API key.
+ if (provider or "").strip().lower() == "anthropic":
+ lines = [
+ (
+ f"{provider_label} reported that your Claude subscription usage is "
+ f"exhausted for {model_label} (included quota + extra-usage credits)."
+ ),
+ "Options: wait for the billing cycle to reset, or add extra usage at "
+ "https://claude.ai/settings/usage",
+ "You can also switch to an Anthropic API key or another provider with "
+ "/model --provider .",
+ ]
+ return "\n".join(lines)
+
lines = [
(
f"{provider_label} reported that billing, credits, or account "
@@ -466,6 +494,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 +529,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 +552,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 +614,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 +850,29 @@ def run_conversation(
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
+ if moa_config:
+ try:
+ from agent.moa_loop import _preset_temperature, aggregate_moa_context
+
+ _moa_context = aggregate_moa_context(
+ user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
+ api_messages=api_messages,
+ reference_models=moa_config.get("reference_models") or [],
+ aggregator=moa_config.get("aggregator") or {},
+ temperature=_preset_temperature(moa_config, "reference_temperature"),
+ aggregator_temperature=_preset_temperature(moa_config, "aggregator_temperature"),
+ max_tokens=moa_config.get("reference_max_tokens"),
+ )
+ if _moa_context:
+ 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:
@@ -853,15 +951,20 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
- # Calculate approximate request size for logging
+ # Calculate approximate request size for logging and pressure checks.
+ # estimate_messages_tokens_rough(api_messages) includes the system
+ # prompt copy but not the tool schema payload, which is sent as a
+ # separate field. Add tools back for compression decisions so long
+ # tool-heavy turns do not creep up to the context ceiling and leave
+ # no room for the model's final answer.
total_chars = sum(len(str(msg)) for msg in api_messages)
approx_tokens = estimate_messages_tokens_rough(api_messages)
- approx_request_tokens = estimate_request_tokens_rough(
+ request_pressure_tokens = estimate_request_tokens_rough(
api_messages, tools=agent.tools or None
)
_runtime_context_error = _ollama_context_limit_error(
- agent, approx_request_tokens
+ agent, request_pressure_tokens
)
if _runtime_context_error:
final_response = _runtime_context_error
@@ -876,6 +979,83 @@ def run_conversation(
except Exception:
pass
break
+
+ # Pre-API pressure check. The turn-prologue preflight only saw the
+ # incoming user message; a single turn can then grow by many large
+ # tool results and leave no output budget before the NEXT call (the
+ # live 271k/272k Codex failure). The post-response should_compress
+ # gate at the tool-loop tail uses API-reported last_prompt_tokens,
+ # which LAGS a just-appended huge tool result — so it misses this
+ # case. Re-check here against the current request estimate.
+ #
+ # Mirror the turn-prologue preflight's guard chain exactly (see
+ # turn_context.py): (1) defer when the rough estimate is known-noisy
+ # relative to a recent real provider prompt that fit under threshold
+ # (schema overhead / post-compaction over-count, #36718); (2) skip
+ # while a same-session compression-failure cooldown is active; (3) then
+ # should_compress() — reusing the canonical threshold_tokens (output
+ # room already reserved by _compute_threshold_tokens) and its summary-
+ # LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
+ # hard per-turn backstop shared with the overflow error handlers.
+ _compressor = agent.context_compressor
+ _defer_preflight = getattr(
+ _compressor, "should_defer_preflight_to_real_usage", lambda _t: False
+ )
+ _compression_cooldown = getattr(
+ _compressor, "get_active_compression_failure_cooldown", lambda: None
+ )()
+ if (
+ agent.compression_enabled
+ and len(messages) > 1
+ and compression_attempts < 3
+ and not _defer_preflight(request_pressure_tokens)
+ and not _compression_cooldown
+ and _compressor.should_compress(request_pressure_tokens)
+ ):
+ compression_attempts += 1
+ logger.info(
+ "Pre-API compression: ~%s request tokens >= %s threshold "
+ "(context=%s, attempt=%s/3)",
+ f"{request_pressure_tokens:,}",
+ f"{int(getattr(_compressor, 'threshold_tokens', 0) or 0):,}",
+ f"{int(getattr(_compressor, 'context_length', 0) or 0):,}"
+ if getattr(_compressor, "context_length", 0) else "unknown",
+ compression_attempts,
+ )
+ agent._emit_status(
+ f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
+ f"near the context/output limit. Compacting before the next model call."
+ )
+ messages, active_system_prompt = agent._compress_context(
+ messages,
+ system_message,
+ approx_tokens=request_pressure_tokens,
+ task_id=effective_task_id,
+ )
+ # Reset retry/empty-response state so the compacted request
+ # gets a fresh chance instead of inheriting stale recovery
+ # counters from the pre-compaction history.
+ agent._empty_content_retries = 0
+ agent._thinking_prefill_retries = 0
+ agent._last_content_with_tools = None
+ agent._last_content_tools_all_housekeeping = False
+ agent._mute_post_response = False
+ # Re-baseline the flush cursor for the compaction mode that just
+ # ran. Legacy session-rotation returns None (the child session has
+ # not seen the compacted transcript, so the next flush writes it
+ # whole); in-place compaction returns list(messages) because the
+ # compacted rows are already persisted under the same session id —
+ # leaving None there would re-append them, doubling the active
+ # context and retriggering compression. Mirrors the post-response
+ # and preflight compaction sites; see
+ # conversation_history_after_compression().
+ conversation_history = conversation_history_after_compression(
+ agent, messages
+ )
+ api_call_count -= 1
+ agent._api_call_count = api_call_count
+ agent.iteration_budget.refund()
+ continue
# Thinking spinner for quiet mode (animated during API call)
thinking_spinner = None
@@ -940,6 +1120,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
@@ -981,6 +1163,14 @@ def run_conversation(
_sanitize_structure_non_ascii(api_kwargs)
if agent.api_mode == "codex_responses":
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
+ # Copilot x-initiator: the first API call of a user turn is
+ # marked "user" so Copilot bills a premium request; tool-loop
+ # follow-ups keep the default "agent" header (#3040).
+ if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url():
+ _xh = dict(api_kwargs.get("extra_headers") or {})
+ _xh["x-initiator"] = "user"
+ api_kwargs["extra_headers"] = _xh
+ agent._is_user_initiated_turn = False
try:
from hermes_cli.middleware import apply_llm_request_middleware
@@ -1094,11 +1284,22 @@ 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"}
or str(agent.base_url or "").lower().startswith("acp://copilot")
or str(agent.base_url or "").lower().startswith("acp+tcp://")
):
_use_streaming = False
+ # MoA streams only when a display/TTS consumer is present to
+ # receive the deltas. MoAChatCompletions.create() honors
+ # stream=True (runs the references, then returns the aggregator's
+ # raw token stream) and is reached here because, for provider
+ # "moa", _create_request_openai_client returns the MoA facade
+ # itself. Without consumers (quiet mode, subagents, health-check
+ # probes) we keep the complete-response path: the facade returns a
+ # whole response when stream is not requested, preserving the
+ # prior behavior for those callers.
+ elif agent.provider == "moa" and not agent._has_stream_consumers():
+ _use_streaming = False
elif not agent._has_stream_consumers():
# No display/TTS consumer. Still prefer streaming for
# health checking, but skip for Mock clients in tests
@@ -1265,6 +1466,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
@@ -1311,7 +1514,7 @@ def _perform_api_call(next_api_kwargs):
elif _resp_error_code == 504:
_failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)"
elif _resp_error_code == 429:
- _failure_hint = f"rate limited by upstream provider (429)"
+ _failure_hint = "rate limited by upstream provider (429)"
elif _resp_error_code in {500, 502}:
_failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)"
elif _resp_error_code in {503, 529}:
@@ -1336,6 +1539,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
@@ -1345,11 +1550,13 @@ def _perform_api_call(next_api_kwargs):
agent._emit_status(f"❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.")
logger.error(f"{agent.log_prefix}Invalid API response after {max_retries} retries.")
agent._persist_session(messages, conversation_history)
+ _final_response = f"Invalid API response after {max_retries} retries: {_failure_hint}"
return {
+ "final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": f"Invalid API response after {max_retries} retries: {_failure_hint}",
+ "error": _final_response,
"failed": True # Mark as failure for filtering
}
@@ -1364,10 +1571,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,
@@ -1394,7 +1603,14 @@ def _perform_api_call(next_api_kwargs):
else:
incomplete_reason = getattr(incomplete_details, "reason", None)
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
- finish_reason = "length"
+ # Responses API max-output exhaustion is a normal
+ # Codex incomplete turn. Let the Codex-specific
+ # continuation path below append the incomplete
+ # assistant state and retry, instead of routing to
+ # the generic chat-completions length rollback that
+ # emits "Response truncated due to output length
+ # limit" and stops gateway turns.
+ finish_reason = "incomplete"
else:
finish_reason = "stop"
elif agent.api_mode == "anthropic_messages":
@@ -1479,6 +1695,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 +1836,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)
@@ -1625,7 +1893,7 @@ def _perform_api_call(next_api_kwargs):
if assistant_message.content:
truncated_response_parts.append(assistant_message.content)
- if length_continue_retries < 3:
+ if length_continue_retries < 4:
_is_partial_stream_stub = (
getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
)
@@ -1639,18 +1907,18 @@ def _perform_api_call(next_api_kwargs):
f"{agent.log_prefix}↻ Stream interrupted mid "
f"tool-call ({_tool_list}) — requesting "
f"chunked retry "
- f"({length_continue_retries}/3)..."
+ f"({length_continue_retries}/4)..."
)
elif _is_partial_stream_stub:
agent._vprint(
f"{agent.log_prefix}↻ Stream interrupted — "
f"requesting continuation "
- f"({length_continue_retries}/3)..."
+ f"({length_continue_retries}/4)..."
)
else:
agent._vprint(
f"{agent.log_prefix}↻ Requesting continuation "
- f"({length_continue_retries}/3)..."
+ f"({length_continue_retries}/4)..."
)
_continue_content = _get_continuation_prompt(
@@ -1674,7 +1942,7 @@ def _perform_api_call(next_api_kwargs):
"api_calls": api_call_count,
"completed": False,
"partial": True,
- "error": "Response remained truncated after 3 continuation attempts",
+ "error": "Response remained truncated after 4 continuation attempts",
}
if agent.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}:
@@ -1683,7 +1951,7 @@ def _perform_api_call(next_api_kwargs):
_is_stub_stall = (
getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
)
- if truncated_tool_call_retries < 3:
+ if truncated_tool_call_retries < 4:
truncated_tool_call_retries += 1
if _is_stub_stall:
# The stream broke mid tool-call (network /
@@ -1691,13 +1959,13 @@ def _perform_api_call(next_api_kwargs):
# cap — say so instead of "max output tokens".
agent._buffer_vprint(
f"⚠️ Stream interrupted mid tool-call — "
- f"retrying ({truncated_tool_call_retries}/3)..."
+ f"retrying ({truncated_tool_call_retries}/4)..."
)
else:
agent._buffer_vprint(
f"⚠️ Truncated tool call detected — "
f"retrying API call "
- f"({truncated_tool_call_retries}/3)..."
+ f"({truncated_tool_call_retries}/4)..."
)
# Boost max_tokens on each retry so the model has
# more room to complete the tool-call JSON. A
@@ -1705,7 +1973,7 @@ def _perform_api_call(next_api_kwargs):
# a genuine output-cap truncation does, and the
# boost is harmless for the stall case.
_tc_boost_base = agent.max_tokens if agent.max_tokens else 4096
- _tc_boost = _tc_boost_base * (truncated_tool_call_retries + 1)
+ _tc_boost = _tc_boost_base * (2 ** truncated_tool_call_retries)
_tc_requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs)
if _tc_requested_cap is not None:
_tc_boost = max(_tc_boost, _tc_requested_cap)
@@ -1718,7 +1986,7 @@ def _perform_api_call(next_api_kwargs):
agent._flush_status_buffer()
if _is_stub_stall:
agent._vprint(
- f"{agent.log_prefix}⚠️ Stream kept dropping mid tool-call after 3 retries — the action was not executed.",
+ f"{agent.log_prefix}⚠️ Stream kept dropping mid tool-call after 4 retries — the action was not executed.",
force=True,
)
else:
@@ -1728,18 +1996,19 @@ def _perform_api_call(next_api_kwargs):
)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
+ _final_response = (
+ "Stream repeatedly dropped mid tool-call (network); "
+ "the tool was not executed"
+ if _is_stub_stall
+ else "Response truncated due to output length limit"
+ )
return {
- "final_response": None,
+ "final_response": _final_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"partial": True,
- "error": (
- "Stream repeatedly dropped mid tool-call (network); "
- "the tool was not executed"
- if _is_stub_stall
- else "Response truncated due to output length limit"
- ),
+ "error": _final_response,
}
# If we have prior messages, roll back to last complete state
@@ -1751,7 +2020,7 @@ def _perform_api_call(next_api_kwargs):
agent._persist_session(messages, conversation_history)
return {
- "final_response": None,
+ "final_response": "Response truncated due to output length limit",
"messages": rolled_back_messages,
"api_calls": api_call_count,
"completed": False,
@@ -1764,7 +2033,7 @@ def _perform_api_call(next_api_kwargs):
agent._vprint(f"{agent.log_prefix}❌ First response truncated - cannot recover", force=True)
agent._persist_session(messages, conversation_history)
return {
- "final_response": None,
+ "final_response": "First response truncated due to output length limit",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -1779,6 +2048,44 @@ def _perform_api_call(next_api_kwargs):
provider=agent.provider,
api_mode=agent.api_mode,
)
+ # Aggregator-only usage is retained for cost pricing: MoA
+ # advisor tokens must be priced at each advisor's OWN model
+ # rate, not the aggregator's, so they are added as dollars
+ # (below) rather than folded into the priced usage.
+ aggregator_usage = canonical_usage
+ # MoA: fold the reference (advisor) fan-out's token usage
+ # into this turn's REPORTED token counts. MoA runs advisors
+ # before the aggregator and returns only the aggregator's
+ # usage, so without this the entire advisor spend — usually
+ # the bulk of a MoA turn — is invisible in token counts.
+ _moa_ref_cost = None
+ _moa_client = getattr(agent, "client", None)
+ if _moa_client is not None and hasattr(_moa_client, "consume_reference_usage"):
+ try:
+ _ref_usage, _moa_ref_cost = _moa_client.consume_reference_usage()
+ if _ref_usage is not None:
+ canonical_usage = canonical_usage + _ref_usage
+ except Exception as _moa_acct_exc: # pragma: no cover - defensive
+ logger.debug("MoA reference usage accounting failed: %s", _moa_acct_exc)
+ # Flush the full-turn MoA trace (references + aggregator I/O)
+ # to disk when moa.save_traces is on. No-op otherwise and
+ # for non-MoA clients. Uses the live session_id so traces
+ # land in the right per-session file. On the streaming path
+ # the aggregator's output wasn't captured inline (its raw
+ # token stream went to the live consumer), so pass the
+ # resolved streamed acting text as a fallback — makes the
+ # trace self-contained instead of only pointing at state.db.
+ if _moa_client is not None and hasattr(_moa_client, "consume_and_save_trace"):
+ try:
+ _agg_streamed_text = (
+ getattr(agent, "_current_streamed_assistant_text", "") or ""
+ )
+ _moa_client.consume_and_save_trace(
+ agent.session_id,
+ aggregator_output_fallback=_agg_streamed_text or None,
+ )
+ except Exception as _moa_trace_exc: # pragma: no cover - defensive
+ logger.debug("MoA trace flush failed: %s", _moa_trace_exc)
prompt_tokens = canonical_usage.prompt_tokens
completion_tokens = canonical_usage.output_tokens
total_tokens = canonical_usage.total_tokens
@@ -1830,15 +2137,38 @@ def _perform_api_call(next_api_kwargs):
api_duration, _cache_pct,
)
+ # On the MoA path, agent.model/provider are the virtual
+ # preset name ("closed") and "moa", which have no pricing
+ # entry — estimating against them returns None and silently
+ # drops the aggregator's own spend, leaving the session cost
+ # as advisor-fan-out only (a ~50% undercount when the
+ # aggregator does the full acting loop). Price the aggregator
+ # turn at its REAL model/provider, read from the MoA client's
+ # resolved aggregator slot.
+ _agg_cost_model = agent.model
+ _agg_cost_provider = agent.provider
+ _agg_cost_base_url = agent.base_url
+ _agg_slot = getattr(_moa_client, "last_aggregator_slot", None) if _moa_client is not None else None
+ if _agg_slot and _agg_slot.get("model"):
+ _agg_cost_model = _agg_slot["model"]
+ _agg_cost_provider = _agg_slot.get("provider") or agent.provider
+ _agg_cost_base_url = _agg_slot.get("base_url") or agent.base_url
cost_result = estimate_usage_cost(
- agent.model,
- canonical_usage,
- provider=agent.provider,
- base_url=agent.base_url,
+ _agg_cost_model,
+ aggregator_usage,
+ provider=_agg_cost_provider,
+ base_url=_agg_cost_base_url,
api_key=getattr(agent, "api_key", ""),
)
if cost_result.amount_usd is not None:
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
+ # Add MoA advisor cost (already priced per-advisor at each
+ # advisor's own model rate) on top of the aggregator cost.
+ if _moa_ref_cost is not None:
+ try:
+ agent.session_estimated_cost_usd += float(_moa_ref_cost)
+ except (TypeError, ValueError): # pragma: no cover - defensive
+ pass
agent.session_cost_status = cost_result.status
agent.session_cost_source = cost_result.source
@@ -1859,6 +2189,18 @@ def _perform_api_call(next_api_kwargs):
# affects 0 rows without error).
if not agent._session_db_created:
agent._ensure_db_session()
+ # Per-call cost delta = aggregator cost + MoA
+ # advisor cost (each priced at its own rate). Folded
+ # here so state.db's estimated_cost_usd includes the
+ # full MoA spend, matching the folded token counts.
+ _cost_delta = None
+ if cost_result.amount_usd is not None:
+ _cost_delta = float(cost_result.amount_usd)
+ if _moa_ref_cost is not None:
+ try:
+ _cost_delta = (_cost_delta or 0.0) + float(_moa_ref_cost)
+ except (TypeError, ValueError): # pragma: no cover
+ pass
agent._session_db.update_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
@@ -1866,8 +2208,7 @@ def _perform_api_call(next_api_kwargs):
cache_read_tokens=canonical_usage.cache_read_tokens,
cache_write_tokens=canonical_usage.cache_write_tokens,
reasoning_tokens=canonical_usage.reasoning_tokens,
- estimated_cost_usd=float(cost_result.amount_usd)
- if cost_result.amount_usd is not None else None,
+ estimated_cost_usd=_cost_delta,
cost_status=cost_result.status,
cost_source=cost_result.source,
billing_provider=agent.provider,
@@ -1937,9 +2278,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:
@@ -2002,11 +2355,11 @@ def _perform_api_call(next_api_kwargs):
agent._unicode_sanitization_passes += 1
if _surrogates_found:
agent._buffer_vprint(
- f"⚠️ Stripped invalid surrogate characters from messages. Retrying..."
+ "⚠️ Stripped invalid surrogate characters from messages. Retrying..."
)
else:
agent._buffer_vprint(
- f"⚠️ Surrogate encoding error — retrying after full-payload sanitization..."
+ "⚠️ Surrogate encoding error — retrying after full-payload sanitization..."
)
continue
if _is_ascii_codec:
@@ -2173,6 +2526,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(
@@ -2355,6 +2717,16 @@ def _perform_api_call(next_api_kwargs):
_label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex"
agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...")
continue
+ if (
+ agent.api_mode == "chat_completions"
+ and agent.provider == "vertex"
+ and status_code == 401
+ and not _retry.vertex_auth_retry_attempted
+ ):
+ _retry.vertex_auth_retry_attempted = True
+ if agent._try_refresh_vertex_client_credentials():
+ agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...")
+ continue
if (
agent.api_mode == "chat_completions"
and agent.provider == "nous"
@@ -2394,7 +2766,7 @@ def _perform_api_call(next_api_kwargs):
):
_retry.copilot_auth_retry_attempted = True
if agent._try_refresh_copilot_client_credentials():
- agent._buffer_vprint(f"🔐 Copilot credentials refreshed after 401. Retrying request...")
+ agent._buffer_vprint("🔐 Copilot credentials refreshed after 401. Retrying request...")
continue
if (
agent.api_mode == "anthropic_messages"
@@ -2617,10 +2989,10 @@ def _perform_api_call(next_api_kwargs):
)
if agent.providers_allowed:
agent._buffer_vprint(
- f" Your provider_routing.only restriction is filtering out tool-capable providers."
+ " Your provider_routing.only restriction is filtering out tool-capable providers."
)
agent._buffer_vprint(
- f" Try removing the restriction or adding providers that support tools for this model."
+ " Try removing the restriction or adding providers that support tools for this model."
)
agent._buffer_vprint(
f" Check which providers support tools: https://openrouter.ai/models/{_model}"
@@ -2629,10 +3001,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,
@@ -2685,15 +3059,17 @@ def _perform_api_call(next_api_kwargs):
f"auto-compaction disabled — not compressing."
)
agent._persist_session(messages, conversation_history)
+ _final_response = (
+ "Context overflow and auto-compaction is disabled "
+ "(compression.enabled: false). Run /compress to compact manually, "
+ "/new to start fresh, or switch to a larger-context model."
+ )
return {
+ "final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": (
- "Context overflow and auto-compaction is disabled "
- "(compression.enabled: false). Run /compress to compact manually, "
- "/new to start fresh, or switch to a larger-context model."
- ),
+ "error": _final_response,
"partial": True,
"failed": True,
"compaction_disabled": True,
@@ -2742,10 +3118,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,37 +3132,113 @@ 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,
+ FailoverReason.upstream_rate_limit,
}
- if is_rate_limited and agent._fallback_index < len(agent._fallback_chain):
+ _is_transport_failure = classified.reason in {
+ FailoverReason.timeout,
+ FailoverReason.overloaded,
+ }
+ # Z.AI Coding Plan GLM-5.2 overload 429s classify as
+ # `overloaded` (to spare the credential pool), but `overloaded`
+ # is excluded from `is_rate_limited` — the gate for the adaptive
+ # Z.AI backoff below. Detect the overload directly so its
+ # long-backoff schedule runs, and raise the retry ceiling so the
+ # long tier (30/60/90/120s) is reachable. See
+ # zai_coding_overload_retry_ceiling() for the ceiling rationale.
+ _is_zai_coding_overload = is_zai_coding_overload_error(
+ base_url=str(_base), model=_model, error=api_error
+ )
+ if _is_zai_coding_overload:
+ max_retries = max(max_retries, zai_coding_overload_retry_ceiling())
+ _should_fallback = (
+ is_rate_limited
+ or (_is_transport_failure and retry_count >= 2)
+ )
+ if _should_fallback and agent._fallback_index < len(agent._fallback_chain):
# Don't eagerly fallback if credential pool rotation may
# still recover. See _pool_may_recover_from_rate_limit
- # for the single-credential-pool and CloudCode-quota
- # exceptions. Fixes #11314 and #13636.
- pool_may_recover = _ra()._pool_may_recover_from_rate_limit(
- agent._credential_pool,
- provider=agent.provider,
- base_url=getattr(agent, "base_url", None),
+ # for the single-credential-pool exception. Fixes #11314.
+ #
+ # Exception: an upstream-aggregator 429 — the credential
+ # pool can't help when the *upstream* model (DeepSeek,
+ # etc.) is throttling OpenRouter, so always fall back to a
+ # different model regardless of pool state.
+ _is_upstream = classified.reason == FailoverReason.upstream_rate_limit
+ pool_may_recover = (
+ False if _is_upstream
+ else _ra()._pool_may_recover_from_rate_limit(
+ agent._credential_pool,
+ )
)
if not pool_may_recover:
- if classified.reason == FailoverReason.billing:
+ if _is_upstream:
+ _upstream_name = (classified.error_context or {}).get(
+ "upstream_provider", "aggregator"
+ )
+ agent._buffer_status(
+ f"⚠️ Upstream {_upstream_name} rate-limited — "
+ "switching to fallback model..."
+ )
+ elif classified.reason == FailoverReason.billing:
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
@@ -2902,11 +3353,13 @@ def _perform_api_call(next_api_kwargs):
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logger.error(f"{agent.log_prefix}413 compression failed after {max_compression_attempts} attempts.")
agent._persist_session(messages, conversation_history)
+ _final_response = f"Request payload too large: max compression attempts ({max_compression_attempts}) reached."
return {
+ "final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": f"Request payload too large: max compression attempts ({max_compression_attempts}) reached.",
+ "error": _final_response,
"partial": True,
"failed": True,
"compression_exhausted": True,
@@ -2914,21 +3367,41 @@ 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
+ )
+
+ # 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:
- agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
+ 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
else:
+ if agent._try_strip_image_parts_from_tool_messages(
+ api_messages,
+ remember_model=False,
+ ):
+ agent._buffer_status(
+ "📐 Compression could not reduce the request further — "
+ "removed retained vision payloads and retrying..."
+ )
+ continue
+
# Terminal — surface buffered context so the user
# sees what compression attempts were made.
agent._flush_status_buffer()
@@ -2936,11 +3409,13 @@ def _perform_api_call(next_api_kwargs):
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logger.error(f"{agent.log_prefix}413 payload too large. Cannot compress further.")
agent._persist_session(messages, conversation_history)
+ _final_response = "Request payload too large (413). Cannot compress further."
return {
+ "final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": "Request payload too large (413). Cannot compress further.",
+ "error": _final_response,
"partial": True,
"failed": True,
"compression_exhausted": True,
@@ -2989,11 +3464,13 @@ def _perform_api_call(next_api_kwargs):
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logger.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
agent._persist_session(messages, conversation_history)
+ _final_response = f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached."
return {
+ "final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.",
+ "error": _final_response,
"partial": True,
"failed": True,
"compression_exhausted": True,
@@ -3001,6 +3478,47 @@ def _perform_api_call(next_api_kwargs):
_retry.restart_with_compressed_messages = True
break
+ # The error is output-cap-shaped (about max_tokens being
+ # too large) but the provider's wording didn't let us parse
+ # the available output budget. Compression CANNOT help here
+ # — the input already fits; the call fails deterministically
+ # on the oversized max_tokens. Routing it into compression
+ # re-sends the same max_tokens, gets the identical 400, and
+ # death-loops until "cannot compress further" (#55546).
+ # Fail fast with an actionable message instead of looping.
+ if is_output_cap_error(error_msg):
+ agent._flush_status_buffer()
+ agent._vprint(
+ f"{agent.log_prefix}❌ The provider rejected the request because "
+ f"max_tokens exceeds its output cap for this model.",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} 💡 Lower model.max_tokens in your config.yaml to "
+ f"at or below the model's max-output limit. "
+ f"(This is an output-cap error, not a context overflow — "
+ f"compression cannot fix it.)",
+ force=True,
+ )
+ logger.error(
+ f"{agent.log_prefix}Output-cap error not routed into compression "
+ f"(max_tokens over provider cap): {error_msg[:200]}"
+ )
+ agent._persist_session(messages, conversation_history)
+ _final_response = (
+ "max_tokens exceeds the provider's output cap for this model. "
+ "Lower model.max_tokens in config.yaml."
+ )
+ return {
+ "final_response": _final_response,
+ "messages": messages,
+ "completed": False,
+ "api_calls": api_call_count,
+ "error": _final_response,
+ "partial": True,
+ "failed": True,
+ }
+
# Error is about the INPUT being too large. Only reduce
# context_length when the provider explicitly reports the
# real lower limit. If the provider only says "input
@@ -3058,11 +3576,13 @@ def _perform_api_call(next_api_kwargs):
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
logger.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
agent._persist_session(messages, conversation_history)
+ _final_response = f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached."
return {
+ "final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.",
+ "error": _final_response,
"partial": True,
"failed": True,
"compression_exhausted": True,
@@ -3070,18 +3590,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 +3619,15 @@ 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)
+ _final_response = f"Context length exceeded ({new_tokens:,} tokens). Cannot compress further."
return {
+ "final_response": _final_response,
"messages": messages,
"completed": False,
"api_calls": api_call_count,
- "error": f"Context length exceeded ({approx_tokens:,} tokens). Cannot compress further.",
+ "error": _final_response,
"partial": True,
"failed": True,
"compression_exhausted": True,
@@ -3183,9 +3714,13 @@ def _perform_api_call(next_api_kwargs):
if agent._has_pending_fallback():
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
+ elif classified.reason == FailoverReason.ssl_cert_verification:
+ agent._buffer_status("⚠️ TLS certificate verification failed — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
if agent._try_activate_fallback():
+ active_system_prompt = _sync_failover_system_message(
+ agent, api_messages, active_system_prompt)
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
@@ -3197,15 +3732,27 @@ def _perform_api_call(next_api_kwargs):
# Terminal — flush buffered context so the user sees
# what was tried before the abort.
agent._flush_status_buffer()
+ # Summarize once: Cloudflare/proxy HTML challenge pages and
+ # other raw provider bodies must be collapsed to a short
+ # one-liner here, otherwise the full page leaks into the
+ # returned ``error`` field and downstream consumers deliver
+ # it verbatim (e.g. a cron failure notification dumped a
+ # ~60KB Cloudflare challenge page as 31 Discord messages).
+ _nonretryable_summary = agent._summarize_api_error(api_error)
if classified.reason == FailoverReason.content_policy_blocked:
agent._emit_status(
f"❌ Provider safety filter blocked this request: "
- f"{agent._summarize_api_error(api_error)}"
+ f"{_nonretryable_summary}"
+ )
+ elif classified.reason == FailoverReason.ssl_cert_verification:
+ agent._emit_status(
+ f"❌ TLS certificate verification failed: "
+ f"{_nonretryable_summary}"
)
else:
agent._emit_status(
f"❌ Non-retryable error (HTTP {status_code}): "
- f"{agent._summarize_api_error(api_error)}"
+ f"{_nonretryable_summary}"
)
agent._vprint(f"{agent.log_prefix}❌ Non-retryable client error (HTTP {status_code}). Aborting.", force=True)
agent._vprint(f"{agent.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
@@ -3275,6 +3822,43 @@ def _perform_api_call(next_api_kwargs):
f"{agent.log_prefix} hermes fallback add (interactive picker — same as `hermes model`)",
force=True,
)
+ # TLS certificate failures are environment problems, not
+ # provider/prompt problems — tell the user exactly which
+ # knobs fix each common cause. Inspired by Claude Code
+ # v2.1.199's immediate SSL fix hints.
+ if classified.reason == FailoverReason.ssl_cert_verification:
+ agent._vprint(
+ f"{agent.log_prefix} 💡 The TLS certificate chain could not be verified. This fails the same",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} way on every retry — fix the environment, then try again:",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} • Corporate TLS-inspecting proxy? Point Python at its CA bundle:",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} export SSL_CERT_FILE=/path/to/corp-ca.pem (also REQUESTS_CA_BUNDLE)",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} • Missing/stale system CA store? Install/refresh it:",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} pip install --upgrade certifi (macOS: run 'Install Certificates.command')",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} • Self-signed local endpoint (llama.cpp, LM Studio, vLLM)? Use http://",
+ force=True,
+ )
+ agent._vprint(
+ f"{agent.log_prefix} for localhost, or add the server's cert to your trust store.",
+ force=True,
+ )
logger.error(f"{agent.log_prefix}Non-retryable client error: {api_error}")
# Skip session persistence when the error is likely
# context-overflow related (status 400 + large session).
@@ -3290,26 +3874,25 @@ def _perform_api_call(next_api_kwargs):
else:
agent._persist_session(messages, conversation_history)
if classified.reason == FailoverReason.content_policy_blocked:
- _summary = agent._summarize_api_error(api_error)
_policy_response = (
"⚠️ The model provider's safety filter blocked this request "
"(not a Hermes/gateway failure).\n\n"
- f"Provider message: {_summary}\n\n"
+ f"Provider message: {_nonretryable_summary}\n\n"
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_policy_response,
- error_detail=_summary,
+ error_detail=_nonretryable_summary,
)
return {
- "final_response": None,
+ "final_response": _nonretryable_summary,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
- "error": str(api_error),
+ "error": _nonretryable_summary,
}
if retry_count >= max_retries:
@@ -3322,11 +3905,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
@@ -3385,6 +3977,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,
@@ -3401,7 +4052,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 "
@@ -3433,20 +4099,48 @@ 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)
- if is_rate_limited:
- agent._buffer_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
+ _backoff_policy = None
+ if (is_rate_limited or _is_zai_coding_overload) and not _retry_after:
+ wait_time, _backoff_policy = adaptive_rate_limit_backoff(
+ retry_count,
+ base_url=str(_base),
+ model=_model,
+ error=api_error,
+ default_wait=wait_time,
+ )
+ if is_rate_limited or _is_zai_coding_overload:
+ _policy_note = ""
+ if _backoff_policy == "zai_coding_overload_long":
+ _policy_note = " (Z.AI Coding overload adaptive long backoff)"
+ elif _backoff_policy == "zai_coding_overload_short":
+ _policy_note = " (Z.AI Coding overload short retry)"
+ _wait_reason = "Provider overloaded" if _is_zai_coding_overload and not is_rate_limited else "Rate limited"
+ _rate_limit_status = f"⏱️ {_wait_reason}. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
+ # Normal retries are buffered to avoid noisy transient chatter. Long
+ # Z.AI Coding waits are different: they can last minutes, so surface
+ # progress immediately instead of making the TUI look frozen.
+ 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
@@ -3456,10 +4150,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,
@@ -3490,15 +4186,27 @@ 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.
+ # Retry 1 → 2× base, retry 2 → 4× base, retry 3 → 8× base,
+ # retry 4 → 16× base, then cap at 32 768.
# Applies to all providers via _ephemeral_max_output_tokens.
# If the original request already used a larger provider/model
# default budget, keep that floor so continuation retries do
# not accidentally downshift to a much smaller cap.
_boost_base = agent.max_tokens if agent.max_tokens else 4096
- _boost = _boost_base * (length_continue_retries + 1)
+ _boost = _boost_base * (2 ** length_continue_retries)
_requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs)
if _requested_cap is not None:
_boost = max(_boost, _requested_cap)
@@ -3621,7 +4329,7 @@ def _perform_api_call(next_api_kwargs):
if has_incomplete_scratchpad(assistant_message.content or ""):
agent._incomplete_scratchpad_retries += 1
- agent._buffer_vprint(f"⚠️ Incomplete detected (opened but never closed)")
+ agent._buffer_vprint("⚠️ Incomplete detected (opened but never closed)")
if agent._incomplete_scratchpad_retries <= 2:
agent._buffer_vprint(f"🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...")
@@ -3638,7 +4346,7 @@ def _perform_api_call(next_api_kwargs):
agent._persist_session(messages, conversation_history)
return {
- "final_response": None,
+ "final_response": "Incomplete REASONING_SCRATCHPAD after 2 retries",
"messages": rolled_back_messages,
"api_calls": api_call_count,
"completed": False,
@@ -3698,7 +4406,7 @@ def _perform_api_call(next_api_kwargs):
agent._codex_incomplete_retries = 0
agent._persist_session(messages, conversation_history)
return {
- "final_response": None,
+ "final_response": "Codex response remained incomplete after 3 continuation attempts",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -3744,20 +4452,43 @@ def _perform_api_call(next_api_kwargs):
agent._vprint(f"{agent.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.", force=True)
agent._invalid_tool_retries = 0
agent._persist_session(messages, conversation_history)
+ _final_response = f"Model generated invalid tool call: {invalid_preview}"
return {
- "final_response": None,
+ "final_response": _final_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"partial": True,
- "error": f"Model generated invalid tool call: {invalid_preview}"
+ "error": _final_response
}
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(assistant_msg)
for tc in assistant_message.tool_calls:
- if tc.function.name not in agent.valid_tool_names:
- content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
+ _tc_name = tc.function.name
+ if _tc_name not in agent.valid_tool_names:
+ # A blank/whitespace-only name is not a typo the
+ # model can fuzzy-correct toward a real tool — it is
+ # almost always a weak open model echoing tool-call
+ # XML/JSON it saw in file or tool output (#47967:
+ # / payloads in a file
+ # prime mimo/nemotron-class models to emit empty
+ # structured calls). Dumping the full tool catalog
+ # in that case feeds the priming loop more names to
+ # mimic and inflates context 3-4x across retries, so
+ # send a terse error that tells the model in-context
+ # tool-call syntax is DATA, not a call to make.
+ if not (_tc_name or "").strip():
+ content = (
+ "Tool call rejected: the tool name was empty. "
+ "If tool-call XML or JSON appeared in file "
+ "contents or tool output, that is data — do "
+ "not re-emit it as a tool call. To call a "
+ "tool, use a valid name from your tool list; "
+ "otherwise reply in plain text."
+ )
+ else:
+ content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({
@@ -3812,7 +4543,7 @@ def _perform_api_call(next_api_kwargs):
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
return {
- "final_response": None,
+ "final_response": "Response truncated due to output length limit",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
@@ -3833,7 +4564,7 @@ def _perform_api_call(next_api_kwargs):
else:
# Instead of returning partial, inject tool error results so the model can recover.
# Using tool results (not user messages) preserves role alternation.
- agent._buffer_vprint(f"⚠️ Injecting recovery tool results for invalid JSON...")
+ agent._buffer_vprint("⚠️ Injecting recovery tool results for invalid JSON...")
agent._invalid_json_retries = 0 # Reset for next attempt
# Append the assistant message with its (broken) tool_calls
@@ -3928,6 +4659,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
@@ -4029,10 +4773,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
@@ -4074,7 +4817,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
@@ -4251,6 +4998,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} "
@@ -4317,14 +5066,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
@@ -4355,9 +5110,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)
@@ -4369,6 +5125,104 @@ 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"
+ final_msg["_verification_stop_synthetic"] = True
+ 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. Both the attempted
+ # answer and the nudge are flagged synthetic so neither
+ # persists — otherwise the resumed transcript keeps a
+ # premature "done" with the nudge stripped, producing an
+ # assistant→assistant adjacency. (#55733)
+ 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
+
+ # User verification-loop gate: when the agent edited code this
+ # turn, let a registered `pre_verify` hook (plugin/shell) keep it
+ # going one more turn. The shipped guidance is folded into the
+ # evidence-based verify-on-stop nudge above, so this path has no
+ # default continuation cost.
+ _verify_nudge2 = None
+ _edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or [])
+ _attempt = getattr(agent, "_pre_verify_nudges", 0)
+ try:
+ from agent.verify_hooks import max_verify_nudges
+ from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
+
+ if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
+ # Posture is fixed for the session — resolve once + cache.
+ coding = getattr(agent, "_resolved_is_coding", None)
+ if coding is None:
+ from agent.coding_context import is_coding_context
+ coding = bool(is_coding_context(platform=getattr(agent, "platform", "") or ""))
+ agent._resolved_is_coding = coding
+ _verify_nudge2 = get_pre_verify_continue_message(
+ session_id=getattr(agent, "session_id", None) or "",
+ platform=getattr(agent, "platform", "") or "",
+ model=getattr(agent, "model", "") or "",
+ coding=coding,
+ attempt=_attempt,
+ final_response=final_response,
+ changed_paths=_edited,
+ )
+ except Exception:
+ logger.debug("pre_verify hook check failed", exc_info=True)
+ _verify_nudge2 = None
+
+ if _verify_nudge2:
+ agent._pre_verify_nudges = _attempt + 1
+ final_msg["finish_reason"] = "verify_hook_continue"
+ final_msg["_pre_verify_synthetic"] = True
+ # Same alternation contract as verify-on-stop: keep the
+ # attempted answer in history, follow it with a synthetic
+ # user nudge, and don't surface the premature answer. Both
+ # are flagged synthetic so neither persists. (#55733)
+ messages.append(final_msg)
+ messages.append({
+ "role": "user",
+ "content": _verify_nudge2,
+ "_pre_verify_synthetic": True,
+ })
+ agent._session_messages = messages
+ logger.debug("pre_verify nudge issued (attempt %d)",
+ agent._pre_verify_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 e3c03938af40..ce3ec2c5c400 100644
--- a/agent/copilot_acp_client.py
+++ b/agent/copilot_acp_client.py
@@ -21,8 +21,14 @@
from types import SimpleNamespace
from typing import Any
+from openai.types.chat.chat_completion_message_tool_call import (
+ ChatCompletionMessageToolCall,
+ Function,
+)
+
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 +100,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
@@ -224,11 +233,73 @@ def _render_message_content(content: Any) -> str:
return str(content).strip()
-def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:
+def _build_openai_tool_call(
+ *,
+ call_id: str,
+ name: str,
+ arguments: str,
+) -> ChatCompletionMessageToolCall:
+ """Build an OpenAI-compatible tool-call object for downstream handling."""
+ return ChatCompletionMessageToolCall(
+ id=call_id,
+ call_id=call_id,
+ response_item_id=None,
+ type="function",
+ function=Function(name=name, arguments=arguments),
+ )
+
+
+def _completion_to_stream_chunks(completion: SimpleNamespace) -> list[SimpleNamespace]:
+ """Convert a one-shot ACP response into OpenAI-style stream chunks."""
+ choice = completion.choices[0]
+ message = choice.message
+ tool_call_deltas = None
+ if message.tool_calls:
+ tool_call_deltas = []
+ for index, tool_call in enumerate(message.tool_calls):
+ tool_call_deltas.append(
+ SimpleNamespace(
+ index=index,
+ id=getattr(tool_call, "id", None),
+ type=getattr(tool_call, "type", "function"),
+ function=SimpleNamespace(
+ name=getattr(tool_call.function, "name", None),
+ arguments=getattr(tool_call.function, "arguments", None),
+ ),
+ )
+ )
+
+ delta = SimpleNamespace(
+ role="assistant",
+ content=message.content or None,
+ tool_calls=tool_call_deltas,
+ reasoning_content=message.reasoning_content,
+ reasoning=message.reasoning,
+ )
+ data_chunk = SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ index=0,
+ delta=delta,
+ finish_reason=choice.finish_reason,
+ )
+ ],
+ model=completion.model,
+ usage=None,
+ )
+ usage_chunk = SimpleNamespace(
+ choices=[],
+ model=completion.model,
+ usage=completion.usage,
+ )
+ return [data_chunk, usage_chunk]
+
+
+def _extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessageToolCall], str]:
if not isinstance(text, str) or not text.strip():
return [], ""
- extracted: list[SimpleNamespace] = []
+ extracted: list[ChatCompletionMessageToolCall] = []
consumed_spans: list[tuple[int, int]] = []
def _try_add_tool_call(raw_json: str) -> None:
@@ -252,12 +323,10 @@ def _try_add_tool_call(raw_json: str) -> None:
call_id = f"acp_call_{len(extracted)+1}"
extracted.append(
- SimpleNamespace(
- id=call_id,
+ _build_openai_tool_call(
call_id=call_id,
- response_item_id=None,
- type="function",
- function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),
+ name=fn_name.strip(),
+ arguments=fn_args,
)
)
@@ -376,6 +445,7 @@ def _create_chat_completion(
timeout: float | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: Any = None,
+ stream: bool = False,
**_: Any,
) -> Any:
prompt_text = _format_messages_as_prompt(
@@ -422,11 +492,14 @@ def _create_chat_completion(
)
finish_reason = "tool_calls" if tool_calls else "stop"
choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
- return SimpleNamespace(
+ completion = SimpleNamespace(
choices=[choice],
usage=usage,
model=model or "copilot-acp",
)
+ if stream:
+ return _completion_to_stream_chunks(completion)
+ return completion
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
try:
diff --git a/agent/credential_persistence.py b/agent/credential_persistence.py
index 069384e7ce66..9217f9535ec8 100644
--- a/agent/credential_persistence.py
+++ b/agent/credential_persistence.py
@@ -22,7 +22,7 @@
("minimax-oauth", "oauth"),
("nous", "device_code"),
("openai-codex", "device_code"),
- ("xai-oauth", "loopback_pkce"),
+ ("xai-oauth", "device_code"),
})
_SAFE_SECRETISH_METADATA_KEYS = frozenset({
diff --git a/agent/credential_pool.py b/agent/credential_pool.py
index 04b22c76a684..9d5d81b2386f 100644
--- a/agent/credential_pool.py
+++ b/agent/credential_pool.py
@@ -11,10 +11,12 @@
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
from hermes_cli.config import load_env
+from agent.secret_scope import get_secret as _get_secret
from agent.credential_persistence import (
is_borrowed_credential_source,
sanitize_borrowed_credential_payload,
@@ -80,7 +82,7 @@ def _load_config_safe() -> Optional[dict]:
# without losing recoverability — the user always has the option to re-add
# via ``hermes auth add``.
#
-# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)
+# Singleton-seeded entries (``device_code``, ``claude_code``)
# are NOT pruned because ``_seed_from_singletons`` would just re-create them
# on the next ``load_pool()`` with the same stale singleton tokens, defeating
# the cleanup. They remain in the pool marked DEAD until an explicit re-auth
@@ -446,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
@@ -478,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(
@@ -556,17 +616,32 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
file_expires = creds.get("expiresAt", 0)
- # If the credentials file has a different token pair, sync it
- if file_refresh and file_refresh != entry.refresh_token:
- logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
+ # Sync when either token changed. Access tokens can be re-issued
+ # without a new refresh token (silent re-issue path), so checking
+ # only refresh_token misses that case and leaves a stale
+ # access_token in the pool → 401 on every request until the pool
+ # entry's exhausted TTL expires.
+ entry_access = entry.access_token or ""
+ entry_refresh = entry.refresh_token or ""
+ if (file_access or file_refresh) and (
+ (file_access and file_access != entry_access)
+ or (file_refresh and file_refresh != entry_refresh)
+ ):
+ logger.debug(
+ "Pool entry %s: syncing tokens from credentials file (tokens changed)",
+ entry.id,
+ )
updated = replace(
entry,
- access_token=file_access,
- refresh_token=file_refresh,
- expires_at_ms=file_expires,
+ access_token=file_access or entry.access_token,
+ refresh_token=file_refresh or entry.refresh_token,
+ expires_at_ms=file_expires or entry.expires_at_ms,
last_status=None,
last_status_at=None,
last_error_code=None,
+ last_error_reason=None,
+ last_error_message=None,
+ last_error_reset_at=None,
)
self._replace_entry(entry, updated)
self._persist()
@@ -649,11 +724,11 @@ def _sync_xai_oauth_entry_from_auth_store(self, entry: PooledCredential) -> Pool
keeps the consumed refresh_token and the next ``_refresh_entry`` call
would replay it and get a ``refresh_token_reused``-style 4xx.
- Only applies to entries seeded from the singleton (``loopback_pkce``);
- manually added entries (``manual:xai_pkce``) are independent
- credentials with their own refresh-token lifecycle.
+ Only applies to entries seeded from the singleton (``device_code``);
+ manually added entries are independent credentials with their own
+ refresh-token lifecycle.
"""
- if self.provider != "xai-oauth" or entry.source != "loopback_pkce":
+ if self.provider != "xai-oauth" or entry.source != "device_code":
return entry
try:
with _auth_store_lock():
@@ -793,12 +868,35 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None
"""
# Only sync entries that were seeded *from* a singleton. Manually
# added pool entries (source="manual:*") are independent credentials
- # and must not write back to the singleton.
- if entry.source not in {"device_code", "loopback_pkce"}:
+ # and must not write back to the singleton. All singleton-seeded
+ # device-code sources (nous, openai-codex, xAI) use ``device_code``.
+ if entry.source != "device_code":
return
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:
@@ -854,6 +952,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)
@@ -863,6 +965,34 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
self._mark_exhausted(entry, None)
return None
+ # Codex OAuth refresh tokens are single-use. The sync→POST→write-back
+ # sequence below must run atomically across Hermes processes: otherwise
+ # two processes can both adopt the same on-disk token, both POST it, and
+ # the loser gets ``refresh_token_reused``. Serialize the whole sequence
+ # through the shared cross-process auth-store flock (the same lock and
+ # extended-timeout pattern used by resolve_codex_runtime_credentials()).
+ # When a waiter finally acquires the lock, the in-lock re-sync below
+ # picks up the rotated token the winner persisted and skips the POST.
+ if self.provider == "openai-codex":
+ refresh_timeout_seconds = auth_mod.env_float(
+ "HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", 20
+ )
+ lock_timeout = max(
+ float(auth_mod.AUTH_LOCK_TIMEOUT_SECONDS),
+ float(refresh_timeout_seconds) + 5.0,
+ )
+ with _auth_store_lock(timeout_seconds=lock_timeout):
+ synced = self._sync_codex_entry_from_auth_store(entry)
+ if synced is not entry:
+ entry = synced
+ if not force and not self._entry_needs_refresh(entry):
+ return entry
+ return self._refresh_entry_impl(entry, force=force)
+ return self._refresh_entry_impl(entry, force=force)
+
+ def _refresh_entry_impl(
+ self, entry: PooledCredential, *, force: bool
+ ) -> Optional[PooledCredential]:
try:
if self.provider == "anthropic":
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
@@ -983,8 +1113,8 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
# consumed the refresh token between our proactive sync and the
# HTTP call. Re-check auth.json and adopt the fresh tokens if
# they have rotated since. Only meaningful for singleton-seeded
- # (loopback_pkce) entries; manual entries don't share state with
- # the singleton.
+ # (device_code) entries; manual entries don't share
+ # state with the singleton.
if self.provider == "xai-oauth":
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
if synced.refresh_token != entry.refresh_token:
@@ -1006,8 +1136,8 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
# Terminal error: auth.json has no newer tokens — the stored
# refresh_token is dead. Clear it from auth.json so the next
# session does not re-seed the same revoked credentials, and
- # remove all singleton-seeded (loopback_pkce) entries from the
- # in-memory pool. Mirrors the Nous quarantine path above.
+ # remove all singleton-seeded xAI entries from the in-memory
+ # pool. Mirrors the Nous quarantine path above.
if auth_mod._is_terminal_xai_oauth_refresh_error(exc):
logger.debug(
"xAI OAuth refresh token is terminally invalid; clearing local token state"
@@ -1039,13 +1169,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 == "device_code"
+ ]
self._entries = [
item for item in self._entries
- if item.source != "loopback_pkce"
+ 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 openai-codex: same race as xAI/nous — another Hermes process
# may have consumed the refresh token between our proactive sync
@@ -1105,13 +1239,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
@@ -1168,13 +1306,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
@@ -1211,7 +1353,7 @@ def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if self.provider == "xai-oauth":
return auth_mod._xai_access_token_is_expiring(
entry.access_token,
- auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
+ auth_mod._xai_proactive_refresh_skew_seconds(entry.access_token),
)
if self.provider == "nous":
# Nous refresh can require network access and should happen when
@@ -1273,7 +1415,7 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal
# tokens that another process (or a fresh `hermes model` ->
# xAI Grok OAuth login) has since rotated in auth.json.
if (self.provider == "xai-oauth"
- and entry.source == "loopback_pkce"
+ and entry.source == "device_code"
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
if synced is not entry:
@@ -1336,7 +1478,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]:
@@ -1510,7 +1652,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
@@ -1666,7 +1812,7 @@ def _is_suppressed(_p, _s): # type: ignore[misc]
_env_file = load_env()
def _env_val(key: str) -> str:
- return (_env_file.get(key) or os.environ.get(key) or "").strip()
+ return (_env_file.get(key) or _get_secret(key, "") or "").strip()
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
anthropic_oauth_env = (
@@ -1782,11 +1928,16 @@ def _env_val(key: str) -> str:
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
token, source = resolve_copilot_token()
if token:
- api_token = get_copilot_api_token(token)
+ api_token, enterprise_base_url = get_copilot_api_token(token)
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
if not _is_suppressed(provider, source_name):
active_sources.add(source_name)
pconfig = PROVIDER_REGISTRY.get(provider)
+ # Use enterprise base URL from token exchange if available,
+ # otherwise fall back to the provider's default.
+ effective_base_url = enterprise_base_url or (
+ pconfig.inference_base_url if pconfig else ""
+ )
changed |= _upsert_entry(
entries,
provider,
@@ -1795,7 +1946,7 @@ def _env_val(key: str) -> str:
"source": source_name,
"auth_type": AUTH_TYPE_API_KEY,
"access_token": api_token,
- "base_url": pconfig.inference_base_url if pconfig else "",
+ "base_url": effective_base_url,
"label": source,
},
)
@@ -1914,28 +2065,30 @@ def _env_val(key: str) -> str:
# (``providers["xai-oauth"]``). Surface them in the pool too so
# ``hermes auth list`` reflects the logged-in state and so the pool
# is the single source of truth for refresh during runtime resolution.
- if _is_suppressed(provider, "loopback_pkce"):
- return changed, active_sources
-
state = _load_provider_state(auth_store, "xai-oauth")
tokens = state.get("tokens") if isinstance(state, dict) else None
if isinstance(tokens, dict) and tokens.get("access_token"):
- active_sources.add("loopback_pkce")
+ # Device code is the only supported xAI OAuth flow; the singleton is
+ # always surfaced as ``device_code`` (consistent with nous/codex).
+ source = "device_code"
+ if _is_suppressed(provider, source):
+ return changed, active_sources
+ active_sources.add(source)
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
base_url = DEFAULT_XAI_OAUTH_BASE_URL
changed |= _upsert_entry(
entries,
provider,
- "loopback_pkce",
+ source,
{
- "source": "loopback_pkce",
+ "source": source,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token"),
"base_url": base_url,
"last_refresh": state.get("last_refresh"),
- "label": label_from_token(tokens.get("access_token", ""), "loopback_pkce"),
+ "label": label_from_token(tokens.get("access_token", ""), source),
},
)
@@ -1952,8 +2105,20 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# changes to the .env file.
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
- val = env_file.get(key) or os.environ.get(key) or ""
- return val.strip()
+ raw = env_file.get(key, "").strip()
+ env_val = os.environ.get(key, "").strip()
+ # If .env contains an unresolved op:// reference, prefer the
+ # already-resolved value from os.environ (set by
+ # load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
+ # "op://Vault/Item/field" string would otherwise win and every
+ # provider auth attempt would receive a URL instead of a key. This
+ # happens during a partial migration, or when the user wrote op://
+ # references straight into .env rather than the secrets.onepassword
+ # config block. For every non-op:// value the original
+ # .env-takes-precedence behaviour is preserved unchanged.
+ if raw.startswith("op://") and env_val:
+ return env_val
+ return raw or _get_secret(key, "") or env_val
# Honour user suppression — `hermes auth remove ` for an
# env-seeded credential marks the env: source as suppressed so it
@@ -2040,7 +2205,12 @@ def _env_payload(
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
- auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
+ # Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path.
+ auth_type = (
+ AUTH_TYPE_OAUTH
+ if provider == "anthropic" and token.startswith("sk-ant-oat")
+ else AUTH_TYPE_API_KEY
+ )
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
@@ -2061,19 +2231,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
@@ -2157,6 +2342,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
@@ -2173,12 +2363,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/credential_sources.py b/agent/credential_sources.py
index f99a75862574..18f0823ba842 100644
--- a/agent/credential_sources.py
+++ b/agent/credential_sources.py
@@ -265,7 +265,7 @@ def _remove_minimax_oauth(provider: str, removed) -> RemovalResult:
return result
-def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
+def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
"""xAI OAuth tokens live in auth.json providers.xai-oauth — clear them.
Without this step, ``hermes auth remove xai-oauth `` silently undoes
@@ -275,11 +275,6 @@ def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
entry from the still-present singleton — credentials reappear with no
user feedback. Clearing the singleton in step with the suppression set
by the central dispatcher makes the removal stick.
-
- Belt-and-braces against the manual entry path: ``hermes auth add
- xai-oauth`` produces a ``manual:xai_pkce`` entry whose removal step
- falls through to "unregistered → nothing to clean up" (correct —
- manual entries are pool-only).
"""
result = RemovalResult()
if _clear_auth_store_provider(provider):
@@ -423,8 +418,8 @@ def _register_all_sources() -> None:
description="auth.json providers.openai-codex + ~/.codex/auth.json",
))
register(RemovalStep(
- provider="xai-oauth", source_id="loopback_pkce",
- remove_fn=_remove_xai_oauth_loopback_pkce,
+ provider="xai-oauth", source_id="device_code",
+ remove_fn=_remove_xai_oauth_device_code,
description="auth.json providers.xai-oauth",
))
register(RemovalStep(
diff --git a/agent/curator.py b/agent/curator.py
index 0ceebecbff20..c13a36ecbbd3 100644
--- a/agent/curator.py
+++ b/agent/curator.py
@@ -273,6 +273,21 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
# Automatic state transitions (pure function, no LLM)
# ---------------------------------------------------------------------------
+def _cron_referenced_skills() -> Set[str]:
+ """Skill names referenced by any cron job (incl. paused/disabled).
+
+ Best-effort: a cron-module import error or corrupt jobs store must never
+ break the curator, so any failure yields an empty set (no protection,
+ but no crash).
+ """
+ try:
+ from cron.jobs import referenced_skill_names as _refs
+ return _refs()
+ except Exception as e:
+ logger.debug("Curator could not read cron skill references: %s", e, exc_info=True)
+ return set()
+
+
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
"""Walk every curator-managed skill and move active/stale/archived based on
the latest real activity timestamp. Pinned skills are never touched.
@@ -292,6 +307,8 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
stale_cutoff = now - timedelta(days=get_stale_after_days())
archive_cutoff = now - timedelta(days=get_archive_after_days())
+ cron_referenced = _cron_referenced_skills()
+
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0}
for row in _u.agent_created_report():
@@ -300,6 +317,15 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
if row.get("pinned"):
continue
+ # A skill referenced by any cron job (incl. paused/disabled) is in
+ # use by definition — resuming or the next fire must find it. The
+ # scheduler only bumps usage when a job actually fires, so jobs that
+ # fire less often than archive_after_days, paused jobs, and far-future
+ # one-shots would otherwise have their skills aged out from under
+ # them. Treat referenced skills like pinned: never auto-transition.
+ if name in cron_referenced:
+ continue
+
# First sight of a curation-eligible skill with no persisted record
# (e.g. a newly-eligible built-in): anchor its clock to now and defer.
if not row.get("_persisted", True):
@@ -316,6 +342,18 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
current = row.get("state", _u.STATE_ACTIVE)
+ # Never-used skills (use_count == 0) get a grace floor: don't archive
+ # one until it is at least stale_after_days old. A use=0 skill is
+ # absence of evidence, not evidence of staleness — a skill created
+ # recently may simply not have had its trigger come up yet.
+ never_used = int(row.get("use_count", 0) or 0) == 0
+ if never_used and anchor > stale_cutoff:
+ # Younger than the stale window — leave it alone entirely.
+ if current == _u.STATE_STALE:
+ _u.set_state(name, _u.STATE_ACTIVE)
+ counts["reactivated"] += 1
+ continue
+
if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED:
ok, _msg = _u.archive_skill(name)
if ok:
@@ -377,8 +415,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"
@@ -388,10 +428,19 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
"back load-bearing UX (slash-command entry points referenced in docs and "
"tips) and are filtered out of the candidate list below — never resurrect "
"one as an archive or absorb target.\n"
+ "3c. DO NOT archive or prune any skill marked `cron=yes` in the candidate "
+ "list. A cron job depends on it and will fail to load it on its next "
+ "run. You MAY still consolidate it into an umbrella — but only because "
+ "the curator rewrites cron job skill references to follow consolidations; "
+ "never simply prune it.\n"
"4. DO NOT use usage counters as a reason to skip consolidation. The "
"counters are new and often mostly zero. Judge overlap on CONTENT, "
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
- "absence of evidence either way.\n"
+ "absence of evidence either way. Corollary: 'use=0' is ALSO not a "
+ "reason to PRUNE a skill. Never archive a never-used skill (use=0) "
+ "unless it is at least 30 days old (check last_activity / created date) "
+ "AND its content is genuinely obsolete or fully absorbed elsewhere — a "
+ "recently-created skill simply may not have had its trigger come up yet.\n"
"5. DO NOT reject consolidation on the grounds that 'each skill has "
"a distinct trigger'. Pairwise distinctness is the wrong bar. The "
"right bar is: 'would a human maintainer write this as N separate "
@@ -469,8 +518,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' "
@@ -1410,12 +1460,14 @@ def _render_candidate_list() -> str:
rows = skill_usage.agent_created_report()
if not rows:
return "No agent-created skills to review."
+ cron_referenced = _cron_referenced_skills()
lines = [f"Agent-created skills ({len(rows)}):\n"]
for r in rows:
lines.append(
f"- {r['name']} "
f"state={r['state']} "
f"pinned={'yes' if r.get('pinned') else 'no'} "
+ f"cron={'yes' if r['name'] in cron_referenced else 'no'} "
f"activity={r.get('activity_count', 0)} "
f"use={r.get('use_count', 0)} "
f"view={r.get('view_count', 0)} "
@@ -1843,6 +1895,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/curator_backup.py b/agent/curator_backup.py
index ddf8699e9bbf..d024219b7fb5 100644
--- a/agent/curator_backup.py
+++ b/agent/curator_backup.py
@@ -556,7 +556,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
if target is None:
return (
False,
- f"no matching backup found"
+ "no matching backup found"
+ (f" for id '{backup_id}'" if backup_id else "")
+ " (use `hermes curator rollback --list` to see available snapshots)",
None,
diff --git a/agent/display.py b/agent/display.py
index 01267e91ea1c..5a16a77d00de 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 ''}\""
@@ -278,6 +515,16 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
msg = msg[:17] + "..."
return f"to {target}: \"{msg}\""
+ if tool_name == "skill_view":
+ name = _oneline(str(args.get("name") or ""))
+ file_path = args.get("file_path")
+ if file_path:
+ file_path = _oneline(str(file_path))
+ preview = f"{name} → {file_path}" if name else file_path
+ else:
+ preview = name
+ return _truncate_preview(preview, max_len) if preview else None
+
key = primary_args.get(tool_name)
if not key:
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
@@ -300,6 +547,122 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
return preview
+# =========================================================================
+# Friendly tool labels (human-phrased verbs for built-in tools)
+#
+# Turns "web_search " into "Searching the web for " — the
+# ChatGPT-style "Searching…/Reading…" surface. Curated and built-in only:
+# we know each core tool's semantics, so the verb is fixed, not computed.
+# Custom/plugin/MCP tools have no entry and fall back to the raw preview.
+# =========================================================================
+
+# Each entry maps a built-in tool name to its present-participle verb phrase.
+# A trailing space-then-preview is appended by build_tool_label() when the
+# tool's argument preview is available (e.g. "Reading docs/api.md").
+_TOOL_VERBS: dict[str, str] = {
+ "web_search": "Searching the web",
+ "web_extract": "Reading",
+ "browser_navigate": "Browsing",
+ "browser_click": "Clicking",
+ "browser_type": "Typing",
+ "read_file": "Reading",
+ "write_file": "Writing",
+ "patch": "Editing",
+ "search_files": "Searching files",
+ "terminal": "Running",
+ "execute_code": "Running code",
+ "image_generate": "Generating image",
+ "video_generate": "Generating video",
+ "text_to_speech": "Generating speech",
+ "vision_analyze": "Looking at the image",
+ "session_search": "Searching past sessions",
+ "skill_view": "Reading skill",
+ "skills_list": "Listing skills",
+ "skill_manage": "Updating skill",
+ "delegate_task": "Delegating",
+ "cronjob": "Scheduling",
+ "clarify": "Asking",
+ "memory": "Updating memory",
+ "todo": "Updating tasks",
+}
+
+# Verbs that read better without the raw argument preview appended.
+_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({
+ "skills_list",
+ "session_search",
+})
+
+# Verbs that take a "for" connector before the preview (search-style phrasing):
+# "Searching the web for " reads better than "Searching the web ".
+_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({
+ "web_search",
+ "search_files",
+})
+
+_friendly_tool_labels: bool = True
+
+
+def set_friendly_tool_labels(enabled: bool) -> None:
+ """Toggle friendly human-phrased tool labels (display.friendly_tool_labels)."""
+ global _friendly_tool_labels
+ _friendly_tool_labels = bool(enabled)
+
+
+def get_friendly_tool_labels() -> bool:
+ """Return whether friendly tool labels are enabled."""
+ return _friendly_tool_labels
+
+
+def get_tool_verb(tool_name: str) -> str | None:
+ """Return the friendly verb for a built-in tool, or None.
+
+ Returns None when friendly labels are disabled or the tool has no curated
+ verb (custom/plugin/MCP tools). Callers that already hold a computed
+ argument preview can compose ``f"{verb} {preview}"`` themselves; use
+ :func:`tool_verb_connector` to pick the right joiner.
+ """
+ if not _friendly_tool_labels:
+ return None
+ return _TOOL_VERBS.get(tool_name)
+
+
+def tool_verb_connector(tool_name: str) -> str:
+ """Return the connector between a verb and its preview (" for " or " ")."""
+ return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " "
+
+
+def verb_drops_preview(tool_name: str) -> bool:
+ """Whether the verb should render alone, without the argument preview."""
+ return tool_name in _TOOL_VERBS_NO_PREVIEW
+
+
+def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
+ """Build a human-phrased status label for a tool call.
+
+ For built-in tools with a known verb (``web_search`` -> "Searching the
+ web for ..."), returns the verb optionally followed by the argument
+ preview. For everything else (custom/plugin/MCP tools, or when friendly
+ labels are disabled) returns the raw preview, so callers can use this as a
+ drop-in replacement for :func:`build_tool_preview`.
+ """
+ if not _friendly_tool_labels:
+ return build_tool_preview(tool_name, args, max_len=max_len)
+
+ verb = _TOOL_VERBS.get(tool_name)
+ if not verb:
+ return build_tool_preview(tool_name, args, max_len=max_len)
+
+ if tool_name in _TOOL_VERBS_NO_PREVIEW:
+ return verb
+
+ preview = build_tool_preview(tool_name, args, max_len=max_len)
+ if not preview:
+ return verb
+ if tool_name in _TOOL_VERBS_FOR_CONNECTOR:
+ return f"{verb} for {preview}"
+ return f"{verb} {preview}"
+
+
# =========================================================================
# Inline diff previews for write actions
# =========================================================================
@@ -906,6 +1269,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 +1307,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 +1315,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":
@@ -1030,15 +1394,17 @@ def _wrap(line: str) -> str:
if tool_name == "skills_list":
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
if tool_name == "skill_view":
- return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}")
+ label = args.get("name", "")
+ file_path = args.get("file_path")
+ if file_path:
+ label = f"{label} → {file_path}" if label else str(file_path)
+ return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}")
if tool_name == "image_generate":
return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}")
if tool_name == "text_to_speech":
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 c39c24a6a5d2..4d75502dab42 100644
--- a/agent/error_classifier.py
+++ b/agent/error_classifier.py
@@ -31,6 +31,9 @@ class FailoverReason(enum.Enum):
# Billing / quota
billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately
rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate
+ # Upstream model rate-limited (aggregator 429) — fallback to a different
+ # model, NOT credential rotation. The user's key is healthy.
+ upstream_rate_limit = "upstream_rate_limit"
# Server-side
overloaded = "overloaded" # 503/529 — provider overloaded, backoff
@@ -38,6 +41,11 @@ class FailoverReason(enum.Enum):
# Transport
timeout = "timeout" # Connection/read timeout — rebuild client + retry
+ # TLS certificate verification failure — deterministic for the host
+ # (TLS-inspecting proxy, missing/expired CA bundle, self-signed cert).
+ # Retrying reproduces the identical handshake failure, so fail fast
+ # with actionable guidance instead of burning retries.
+ ssl_cert_verification = "ssl_cert_verification"
# Context / payload
context_overflow = "context_overflow" # Context too large — compress, not failover
@@ -107,6 +115,7 @@ def is_auth(self) -> bool:
"exceeded your current quota",
"account is deactivated",
"plan does not include",
+ "out of extra usage", # Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400)
"out of funds",
"run out of funds",
"balance_depleted",
@@ -133,6 +142,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",
@@ -250,6 +284,15 @@ def is_auth(self) -> bool:
"no such model",
"unknown model",
"unsupported model",
+ # OpenRouter returns 404 with this message when none of the candidate
+ # endpoints for the selected model support tool/function calling.
+ # Classifying this as model_not_found triggers fallback to a different
+ # model or provider that does support tools. Without this entry the
+ # pattern falls through to ``unknown`` with ``retryable=True``, the
+ # retry loop burns all attempts on the same deterministic rejection,
+ # and the error surfaces as a confusing "model not found" message
+ # instead of automatically failing over. See PR #58446.
+ "no endpoints found that support tool use",
]
# Request-validation patterns — the request is malformed and will fail
@@ -330,6 +373,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)
@@ -401,6 +452,29 @@ def is_auth(self) -> bool:
"incomplete chunked read",
]
+# SSL certificate verification failures — deterministic, NOT transient.
+#
+# A failed certificate chain (TLS-inspecting corporate proxy, missing
+# custom CA in the trust store, expired certificate, self-signed cert)
+# fails identically on every retry. Burning the retry budget before
+# surfacing the error hides the actionable fix from the user for minutes.
+# Inspired by Claude Code v2.1.199 (July 2026), which made SSL certificate
+# errors fail immediately with a fix hint instead of retrying.
+#
+# Must be checked BEFORE _SSL_TRANSIENT_PATTERNS — "certificate verify
+# failed" messages usually also contain "[SSL:" which would otherwise
+# match the transient list and retry forever.
+_SSL_CERT_VERIFY_PATTERNS = [
+ "certificate verify failed", # Python ssl module canonical text
+ "certificate_verify_failed", # OpenSSL error token
+ "unable to get local issuer certificate",
+ "self-signed certificate",
+ "self signed certificate",
+ "certificate has expired",
+ "hostname mismatch, certificate is not valid",
+ "unable to verify the first certificate", # Node/undici phrasing (MCP bridges)
+]
+
# SSL/TLS transient failure patterns — intentionally distinct from
# _SERVER_DISCONNECT_PATTERNS above.
#
@@ -698,7 +772,22 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError:
if classified is not None:
return classified
- # ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
+ # ── 5. SSL certificate verification failures → fail fast ────────
+ # A broken certificate chain (TLS-inspecting proxy, missing custom CA,
+ # expired/self-signed cert) is deterministic for the host — every retry
+ # reproduces the identical handshake failure. Fail immediately with
+ # actionable guidance instead of burning the retry budget first.
+ # Checked BEFORE the transient-SSL patterns: cert-verify messages also
+ # contain "[ssl:" which would otherwise match the transient list.
+ # Inspired by Claude Code v2.1.199 (July 2026).
+ if any(p in error_msg for p in _SSL_CERT_VERIFY_PATTERNS):
+ return _result(
+ FailoverReason.ssl_cert_verification,
+ retryable=False,
+ should_fallback=False,
+ )
+
+ # ── 5b. SSL/TLS transient errors → retry as timeout (not compression) ──
# SSL alerts mid-stream are transport hiccups, not server-side context
# overflow signals. Classify before the disconnect check so a large
# session doesn't incorrectly trigger context compression when the real
@@ -717,6 +806,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 +952,35 @@ 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,
+ )
+ # Distinguish an OpenRouter-aggregator upstream 429 (an upstream model
+ # like DeepSeek rate-limited OpenRouter's aggregate traffic) from an
+ # account-level 429 (the user's key is actually throttled). OpenRouter
+ # wraps upstream errors with the outer message "Provider returned
+ # error" — the user's key is healthy, so marking it exhausted / rotating
+ # is wrong and burns the key for ~24min. Fall back to a different model.
+ if _is_openrouter_upstream_error(body, provider):
+ upstream_provider = _extract_upstream_provider_name(body)
+ ctx = {"upstream_provider": upstream_provider} if upstream_provider else {}
+ return result_fn(
+ FailoverReason.upstream_rate_limit,
+ retryable=True,
+ should_rotate_credential=False,
+ should_fallback=True,
+ error_context=ctx,
+ )
return result_fn(
FailoverReason.rate_limit,
retryable=True,
@@ -879,11 +1016,44 @@ def _classify_by_status(
retryable=False,
should_fallback=True,
)
+ # Some local inference servers (notably llama.cpp / llama-server)
+ # report context overflow with an HTTP 500 instead of the standard
+ # 400/413. The request-validation guard above already ran, so any
+ # remaining explicit context-overflow signal routes into the
+ # compression-and-retry path (mirroring _classify_400) instead of
+ # blind server_error retries that exhaust and drop the turn.
+ if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
+ return result_fn(
+ FailoverReason.context_overflow,
+ retryable=True,
+ should_compress=True,
+ )
return result_fn(FailoverReason.server_error, retryable=True)
if status_code in {503, 529}:
+ # Same overflow-as-5xx variant (server busy / model-load OOM, or a
+ # Cloudflare/Tailscale hop relabeling the status). Route explicit
+ # overflow bodies into compression; otherwise treat as transient
+ # overload and retry.
+ if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
+ return result_fn(
+ FailoverReason.context_overflow,
+ retryable=True,
+ should_compress=True,
+ )
return result_fn(FailoverReason.overloaded, retryable=True)
+ # 408 Request Timeout — a transient timing failure the server itself flags
+ # as safe to retry (RFC 9110 §15.5.9), not a malformed request. Commonly
+ # emitted by reverse proxies sitting in front of self-hosted backends
+ # (llama.cpp / Ollama / vLLM) when a long generation outruns the proxy's
+ # request-read window. Route to the dedicated ``timeout`` reason (rebuild
+ # client + retry) instead of falling through to the generic 4xx bucket
+ # below, which would abort the turn on a retry-safe error the same way it
+ # aborts a 400 Bad Request.
+ if status_code == 408:
+ return result_fn(FailoverReason.timeout, retryable=True)
+
# Other 4xx — non-retryable
if 400 <= status_code < 500:
return result_fn(
@@ -1194,6 +1364,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 +1464,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 {}
@@ -1363,3 +1550,49 @@ def _extract_message(error: Exception, body: dict) -> str:
return msg.strip()[:500]
# Fallback to str(error)
return str(error)[:500]
+
+
+def _is_openrouter_upstream_error(body: Any, provider: str) -> bool:
+ """Detect OpenRouter's aggregator-wrapped upstream provider errors.
+
+ OpenRouter returns errors from upstream model providers (DeepSeek,
+ Anthropic, etc.) wrapped with the outer message "Provider returned error"
+ and the real error nested in ``metadata.raw``. This signal means the
+ user's OpenRouter key is healthy — the upstream provider is the one that
+ failed — so credential rotation is the wrong recovery.
+ """
+ if not isinstance(body, dict):
+ return False
+ provider_lower = (provider or "").strip().lower()
+ err = body.get("error")
+ if not isinstance(err, dict):
+ return False
+ outer_msg = str(err.get("message") or "").strip().lower()
+ if outer_msg != "provider returned error":
+ return False
+ # Require either the explicit OpenRouter provider OR the metadata shape
+ # that only OpenRouter produces (metadata.raw / metadata.provider_name).
+ if provider_lower == "openrouter":
+ return True
+ metadata = err.get("metadata")
+ if isinstance(metadata, dict) and (
+ "raw" in metadata or "provider_name" in metadata
+ ):
+ return True
+ return False
+
+
+def _extract_upstream_provider_name(body: Any) -> Optional[str]:
+ """Pull the upstream provider name out of OpenRouter's error metadata."""
+ if not isinstance(body, dict):
+ return None
+ err = body.get("error")
+ if not isinstance(err, dict):
+ return None
+ metadata = err.get("metadata")
+ if not isinstance(metadata, dict):
+ return None
+ name = metadata.get("provider_name")
+ if isinstance(name, str) and name.strip():
+ return name.strip()
+ return None
diff --git a/agent/file_safety.py b/agent/file_safety.py
index 7a70f9641250..d7e20ee5f0b9 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
@@ -280,7 +293,7 @@ def get_read_block_error(path: str) -> Optional[str]:
# .env contents — .env.example is the documented-shape substitute. The
# terminal tool can still ``cat .env``; this is defense-in-depth, not a
# boundary (see module docstring).
- if resolved.name in _BLOCKED_PROJECT_ENV_BASENAMES:
+ if resolved.name.lower() in _BLOCKED_PROJECT_ENV_BASENAMES:
return (
f"Access denied: {path} is a secret-bearing environment file "
"and cannot be read to prevent credential leakage. "
@@ -291,6 +304,30 @@ def get_read_block_error(path: str) -> Optional[str]:
return None
+def raise_if_read_blocked(path: str) -> None:
+ """Raise ``ValueError`` if ``path`` is a denied Hermes read (see
+ :func:`get_read_block_error`), else return.
+
+ Shared chokepoint for provider input-loading sites that read a local
+ file the model/tool supplied (e.g. image-gen ``image_url`` /
+ ``reference_image_urls`` paths). Centralizes the guard so every provider
+ enforces the same read boundary with identical semantics instead of each
+ open-coding the try/except block (#57698).
+
+ Best-effort by design: if ``agent.file_safety`` machinery is somehow
+ unavailable at the call site the guard no-ops rather than breaking local
+ image loading — consistent with the defense-in-depth (not security
+ boundary) framing of the denylist itself. The blocking ``ValueError`` from
+ a real hit still propagates; only unexpected internal errors are swallowed.
+ """
+ try:
+ blocked = get_read_block_error(path)
+ except Exception: # noqa: BLE001 - guard must never break local-file loading
+ return
+ if blocked:
+ raise ValueError(blocked)
+
+
# ---------------------------------------------------------------------------
# Cross-profile write guard (#TBD)
#
diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py
deleted file mode 100644
index 222327807be3..000000000000
--- 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/gemini_native_adapter.py b/agent/gemini_native_adapter.py
index a79effebba46..9d3b1eb324d5 100644
--- a/agent/gemini_native_adapter.py
+++ b/agent/gemini_native_adapter.py
@@ -27,6 +27,7 @@
import httpx
+from agent.bounded_response import read_streaming_error_body
from agent.gemini_schema import sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
@@ -337,6 +338,22 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
if parts:
contents.append({"role": gemini_role, "parts": parts})
+ # Gemini's generateContent requires strict user/model alternation;
+ # consecutive same-role contents are rejected with HTTP 400 "Please ensure
+ # that multiturn requests alternate between user and model". The loop above
+ # emits one content per source message, so parallel tool calls (N tool
+ # results become N user functionResponse contents), back-to-back user turns,
+ # or merged assistant turns would each violate that. Merge adjacent
+ # same-role contents by concatenating their parts. For parallel calls this
+ # also produces the grouped multi-functionResponse turn Gemini expects.
+ merged_contents: List[Dict[str, Any]] = []
+ for content in contents:
+ if merged_contents and merged_contents[-1]["role"] == content["role"]:
+ merged_contents[-1]["parts"].extend(content["parts"])
+ else:
+ merged_contents.append(content)
+ contents = merged_contents
+
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
@@ -726,14 +743,17 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices:
return chunks
-def gemini_http_error(response: httpx.Response) -> GeminiAPIError:
+def gemini_http_error(
+ response: httpx.Response, *, body_text: Optional[str] = None
+) -> GeminiAPIError:
status = response.status_code
- body_text = ""
body_json: Dict[str, Any] = {}
- try:
- body_text = response.text
- except Exception:
- body_text = ""
+ if body_text is None:
+ try:
+ body_text = response.text
+ except Exception:
+ body_text = ""
+ body_text = body_text or ""
if body_text:
try:
parsed = json.loads(body_text)
@@ -952,8 +972,8 @@ def _generator() -> Iterator[_GeminiStreamChunk]:
try:
with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response:
if response.status_code != 200:
- response.read()
- raise gemini_http_error(response)
+ body_text = read_streaming_error_body(response)
+ raise gemini_http_error(response, body_text=body_text)
tool_call_indices: Dict[str, Dict[str, Any]] = {}
for event in _iter_sse_events(response):
for chunk in translate_stream_event(event, model, tool_call_indices):
diff --git a/agent/google_code_assist.py b/agent/google_code_assist.py
deleted file mode 100644
index eec6441f80e2..000000000000
--- 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 9eb55ec19dc3..000000000000
--- 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.
Return to your terminal — Hermes will walk you through a manual paste fallback.
-"""
-
-
-def _bind_callback_server(preferred_port: int = DEFAULT_REDIRECT_PORT) -> Tuple[http.server.HTTPServer, int]:
- try:
- server = http.server.HTTPServer((REDIRECT_HOST, preferred_port), _OAuthCallbackHandler)
- return server, preferred_port
- except OSError as exc:
- logger.info(
- "Preferred OAuth callback port %d unavailable (%s); requesting ephemeral port",
- preferred_port, exc,
- )
- server = http.server.HTTPServer((REDIRECT_HOST, 0), _OAuthCallbackHandler)
- return server, server.server_address[1]
-
-
-def _is_headless() -> bool:
- return any(os.getenv(k) for k in _HEADLESS_ENV_VARS)
-
-
-# =============================================================================
-# Main login flow
-# =============================================================================
-
-def start_oauth_flow(
- *,
- force_relogin: bool = False,
- open_browser: bool = True,
- callback_wait_seconds: float = CALLBACK_WAIT_SECONDS,
- project_id: str = "",
-) -> GoogleCredentials:
- """Run the interactive browser OAuth flow and persist credentials.
-
- Args:
- force_relogin: If False and valid creds already exist, return them.
- open_browser: If False, skip webbrowser.open and print the URL only.
- callback_wait_seconds: Max seconds to wait for the browser callback.
- project_id: Initial GCP project ID to bake into the stored creds.
- Can be discovered/updated later via update_project_ids().
- """
- if not force_relogin:
- existing = load_credentials()
- if existing and existing.access_token:
- logger.info("Google OAuth credentials already present; skipping login.")
- return existing
-
- client_id = _require_client_id() # raises GoogleOAuthError with install hints
- client_secret = _get_client_secret()
-
- verifier, challenge = _generate_pkce_pair()
- state = secrets.token_urlsafe(16)
-
- # If headless, skip the listener and go straight to paste mode
- if _is_headless() and open_browser:
- logger.info("Headless environment detected; using paste-mode OAuth fallback.")
- return _paste_mode_login(verifier, challenge, state, client_id, client_secret, project_id)
-
- server, port = _bind_callback_server(DEFAULT_REDIRECT_PORT)
- redirect_uri = f"http://{REDIRECT_HOST}:{port}{CALLBACK_PATH}"
-
- _OAuthCallbackHandler.expected_state = state
- _OAuthCallbackHandler.captured_code = None
- _OAuthCallbackHandler.captured_error = None
- ready = threading.Event()
- _OAuthCallbackHandler.ready = ready
-
- params = {
- "client_id": client_id,
- "redirect_uri": redirect_uri,
- "response_type": "code",
- "scope": OAUTH_SCOPES,
- "state": state,
- "code_challenge": challenge,
- "code_challenge_method": "S256",
- "access_type": "offline",
- "prompt": "consent",
- }
- auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params) + "#hermes"
-
- server_thread = threading.Thread(target=server.serve_forever, daemon=True)
- server_thread.start()
-
- print()
- print("Opening your browser to sign in to Google…")
- print(f"If it does not open automatically, visit:\n {auth_url}")
- print()
-
- if open_browser:
- try:
- import webbrowser
-
- try:
- from hermes_cli.auth import (
- _can_open_graphical_browser as _can_open_gui,
- )
- except Exception:
- _can_open_gui = lambda: True # noqa: E731
-
- if _can_open_gui():
- webbrowser.open(auth_url, new=1, autoraise=True)
- except Exception as exc:
- logger.debug("webbrowser.open failed: %s", exc)
-
- code: Optional[str] = None
- try:
- if ready.wait(timeout=callback_wait_seconds):
- code = _OAuthCallbackHandler.captured_code
- error = _OAuthCallbackHandler.captured_error
- if error:
- raise GoogleOAuthError(
- f"Authorization failed: {error}",
- code="google_oauth_authorization_failed",
- )
- else:
- logger.info("Callback server timed out — offering manual paste fallback.")
- code = _prompt_paste_fallback()
- finally:
- try:
- server.shutdown()
- except Exception:
- pass
- try:
- server.server_close()
- except Exception:
- pass
- server_thread.join(timeout=2.0)
-
- if not code:
- raise GoogleOAuthError(
- "No authorization code received. Aborting.",
- code="google_oauth_no_code",
- )
-
- token_resp = exchange_code(
- code, verifier, redirect_uri,
- client_id=client_id, client_secret=client_secret,
- )
- return _persist_token_response(token_resp, project_id=project_id)
-
-
-def _paste_mode_login(
- verifier: str,
- challenge: str,
- state: str,
- client_id: str,
- client_secret: str,
- project_id: str,
-) -> GoogleCredentials:
- """Run OAuth flow without a local callback server."""
- # Use a placeholder redirect URI; user will paste the full URL back
- redirect_uri = f"http://{REDIRECT_HOST}:{DEFAULT_REDIRECT_PORT}{CALLBACK_PATH}"
- params = {
- "client_id": client_id,
- "redirect_uri": redirect_uri,
- "response_type": "code",
- "scope": OAUTH_SCOPES,
- "state": state,
- "code_challenge": challenge,
- "code_challenge_method": "S256",
- "access_type": "offline",
- "prompt": "consent",
- }
- auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params) + "#hermes"
-
- print()
- print("Open this URL in a browser on any device:")
- print(f" {auth_url}")
- print()
- print("After signing in, Google will redirect to localhost (which won't load).")
- print("Copy the full URL from your browser and paste it below.")
- print()
-
- code = _prompt_paste_fallback()
- if not code:
- raise GoogleOAuthError("No authorization code provided.", code="google_oauth_no_code")
-
- token_resp = exchange_code(
- code, verifier, redirect_uri,
- client_id=client_id, client_secret=client_secret,
- )
- return _persist_token_response(token_resp, project_id=project_id)
-
-
-def _prompt_paste_fallback() -> Optional[str]:
- print()
- print("Paste the full redirect URL Google showed you, OR just the 'code=' parameter value.")
- raw = input("Callback URL or code: ").strip()
- if not raw:
- return None
- if raw.startswith("http://") or raw.startswith("https://"):
- parsed = urllib.parse.urlparse(raw)
- params = urllib.parse.parse_qs(parsed.query)
- return (params.get("code") or [""])[0] or None
- # Accept a bare query string as well
- if raw.startswith("?"):
- params = urllib.parse.parse_qs(raw[1:])
- return (params.get("code") or [""])[0] or None
- return raw
-
-
-def _persist_token_response(
- token_resp: Dict[str, Any],
- *,
- project_id: str = "",
-) -> GoogleCredentials:
- access_token = str(token_resp.get("access_token", "") or "").strip()
- refresh_token = str(token_resp.get("refresh_token", "") or "").strip()
- expires_in = int(token_resp.get("expires_in", 0) or 0)
- if not access_token or not refresh_token:
- raise GoogleOAuthError(
- "Google token response missing access_token or refresh_token.",
- code="google_oauth_incomplete_token_response",
- )
- creds = GoogleCredentials(
- access_token=access_token,
- refresh_token=refresh_token,
- expires_ms=int((time.time() + max(60, expires_in)) * 1000),
- email=_fetch_user_email(access_token),
- project_id=project_id,
- managed_project_id="",
- )
- save_credentials(creds)
- logger.info("Google OAuth credentials saved to %s", _credentials_path())
- return creds
-
-
-# =============================================================================
-# Pool-compatible variant
-# =============================================================================
-
-def run_gemini_oauth_login_pure() -> Dict[str, Any]:
- """Run the login flow and return a dict matching the credential pool shape."""
- creds = start_oauth_flow(force_relogin=True)
- return {
- "access_token": creds.access_token,
- "refresh_token": creds.refresh_token,
- "expires_at_ms": creds.expires_ms,
- "email": creds.email,
- "project_id": creds.project_id,
- }
-
-
-# =============================================================================
-# Project ID resolution
-# =============================================================================
-
-def resolve_project_id_from_env() -> str:
- """Return a GCP project ID from env vars, in priority order."""
- for var in (
- "HERMES_GEMINI_PROJECT_ID",
- "GOOGLE_CLOUD_PROJECT",
- "GOOGLE_CLOUD_PROJECT_ID",
- ):
- val = (os.getenv(var) or "").strip()
- if val:
- return val
- return ""
diff --git a/agent/image_gen_provider.py b/agent/image_gen_provider.py
index a7f1b8c31ff9..a3eeb1e4c8c0 100644
--- a/agent/image_gen_provider.py
+++ b/agent/image_gen_provider.py
@@ -11,6 +11,18 @@
as ``kind: backend``) or ``~/.hermes/plugins/image_gen//`` (user, opt-in
via ``plugins.enabled``).
+Unified surface
+---------------
+One tool — ``image_generate`` — covers **text-to-image** and
+**image-to-image / image editing**. The router is the presence of
+``image_url`` (and/or ``reference_image_urls``): if any source image is
+provided, the provider routes to its image-to-image / edit endpoint; if
+omitted, the provider routes to text-to-image. Users pick one **model**
+(e.g. nano-banana-pro, gpt-image-2, grok-imagine-image); the provider
+handles which underlying endpoint to hit. This mirrors the ``video_gen``
+provider design (``agent/video_gen_provider.py``) so the two surfaces
+stay learnable together.
+
Response shape
--------------
All providers return a dict that :func:`success_response` / :func:`error_response`
@@ -21,6 +33,7 @@
model str provider-specific model identifier
prompt str echoed prompt
aspect_ratio str "landscape" | "square" | "portrait"
+ modality str "text" | "image" (which mode was used)
provider str provider name (for diagnostics)
error str only when success=False
error_type str only when success=False
@@ -127,19 +140,51 @@ def default_model(self) -> Optional[str]:
return models[0].get("id")
return None
+ def capabilities(self) -> Dict[str, Any]:
+ """Return what this provider supports.
+
+ Returned dict (all keys optional)::
+
+ {
+ "modalities": ["text", "image"], # which inputs the backend accepts
+ "max_reference_images": 9, # cap for reference_image_urls
+ }
+
+ ``modalities`` declares whether the active backend/model supports
+ text-to-image (``"text"``), image-to-image / editing (``"image"``),
+ or both. The tool layer surfaces this in the dynamic schema so the
+ model knows when ``image_url`` is honored. Used by ``hermes tools``
+ for the picker too. Default: text-only (backward compatible — a
+ provider that doesn't override this advertises text-to-image only).
+ """
+ return {
+ "modalities": ["text"],
+ "max_reference_images": 0,
+ }
+
@abc.abstractmethod
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
+ *,
+ image_url: Optional[str] = None,
+ reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
- """Generate an image.
+ """Generate an image from a text prompt, or edit/transform a source image.
+
+ Routing: if ``image_url`` (or any ``reference_image_urls``) is
+ provided, the provider should route to its image-to-image / edit
+ endpoint; otherwise text-to-image. ``image_url`` is the primary
+ source image to edit; ``reference_image_urls`` are additional
+ style/composition references (provider clamps to its declared
+ ``max_reference_images``).
Implementations should return the dict from :func:`success_response`
or :func:`error_response`. ``kwargs`` may contain forward-compat
- parameters future versions of the schema will expose — implementations
- should ignore unknown keys.
+ parameters future versions of the schema will expose —
+ implementations MUST ignore unknown keys (no TypeError).
"""
@@ -162,6 +207,26 @@ def resolve_aspect_ratio(value: Optional[str]) -> str:
return DEFAULT_ASPECT_RATIO
+def normalize_reference_images(value: Any) -> Optional[List[str]]:
+ """Coerce a reference-image argument into a clean list of URL/path strings.
+
+ Accepts a single string or a list; strips blanks and whitespace. Returns
+ ``None`` when nothing usable remains so providers can treat "no refs" as a
+ single sentinel.
+ """
+ if value is None:
+ return None
+ if isinstance(value, str):
+ value = [value]
+ if not isinstance(value, (list, tuple)):
+ return None
+ out: List[str] = []
+ for item in value:
+ if isinstance(item, str) and item.strip():
+ out.append(item.strip())
+ return out or None
+
+
def _images_cache_dir() -> Path:
"""Return ``$HERMES_HOME/cache/images/``, creating parents as needed."""
from hermes_constants import get_hermes_home
@@ -280,13 +345,16 @@ def success_response(
prompt: str,
aspect_ratio: str,
provider: str,
+ modality: str = "text",
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build a uniform success response dict.
``image`` may be an HTTP URL or an absolute filesystem path (for b64
- providers like OpenAI). Callers that need to pass through additional
- backend-specific fields can supply ``extra``.
+ providers like OpenAI). ``modality`` is ``"text"`` (text-to-image) or
+ ``"image"`` (image-to-image / editing) — indicates which endpoint was
+ actually hit, useful for diagnostics. Callers that need to pass through
+ additional backend-specific fields can supply ``extra``.
"""
payload: Dict[str, Any] = {
"success": True,
@@ -294,6 +362,7 @@ def success_response(
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
+ "modality": modality,
"provider": provider,
}
if extra:
diff --git a/agent/image_routing.py b/agent/image_routing.py
index c8b3f6640c6d..1fe52d9565b2 100644
--- a/agent/image_routing.py
+++ b/agent/image_routing.py
@@ -17,13 +17,17 @@
| ``text``, default ``auto``) and the active model's capability metadata.
In ``auto`` mode:
- - If the user has explicitly configured ``auxiliary.vision.provider``
- (i.e. not ``auto`` and not empty), we assume they want the text pipeline
- regardless of the main model — they've opted in to a specific vision
- backend for a reason (cost, quality, local-only, etc.).
- - Otherwise, if the active model reports ``supports_vision=True`` in its
- models.dev metadata, we attach natively.
- - Otherwise (non-vision model, no explicit override), we fall back to text.
+ - If the active model reports ``supports_vision=True`` (via config
+ override or models.dev metadata), we attach natively — vision-capable
+ main models should always see the original pixels, even when an
+ auxiliary vision backend is configured. That auxiliary backend then
+ acts as a *fallback* for sessions whose main model can't take images.
+ - Otherwise, if the user has explicitly configured ``auxiliary.vision``
+ (provider/model/base_url not ``auto``/empty), we route through the
+ text pipeline so the auxiliary vision backend can describe the image
+ for the text-only main model.
+ - Otherwise (non-vision model, no explicit override), we fall back to
+ text via the default vision_analyze flow.
This keeps ``vision_analyze`` surfaced as a tool in every session — skills
and agent flows that chain it (browser screenshots, deeper inspection of
@@ -185,7 +189,8 @@ def _supports_vision_override(
2. ``providers..models..supports_vision``
(named custom providers — ``provider`` may be the runtime-resolved
value ``"custom"`` and/or the user-declared name under
- ``model.provider``; both are tried)
+ ``model.provider``; both are tried. For ``custom:`` syntax,
+ the stripped ```` is also tried as a provider key.)
Returns None when no override is set, so the caller falls through to
models.dev. Returns False explicitly only when the user wrote a
@@ -205,11 +210,16 @@ def _supports_vision_override(
# get rewritten to provider="custom" at runtime
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
# config still holds the user-declared name under model.provider. Try
- # both as candidate provider keys.
+ # both as candidate provider keys, plus the stripped suffix from
+ # "custom:" (where is the key under providers:).
config_provider = str(model_cfg.get("provider") or "").strip()
+ # Extract the stripped name from "custom:" if present
+ stripped_suffix = ""
+ if config_provider.startswith("custom:"):
+ stripped_suffix = config_provider[len("custom:"):]
providers_raw = cfg.get("providers")
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
- for p in dict.fromkeys(filter(None, (provider, config_provider))):
+ for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
entry_raw = providers_cfg.get(p)
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
models_raw = entry.get("models")
@@ -251,6 +261,78 @@ def _supports_vision_override(
return None
+def _resolve_inference_base_url(
+ cfg: Optional[Dict[str, Any]],
+ provider: str,
+) -> str:
+ """Best-effort base URL for the active inference provider."""
+ try:
+ from agent.auxiliary_client import _RUNTIME_MAIN_BASE_URL
+
+ runtime = str(_RUNTIME_MAIN_BASE_URL or "").strip()
+ if runtime:
+ return runtime
+ except Exception:
+ pass
+
+ if not isinstance(cfg, dict):
+ return ""
+
+ model_cfg_raw = cfg.get("model")
+ model_cfg: Dict[str, Any] = model_cfg_raw if isinstance(model_cfg_raw, dict) else {}
+ base_url = str(model_cfg.get("base_url") or "").strip()
+ if base_url:
+ return base_url
+
+ config_provider = str(model_cfg.get("provider") or "").strip()
+ candidate_names: set[str] = set()
+ for p in filter(None, (provider, config_provider)):
+ candidate_names.add(p)
+ if p.lower().startswith("custom:"):
+ candidate_names.add(p.split(":", 1)[1])
+ else:
+ candidate_names.add(f"custom:{p}")
+
+ providers_cfg = cfg.get("providers")
+ if isinstance(providers_cfg, dict):
+ for name in candidate_names:
+ entry = providers_cfg.get(name)
+ if isinstance(entry, dict):
+ bu = str(entry.get("base_url") or "").strip()
+ if bu:
+ return bu
+
+ custom_providers = cfg.get("custom_providers")
+ if isinstance(custom_providers, list):
+ lowered = {n.lower() for n in candidate_names}
+ for entry_raw in custom_providers:
+ if not isinstance(entry_raw, dict):
+ continue
+ entry_name = str(entry_raw.get("name") or "").strip()
+ if entry_name not in candidate_names and entry_name.lower() not in lowered:
+ continue
+ bu = str(entry_raw.get("base_url") or "").strip()
+ if bu:
+ return bu
+
+ return ""
+
+
+def _should_probe_ollama_vision(provider: str, base_url: str) -> bool:
+ """True when the active provider likely fronts a local Ollama server."""
+ p = (provider or "").strip().lower()
+ if p == "ollama":
+ return True
+ if not base_url:
+ return False
+ try:
+ from agent.model_metadata import detect_local_server_type
+
+ return detect_local_server_type(base_url) == "ollama"
+ except Exception:
+ return False
+
+
def _coerce_mode(raw: Any) -> str:
"""Normalize a config value into one of the valid modes."""
if not isinstance(raw, str):
@@ -264,8 +346,10 @@ def _coerce_mode(raw: Any) -> str:
def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
"""True when the user configured a specific auxiliary vision backend.
- An explicit override means the user *wants* the text pipeline (they're
- paying for a dedicated vision model), so we don't silently bypass it.
+ An explicit override means the user has a dedicated vision backend
+ available; it's used as a *fallback* when the main model can't take
+ images natively. In ``auto`` mode, native vision on a vision-capable
+ main model still wins over this fallback — see issue #29135.
"""
if not isinstance(cfg, dict):
return False
@@ -302,15 +386,33 @@ def _lookup_supports_vision(
return override
if not provider or not model:
return None
+ caps = None
try:
from agent.models_dev import get_model_capabilities
caps = get_model_capabilities(provider, model)
except Exception as exc: # pragma: no cover - defensive
logger.debug("image_routing: caps lookup failed for %s:%s — %s", provider, model, exc)
- return None
- if caps is None:
- return None
- return bool(caps.supports_vision)
+ if caps is not None:
+ return bool(caps.supports_vision)
+
+ base_url = _resolve_inference_base_url(cfg, provider)
+ if not base_url and (provider or "").strip().lower() == "ollama":
+ base_url = "http://localhost:11434/v1"
+ if _should_probe_ollama_vision(provider, base_url):
+ try:
+ from agent.model_metadata import query_ollama_supports_vision
+
+ ollama_vision = query_ollama_supports_vision(model, base_url)
+ if ollama_vision is not None:
+ return ollama_vision
+ except Exception as exc: # pragma: no cover - defensive
+ logger.debug(
+ "image_routing: ollama vision probe failed for %s:%s — %s",
+ provider,
+ model,
+ exc,
+ )
+ return None
def decide_image_input_mode(
@@ -336,13 +438,15 @@ def decide_image_input_mode(
if mode_cfg == "text":
return "text"
- # auto
- if _explicit_aux_vision_override(cfg):
- return "text"
-
+ # auto: prefer native vision when the main model supports it. An
+ # explicit auxiliary.vision config acts as a *fallback* for text-only
+ # main models — it should not preempt native vision on a model that
+ # can natively inspect the pixels (issue #29135).
supports = _lookup_supports_vision(provider, model, cfg)
if supports is True:
return "native"
+ if _explicit_aux_vision_override(cfg):
+ return "text"
return "text"
@@ -388,14 +492,98 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]:
# BMP: "BM"
if raw.startswith(b"BM"):
return "image/bmp"
- # HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc.
- if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in {
- b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis",
- }:
- return "image/heic"
+ # ISO-BMFF family (HEIC/HEIF/AVIF): bytes 4..8 == 'ftyp', major brand at 8..12
+ if len(raw) >= 12 and raw[4:8] == b"ftyp":
+ brand = raw[8:12]
+ if brand in {b"avif", b"avis"}:
+ return "image/avif"
+ if brand in {
+ b"heic", b"heix", b"hevc", b"hevx",
+ b"mif1", b"msf1", b"heim", b"heis",
+ }:
+ return "image/heic"
+ # TIFF: II*\0 (little-endian) or MM\0* (big-endian)
+ if raw[:4] in {b"II*\x00", b"MM\x00*"}:
+ return "image/tiff"
+ # ICO: 00 00 01 00 (reserved=0, type=1=icon)
+ if raw[:4] == b"\x00\x00\x01\x00":
+ return "image/x-icon"
+ # SVG: text-based, look for an