Merge remote-tracking branch 'origin/main' into feat/hsp-sync-client

# Conflicts:
#	hermes_cli/main.py
This commit is contained in:
Ben Barclay 2026-07-23 12:37:39 +10:00
commit 9d146c9cc2
1500 changed files with 203868 additions and 17823 deletions

View file

@ -97,9 +97,6 @@ packaging/
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc

View file

@ -7,7 +7,7 @@ description: >-
inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
required: false
default: ${{ github.token }}
@ -39,6 +39,9 @@ outputs:
ci_review:
description: Require CI-sensitive file review label.
value: ${{ steps.classify.outputs.ci_review }}
ci_review_files:
description: JSON list of CI-sensitive files changed by the pull request.
value: ${{ steps.classify.outputs.ci_review_files }}
runs:
using: composite
@ -72,12 +75,19 @@ runs:
# Retried: a rate-limit blip or eventual-consistency 404 on a
# freshly-pushed HEAD would otherwise silently fall open (all lanes
# run — safe, but wasteful and it masks the API failure).
#
# `.files[]?` (null-safe): with --paginate, a PR more than 100
# commits ahead of its merge-base paginates the compare, and pages
# after the first carry `files: null` — bare `.files[]` makes jq
# die with "cannot iterate over: null", which fails every retry
# and forces the fail-open path (seen on stacked PRs). The full
# file list (up to the API's 300-file cap) is on page one.
CHANGED=""
for i in 1 2 3; do
if CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[].filename')"; then
--jq '.files[]?.filename')"; then
break
fi
if [ "$i" = 3 ]; then

View file

@ -0,0 +1,69 @@
name: Get GitHub App Token
description: >-
Mint a short-lived (1-hour) installation access token from the repo's
GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
and are scoped to the App's installation permissions, not a user account.
Callers must source App credentials from a protected, main-only environment.
Never pass an App private key to a pull_request job, a local action, or a
reusable workflow resolved from an untrusted PR ref. The fallback keeps a
trusted caller functional when its protected environment is misconfigured.
Composite actions cannot access contexts directly, so callers pass the
public vars.APP_CLIENT_ID and protected secrets.APP_PRIVATE_KEY as inputs.
When the private key is empty, the fallback fires.
inputs:
client-id:
description: GitHub App Client ID. Pass vars.APP_CLIENT_ID from the calling workflow.
required: false
default: ''
private-key:
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
required: false
default: ''
owner:
description: GitHub App installation owner. Empty scopes the token to the current repository.
required: false
default: ''
repositories:
description: Comma- or newline-separated repositories to scope within the installation owner.
required: false
default: ''
outputs:
token:
description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks.
value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }}
runs:
using: composite
steps:
- name: Check if App credentials exist
id: check
shell: bash
env:
CLIENT_ID: ${{ inputs.client-id }}
run: |
if [ -n "$CLIENT_ID" ]; then
echo "has_app=true" >> "$GITHUB_OUTPUT"
else
echo "has_app=false" >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub App token
id: app-token
if: steps.check.outputs.has_app == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ inputs.client-id }}
private-key: ${{ inputs.private-key }}
owner: ${{ inputs.owner }}
repositories: ${{ inputs.repositories }}
- name: Fall back to GITHUB_TOKEN
id: fallback
if: steps.check.outputs.has_app != 'true'
shell: bash
run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"

View file

@ -9,6 +9,10 @@ name: CI
# definitions, matrices, and concurrency settings. They no longer have
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
# through this file.
#
# SECURITY: this workflow runs PR-controlled actions, workflows, and code.
# Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted
# main-only automation uses protected environments in its own workflows.
on:
pull_request:
@ -17,7 +21,7 @@ on:
permissions:
contents: read
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
actions: read # needed by osv-scanner (SARIF upload)
security-events: write # needed by osv-scanner (SARIF upload)
packages: write # needed by docker build
@ -46,6 +50,7 @@ jobs:
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
ci_review_files: ${{ steps.classify.outputs.ci_review_files }}
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -53,9 +58,7 @@ jobs:
id: classify
uses: ./.github/actions/detect-changes
with:
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so classification still works there.
github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
github-token: ${{ github.token }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@ -68,89 +71,170 @@ jobs:
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
secrets: inherit
js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
secrets: inherit
e2e-desktop:
name: Desktop E2E
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/e2e-desktop.yml
docs-site:
name: Docs Site
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
secrets: inherit
history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
secrets: inherit
contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
secrets: inherit
uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
secrets: inherit
lockfile-diff:
name: package-lock.json diff
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
uses: ./.github/workflows/lockfile-diff.yml
secrets: inherit
docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
secrets: inherit
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')
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == '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' }}
review-labels:
name: Review label gate
needs: [detect, supply-chain]
if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true')
uses: ./.github/workflows/review-labels.yml
with:
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
ci_review_files: ${{ needs.detect.outputs.ci_review_files }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
secrets: inherit
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
osv-scanner:
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Live-updating PR review comment.
#
# A single ``comment-live`` job polls the GitHub Actions API every 15s
# for job statuses in this run, re-assembles the review comment from
# whatever results are available, and upserts it via the
# ``<!-- hermes-ci-review-bot -->`` marker.
#
# The poller exits when all non-infra jobs are completed (or on
# timeout). ci-timings' review_status is picked up automatically when
# its artifact becomes available — the poller downloads and merges it.
# ─────────────────────────────────────────────────────────────────────
comment-live:
name: CI review comment (live)
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check, e2e-desktop]
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Run live comment poller
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_RUN_ID: ${{ github.run_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Commit info for the review comment header.
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
# Structured review statuses from workflow_call jobs.
# Each job outputs a JSON array of {source, results: [...]} objects
# that the assembler renders directly — no hardcoded job-name
# matching. We merge all available outputs into one array.
REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }}
run: |
set -uo pipefail
# REVIEW_STATUSES is a JSON array of strings (some may be empty
# when a job was skipped). Parse each string and merge into one
# flat array for the assembler.
python3 - <<'PYEOF'
import json, os, sys
raw = os.environ.get("REVIEW_STATUSES", "")
merged = []
if raw:
try:
arr = json.loads(raw)
except (json.JSONDecodeError, TypeError):
arr = []
for item in arr:
if not item:
continue
try:
statuses = json.loads(item)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(statuses, list):
merged.extend(statuses)
# Write merged array to a temp file the poller reads.
with open("/tmp/review_statuses.json", "w") as f:
json.dump(merged, f)
print(f"Merged {len(merged)} review status entries")
PYEOF
python3 scripts/ci/live_comment.py \
--interval 15 \
--timeout 2100 \
--review-statuses-file /tmp/review_statuses.json
# ─────────────────────────────────────────────────────────────────────
# Gate: runs after everything. ``if: always()`` ensures it reports a
@ -158,13 +242,18 @@ jobs:
# results cause it to fail; ``skipped`` is treated as success.
#
# Branch protection should require ONLY this check.
#
# Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so
# the live comment poller can list failed jobs in the PR comment.
# ─────────────────────────────────────────────────────────────────────
all-checks-pass:
name: All required checks pass
needs:
- detect
- tests
- lint
- js-tests
- e2e-desktop
- docs-site
- history-check
- contributor-check
@ -172,20 +261,30 @@ jobs:
- lockfile-diff
- docker-lint
- supply-chain
- review-labels
- osv-scanner
# comment-live is a polling job — it doesn't block the gate.
# we don't require docker to pass rn because it's so slow lol
# - docker
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
needs-json: ${{ steps.evaluate.outputs.needs-json }}
steps:
- name: Evaluate job results
id: evaluate
env:
NEEDS: ${{ toJSON(needs) }}
run: |
echo "$NEEDS" | python3 -c "
import json, sys
needs = json.load(sys.stdin)
# Emit compact {job_name: result} for the comment assembler.
compact = {name: info['result'] for name, info in needs.items()}
print(f'needs-json={json.dumps(compact)}')
with open('$GITHUB_OUTPUT', 'a') as f:
f.write(f'needs-json={json.dumps(compact)}\n')
failed = [name for name, info in needs.items() if info['result'] == 'failure']
for name, info in sorted(needs.items()):
result = info['result']
@ -202,6 +301,9 @@ jobs:
# 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.
#
# The live comment poller picks up ci-timings' completion automatically —
# it reads review-status.json from the artifact when the job finishes.
# ─────────────────────────────────────────────────────────────────────
ci-timings:
name: CI timing report
@ -226,24 +328,26 @@ jobs:
- name: Collect timings and generate report
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ 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
--summary-out ci-timings-summary.md \
--review-status-out review-status.json
- name: Upload HTML report
- name: Upload HTML report + review status
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
with:
name: ci-timings-report
path: ci-timings-report.html
path: |
ci-timings-report.html
review-status.json
retention-days: 14
archive: false
- name: Output summary
env:

View file

@ -2,6 +2,10 @@ name: Contributor Attribution Check
on:
workflow_call:
outputs:
review_status:
description: "JSON array of review status objects"
value: ${{ jobs.check-attribution.outputs.review_status }}
permissions:
contents: read
@ -10,12 +14,15 @@ jobs:
check-attribution:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
review_status: ${{ steps.check-emails.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Full history needed for git log
- name: Check for unmapped contributor emails
id: check-emails
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
@ -25,6 +32,7 @@ jobs:
if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
echo "review_status=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
@ -67,6 +75,16 @@ jobs:
echo ""
echo "To find the GitHub username for an email:"
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
# Emit review_status for unmapped emails
DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //')
HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"<email>": "<github-username>",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n'
REVIEW_STATUS=$(jq -nc \
--arg detail "$DETAIL" \
--arg how_to_fix "$HOW_TO_FIX" \
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
exit 1
else
echo "✅ All contributor emails are mapped."

View file

@ -56,6 +56,13 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@ -73,8 +80,8 @@ jobs:
- name: Prepare skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |

View file

@ -20,7 +20,9 @@ env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# Build, test, and optionally push the image for each architecture.
# Build and test the image for each architecture. This job runs PR code,
# so it must remain secret-free. Publishing happens in the separate,
# protected publish job after these tests pass.
build:
if: github.repository == 'NousResearch/hermes-agent'
strategy:
@ -62,49 +64,6 @@ jobs:
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`).
@ -147,6 +106,74 @@ jobs:
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
# ---------------------------------------------------------------------------
# Rebuild and push each architecture only after the unprivileged build/test
# matrix passes. This job is the sole Docker Hub credential boundary.
# ---------------------------------------------------------------------------
publish:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
needs: [build]
environment: container-publish
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: 30
steps:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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 }}
# Push by digest only (no tag). The merge job assembles the tagged
# manifest list after both architecture publishers complete.
- name: Push ${{ matrix.arch }} by digest
id: push
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 }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
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 —
@ -158,8 +185,9 @@ jobs:
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]
needs: [publish]
timeout-minutes: 10
environment: container-publish
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4

255
.github/workflows/e2e-desktop.yml vendored Normal file
View file

@ -0,0 +1,255 @@
name: E2E Desktop
on:
workflow_call:
outputs:
review_status:
description: Screenshot and visual-diff status for the CI review comment.
value: ${{ jobs.e2e.outputs.review_status }}
permissions:
contents: read
concurrency:
group: e2e-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
name: Playwright E2E (Linux)
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
review_status: ${{ steps.review-status.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ── System deps for Electron on headless Ubuntu ───────────────────
# Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's
# install-deps covers browsers; for Electron we install the apt
# packages directly.
- name: Install system dependencies for Electron
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
xvfb \
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
# ── Node ───────────────────────────────────────────────────────────
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# Full npm ci (not --ignore-scripts): electron's postinstall
# downloads the binary we launch, and node-pty's native build is
# needed for the terminal pane.
- uses: ./.github/actions/retry
with:
command: npm ci
# ── Python (for the hermes serve backend) ──────────────────────────
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install Python dependencies
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
# ── Build desktop app ─────────────────────────────────────────────
# The Playwright step below runs `npm run build` before testing so
# dist/ is always fresh — no separate build step needed here.
# ── Restore visual baseline screenshots from main ──────────────────
# Baselines are generated on main (via --update-snapshots) and cached.
# On PRs, we restore them so toHaveScreenshot has something to compare
# against. The cache key is keyed on the desktop source files so a
# UI change naturally invalidates it — but we fall back to the main
# cache to avoid cold starts on unrelated PRs.
- name: Restore visual baseline screenshots
id: restore-baselines
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-${{ github.ref_name }}
restore-keys: |
visual-baselines-main
# ── Run Playwright E2E under xvfb ─────────────────────────────────
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
# window always has a consistent viewport for screenshot comparison.
# On main, we run with --update-snapshots to generate baselines.
# `npm run test:e2e` builds dist/ as a pretest hook so the renderer
# is always fresh — no separate build step needed.
- name: Run Playwright E2E tests
working-directory: apps/desktop
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
CI: "true"
# Ensure no real API keys leak into the test env.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
# ── Save updated baselines to cache (main only) ───────────────────
- name: Save updated baselines to cache
if: github.ref_name == 'main' && always()
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-main
# ── Upload Playwright report (HTML + traces) ──────────────────────
- name: Upload Playwright report
id: upload-report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-${{ github.sha }}
path: apps/desktop/playwright-report
retention-days: 14
overwrite: true
# ── Upload test results (screenshots, traces, diffs) ───────────────
- name: Upload test results
id: upload-results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-test-results-${{ github.sha }}
path: apps/desktop/test-results
retention-days: 14
overwrite: true
# ── Upload just the visual diffs (small, fast to review) ──────────
- name: Upload visual diffs
id: upload-diffs
if: always() && github.ref_name != 'main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-diffs-${{ github.sha }}
path: |
apps/desktop/test-results/**/*-diff.png
apps/desktop/test-results/**/*-actual.png
apps/desktop/test-results/**/*-expected.png
retention-days: 14
overwrite: true
if-no-files-found: ignore
- name: Build screenshot review status
id: review-status
if: always()
working-directory: apps/desktop
env:
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
run: |
python3 ../../scripts/ci/e2e_screenshot_status.py \
--results-dir test-results \
--manifest-output /tmp/e2e-screenshot-manifest.json \
--evidence-dir /tmp/e2e-evidence \
--artifact-url "$RESULTS_URL" \
--output /tmp/e2e-review-status.json
{
echo 'review_status<<__E2E_REVIEW_STATUS__'
cat /tmp/e2e-review-status.json
echo '__E2E_REVIEW_STATUS__'
} >> "$GITHUB_OUTPUT"
# The trusted workflow_run publisher consumes only this flat, bounded
# artifact. It turns selected images into GitHub attachment URLs; it
# never checks out or runs this PR's code.
- name: Upload inline E2E evidence
if: always() && github.ref_name != 'main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-evidence-${{ github.sha }}
path: /tmp/e2e-evidence
retention-days: 14
overwrite: true
if-no-files-found: error
# ── Generate step summary with visual diff info ───────────────────
# Parse the JSON report + scan for diff images, then post a summary
# to the GitHub Actions step output so reviewers can see what changed
# without downloading artifacts. Runs AFTER uploads so it can link
# the artifact download URLs from their step outputs.
- name: Generate visual diff summary
if: always()
working-directory: apps/desktop
env:
REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }}
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
run: |
{
echo "## Desktop E2E — Visual Diff Report"
echo ""
# Count diff images (playwright writes *-diff.png on mismatch)
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
if [ "$DIFF_COUNT" -eq 0 ]; then
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)."
else
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**"
echo ""
echo "| Test | Diff | Actual | Expected |"
echo "|------|------|--------|----------|"
# List each diff image with a link to the artifact
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
base=${diff%-diff.png}
test_name=$(basename "$base")
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |"
done
fi
echo ""
echo "📥 **Artifacts:**"
echo ""
if [ -n "$RESULTS_URL" ]; then
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces"
fi
if [ -n "$REPORT_URL" ]; then
echo "- [playwright-report]($REPORT_URL) — interactive HTML report"
fi
if [ -n "$DIFFS_URL" ]; then
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
fi
echo ""
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
# Also parse the JSON report for pass/fail counts
if [ -f playwright-report/results.json ]; then
echo ""
echo "### Test Results"
echo ""
node -e "
const r = require('./playwright-report/results.json');
const stats = r.stats || {};
console.log('| Status | Count |');
console.log('|--------|-------|');
console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |');
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
" 2>/dev/null || true
fi
} >> "$GITHUB_STEP_SUMMARY"

View file

@ -15,6 +15,10 @@ name: History Check
on:
workflow_call:
outputs:
review_status:
description: "JSON array of review_status objects for the synthesizer."
value: ${{ jobs.check-common-ancestor.outputs.review_status }}
permissions:
contents: read
@ -23,18 +27,23 @@ jobs:
check-common-ancestor:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
review_status: ${{ steps.merge-base-check.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
- name: Reject PRs with no common ancestor on main
- id: merge-base-check
name: Reject PRs with no common ancestor on main
run: |
# `git merge-base` exits non-zero AND prints nothing when the two
# commits share no ancestor. We check both conditions explicitly
# so the failure message is clear regardless of which signal fires
# first.
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo ""
echo "::error::This PR has no common ancestor with main."
echo ""
@ -56,3 +65,4 @@ jobs:
exit 1
fi
echo "::notice::Common ancestor with main: $BASE"
echo "review_status=[]" >> "$GITHUB_OUTPUT"

View file

@ -7,7 +7,7 @@ name: auto-fix lint issues & formatting
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
# check in typecheck.yml fails only when un-fixable errors remain.
#
# NOTE: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike
# NOTE: App token pushes DO trigger further workflow runs (unlike
# secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }})
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
# run cancels the in-flight one, and since the second run finds no new fixes
@ -122,12 +122,20 @@ jobs:
if: needs.generate-patch.outputs.has-fixes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
permissions:
contents: write # needed to push to bot/js-autofix
pull-requests: write # needed for PR creation + auto-merge
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Download patch
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
@ -170,7 +178,7 @@ jobs:
- name: Create/update PR and enable auto-merge
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BOT_BRANCH: bot/js-autofix
run: |
set -euo pipefail
@ -193,7 +201,7 @@ jobs:
- name: Wait for merge, auto-close on failure or stale
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
START_SHA: ${{ github.sha }}
run: |
set -euo pipefail

81
.github/workflows/label-rerun.yml vendored Normal file
View file

@ -0,0 +1,81 @@
name: Label rerun
# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in
# the latest CI run. This re-evaluates ``review-labels`` (which now sees the
# label) and GitHub automatically reruns dependent jobs (``comment-live``,
# ``all-checks-pass``) — so the review comment gets updated too.
#
# If the CI run is still in progress when the label is added, we wait for it
# to finish before rerunning (``gh run rerun`` only works on completed runs).
# The wait can be long (20+ min for a full CI run), but it's better than
# silently failing and leaving the reviewer stuck.
on:
pull_request:
types: [labeled]
permissions:
actions: write
pull-requests: read
concurrency:
group: label-rerun-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
rerun-review-labels:
name: Rerun review-labels job
if: github.event.label.name == 'ci-reviewed'
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Wait for CI run to finish, then rerun failed jobs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -uo pipefail
# Find the latest CI run for this PR's head SHA.
RUN_ID=$(gh run list \
--repo "$REPO" \
--commit "$HEAD_SHA" \
--workflow ci.yml \
--limit 1 \
--json databaseId,status \
--jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true)
if [ -z "$RUN_ID" ]; then
echo "No CI run found for this PR — nothing to rerun."
exit 0
fi
# Split "RUN_ID STATUS" into two vars.
RUN_ID="${RUN_ID%% *}"
STATUS="${RUN_ID##* }"
echo "Latest CI run: $RUN_ID (status: $STATUS)"
# If the run is still in progress, wait for it to finish.
# gh run rerun only works on completed runs — if we try while it's
# running, GitHub rejects with "cannot be rerun; This workflow is
# already running".
if [ "$STATUS" != "completed" ]; then
echo "Run is $STATUS — waiting for completion (this may take a while)..."
# gh run watch --exit-status exits non-zero if the run fails,
# which is expected (the label gate fails). Don't let that kill
# the workflow — we WANT to rerun failed jobs.
timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true
# Verify it's actually completed now.
STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown")
if [ "$STATUS" != "completed" ]; then
echo "Run is still $STATUS after wait — giving up."
exit 0
fi
fi
echo "Run completed. Rerunning all failed jobs..."
gh run rerun "$RUN_ID" --repo "$REPO" --failed || true
echo "Done. GitHub will rerun review-labels and all dependent jobs."

View file

@ -2,11 +2,14 @@ name: Lint (ruff + ty)
# Two things here:
# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch.
# Posts a Markdown summary and a PR comment. Exit zero always.
# Writes a Markdown summary to the run page. Exit zero always.
# 2. Blocking ``ruff check .`` — enforces the explicit rules in
# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge.
# Separate job so the advisory diff still runs and posts even when
# enforcement fails.
# Separate job so the advisory diff still runs even when enforcement
# fails.
#
# CI-sensitive file review was previously here as a ``ci-review`` job but
# has moved to ``review-labels.yml`` so it can be rerun independently.
on:
workflow_call:
@ -15,14 +18,9 @@ on:
description: The event name from the calling orchestrator (pull_request or push).
type: string
required: true
ci_review:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
type: boolean
default: false
permissions:
contents: read
pull-requests: write # needed to post/update PR comments
concurrency:
group: lint-${{ github.ref }}
@ -162,115 +160,3 @@ jobs:
- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all
ci-review:
# Require explicit maintainer review when CI-sensitive files change:
# eslint config, workflow YAMLs, or composite actions. These files
# influence what code the js-autofix job executes and pushes to
# main, so a malicious PR could inject arbitrary code via a custom eslint
# rule's `fix` function. The label gate ensures a human reviews before
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
name: CI-sensitive file review
if: inputs.event_name == 'pull_request' && inputs.ci_review
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Require ci-reviewed label
id: label-check
env:
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
echo "reviewed=true" >> "$GITHUB_OUTPUT"
echo "ci-reviewed label present."
exit 0
fi
echo "reviewed=false" >> "$GITHUB_OUTPUT"
# On failure: find the bot's previous comment and edit it, or create
# a new one if none exists. Using an HTML comment marker so we can
# locate it reliably across runs without parsing the body text.
# Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API
# call would fail. The label gate still holds via the step below.
- name: Post or update review warning
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
MARKER="<!-- ci-review-bot -->"
BODY="${MARKER}
## ⚠️ CI-sensitive file review required
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
or composite actions). These files influence what code the
js-autofix job executes and pushes to main.
A maintainer should verify:
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
- no workflow changes that widen permissions or remove guards,
- no composite action changes that alter what gets executed.
After review, add the \`ci-reviewed\` label and re-run this check."
# Find an existing comment with our marker.
COMMENT_ID=$(gh api \
"repos/${{ github.repository }}/issues/${PR}/comments" \
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
| head -1 || true)
if [ -n "$COMMENT_ID" ]; then
gh api --method PATCH \
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-f body="$BODY"
else
gh pr comment "$PR" --body "$BODY"
fi
# Fail the job when the label is missing — always runs (including
# fork PRs) so the security gate holds even when the comment step
# was skipped above.
- name: Fail on missing label
if: steps.label-check.outputs.reviewed != 'true'
run: |
echo "::error::CI-sensitive changes require the ci-reviewed label."
exit 1
# On success: if a previous warning comment exists, edit it to show
# the review passed so the PR doesn't have a stale ⚠️ sitting around.
# Skipped on fork PRs — no comment was ever posted to update.
- name: Update previous warning to passed
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
MARKER="<!-- ci-review-bot -->"
# Find an existing comment with our marker.
COMMENT_ID=$(gh api \
"repos/${{ github.repository }}/issues/${PR}/comments" \
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
| head -1 || true)
if [ -n "$COMMENT_ID" ]; then
BODY="${MARKER}
## ✅ CI-sensitive file review passed
The \`ci-reviewed\` label is present on this PR."
gh api --method PATCH \
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-f body="$BODY"
fi

View file

@ -7,22 +7,25 @@ name: Lockfile diff
# the ``packages`` map at the merge base and at HEAD and set-diffs the
# {install path: version} maps instead.
#
# The comment is upserted: the script embeds a hidden HTML marker and the
# workflow PATCHes the existing comment when one is found, so a PR gets
# exactly one lockfile-diff comment that tracks the latest push instead
# of a stack of stale ones. When a later push reverts all lockfile
# changes, the comment is updated to say so (deleting it would be more
# surprising than telling the reviewer it's resolved).
# The semantic diff is exposed as a workflow_call output ``review_status``
# (a JSON array in the unified status format) and an artifact
# (``lockfile-diff`` containing the markdown fragment) for the step
# summary.
#
# Never blocking — this is review signal, not enforcement. Exit is 0 even
# when commenting fails (fork PRs get a read-only GITHUB_TOKEN).
# Never blocking — this is review signal, not enforcement.
on:
workflow_call:
outputs:
changed:
description: Whether package-lock.json changed relative to the target branch.
value: ${{ jobs.diff.outputs.changed }}
review_status:
description: JSON array of review status objects for the unified PR comment.
value: ${{ jobs.diff.outputs.review_status }}
permissions:
contents: read
pull-requests: write # post/update the diff comment
concurrency:
group: lockfile-diff-${{ github.event.pull_request.number || github.ref }}
@ -33,6 +36,9 @@ jobs:
name: package-lock.json semantic diff
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
changed: ${{ steps.diff.outputs.changed }}
review_status: ${{ steps.emit-status.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -54,45 +60,36 @@ jobs:
--output /tmp/lockfile-diff.md
if [ -s /tmp/lockfile-diff.md ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY"
{
echo "## package-lock.json semantic diff"
echo ""
cat /tmp/lockfile-diff.md
} >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
: > /tmp/lockfile-diff.md
fi
- name: Post or update PR comment
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
CHANGED: ${{ steps.diff.outputs.changed }}
- name: Emit review_status
id: emit-status
run: |
set -euo pipefail
MARKER='<!-- hermes-lockfile-diff -->'
CHANGED="${{ steps.diff.outputs.changed }}"
STATUS="[]"
# Find our previous comment (paginated — busy PRs exceed one page).
EXISTING=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" \
--jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \
| head -1 || true)
if [ "$CHANGED" != "true" ]; then
if [ -n "$EXISTING" ]; then
# A previous push changed the lockfile but the latest one
# doesn't — update the comment rather than leave stale info.
printf '%s\n✅ package-lock.json changes from an earlier push have been reverted — locked versions now match the target branch.\n' "$MARKER" > /tmp/lockfile-diff.md
else
echo "No lockfile changes and no existing comment — nothing to do."
exit 0
fi
fi
if [ -n "$EXISTING" ]; then
echo "Updating existing comment ${EXISTING}"
gh api --method PATCH "repos/${REPO}/issues/comments/${EXISTING}" \
-F body=@/tmp/lockfile-diff.md > /dev/null \
|| echo "::warning::Could not update PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
if [ "$CHANGED" = "true" ]; then
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}"
else
echo "Creating new comment"
gh api "repos/${REPO}/issues/${PR}/comments" \
-F body=@/tmp/lockfile-diff.md > /dev/null \
|| echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
STATUS="[]"
fi
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
- name: Upload diff artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lockfile-diff
path: /tmp/lockfile-diff.md
retention-days: 1
overwrite: true

View file

@ -14,14 +14,14 @@ name: OSV-Scanner
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
# dep became known-vulnerable" case.
#
# Steps below are inlined from Google's officially-recommended reusable
# workflow (google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml),
# rather than called via `uses:` so we can set a `timeout-minutes` in the
# degenerate case where this job hangs.
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
# fail-on-vuln is disabled so the job does not block merges on pre-existing
# vulnerabilities in pinned deps that we may need to patch deliberately.
#
# The reusable workflow can't emit custom outputs, so a wrapper job
# downloads the SARIF result and summarizes the vulnerability count into
# a review_status for the unified PR comment.
on:
workflow_call:
@ -40,62 +40,88 @@ permissions:
jobs:
scan:
name: Scan lockfiles
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
# The upstream reusable workflow uploads this exact file under its
# fixed artifact name, which the wrapper downloads below.
results-file-name: osv-results.sarif
fail-on-vuln: false
emit-status:
name: Emit review status
runs-on: ubuntu-latest
needs: scan
if: always()
outputs:
review_status: ${{ steps.emit.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 'Run scanner'
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--output=results.json
--format=json
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
continue-on-error: true
- name: 'Run osv-scanner-reporter'
uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
scan-args: |-
--output=results.sarif
--new=results.json
--gh-annotations=false
--fail-on-vuln=false
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: 'Upload artifact'
id: 'upload_artifact'
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- name: Download SARIF result
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: OSV Scanner SARIF file
path: results.sarif
retention-days: 5
path: /tmp/osv-results
continue-on-error: true
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
if: ${{ !cancelled() }}
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
sarif_file: results.sarif
- name: 'Print Code Scanning URL'
if: ${{ !cancelled() }}
- name: Emit review_status
id: emit
run: |
echo "View the OSV-Scanner results in the 'Security' tab, using the following link:"
echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}+tool%3Aosv-scanner"
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
set -euo pipefail
STATUS="[]"
- name: 'Error troubleshooter'
if: ${{ always() && steps.upload_artifact.outcome == 'failure' }}
run: |
echo "::error::Artifact upload failed. This is most likely caused by a error during scanning earlier in the workflow."
exit 1
if [ -f /tmp/osv-results/osv-results.sarif ]; then
# Count vulnerabilities from the SARIF file
VULN_COUNT=$(python3 -c "
import json, sys
try:
with open('/tmp/osv-results/osv-results.sarif') as f:
data = json.load(f)
count = 0
vulns = []
for run in data.get('runs', []):
for result in run.get('results', []):
count += 1
rule_id = result.get('ruleId', 'unknown')
message = result.get('message', {}).get('text', '')
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
vulns.append(f'- {rule_id} in {loc}: {message}')
print(count)
if vulns:
print('\n'.join(vulns[:20]), file=sys.stderr)
except Exception:
print(0)
")
VULN_DETAIL=""
if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then
VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies")
VULN_DETAIL=$(python3 -c "
import json, sys
try:
with open('/tmp/osv-results/osv-results.sarif') as f:
data = json.load(f)
vulns = []
for run in data.get('runs', []):
for result in run.get('results', []):
rule_id = result.get('ruleId', 'unknown')
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
vulns.append(f'- {rule_id} in {loc}')
print(json.dumps('\n'.join(vulns[:20])))
except Exception:
print(json.dumps(''))
")
STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]"
else
STATUS="[]"
fi
fi
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"

View file

@ -0,0 +1,69 @@
name: Publish E2E evidence
# This runs only from the default branch after CI completes. It intentionally
# checks out main, never the PR ref, and treats the downloaded artifact as
# untrusted input before uploading validated GitHub attachments.
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
actions: read
contents: read
pull-requests: write
concurrency:
group: publish-e2e-evidence-${{ github.event.workflow_run.id }}
cancel-in-progress: false
jobs:
publish:
name: Publish inline E2E evidence
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: gh-image
steps:
- name: Check out trusted publisher
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
# v1.2.0 resolves to 44f4b93ecbbe22de6c45fa2f62f519aee564ca8c.
- name: Install gh-image
run: gh extension install drogers0/gh-image --pin v1.2.0
- name: Download and attach evidence
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
GH_SESSION_TOKEN: ${{ secrets.GH_IMAGE_SESSION_TOKEN }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
run: |
set -euo pipefail
PR_NUMBER=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID" --jq '.pull_requests[0].number // empty')
if [ -z "$PR_NUMBER" ]; then
echo "No pull request is associated with CI run $SOURCE_RUN_ID."
exit 0
fi
ARTIFACT_NAME=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID/artifacts" \
--jq '.artifacts[] | select(.expired == false and (.name | startswith("e2e-evidence-"))) | .name' \
| python3 -c 'import sys; print(next(iter(sys.stdin), "").strip())')
if [ -z "$ARTIFACT_NAME" ]; then
echo "No E2E evidence artifact was produced for CI run $SOURCE_RUN_ID."
exit 0
fi
EVIDENCE_DIR="$RUNNER_TEMP/e2e-evidence"
mkdir -p "$EVIDENCE_DIR"
gh run download "$SOURCE_RUN_ID" --repo "$SOURCE_REPO" --name "$ARTIFACT_NAME" --dir "$EVIDENCE_DIR"
python3 scripts/ci/publish_e2e_evidence.py \
--evidence-dir "$EVIDENCE_DIR" \
--source-repo "$SOURCE_REPO" \
--pr-number "$PR_NUMBER"

109
.github/workflows/review-labels.yml vendored Normal file
View file

@ -0,0 +1,109 @@
name: Review labels
# Require explicit maintainer review when CI-sensitive files or the MCP
# catalog change. Previously this was split across two jobs in two
# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and
# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on
# ``mcp_catalog``). Both checked for their own label.
#
# Now consolidated: a single ``ci-reviewed`` label covers both. The
# comment sections tell the reviewer exactly what to verify per area,
# so one label is enough — the human reads the comment, not the label
# name.
#
# Outputs:
# ci_reviewed — "true" / "false" / "" (empty when neither lane ran)
# review_status — JSON array of status objects consumed by the review
# comment assembler. See scripts/ci/emit_review_status.py.
on:
workflow_call:
inputs:
ci_review:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
type: boolean
default: false
ci_review_files:
description: JSON list of CI-sensitive files changed by the pull request.
type: string
default: '[]'
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
default: false
supply_chain:
description: Whether the critical supply-chain scan found a risk requiring review.
type: boolean
default: false
outputs:
ci_reviewed:
description: Whether the ci-reviewed label is present. Empty when neither input was true.
value: ${{ jobs.check.outputs.ci_reviewed }}
review_status:
description: JSON array of status objects for the review comment assembler.
value: ${{ jobs.check.outputs.review_status }}
permissions:
contents: read
pull-requests: read # read PR labels
jobs:
check:
name: Review label gate
if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }}
review_status: ${{ steps.build-status.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check ci-reviewed label
id: label-check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
echo "ci-reviewed label present."
echo "ci_reviewed=true" >> "$GITHUB_OUTPUT"
else
echo "ci-reviewed label missing."
echo "ci_reviewed=false" >> "$GITHUB_OUTPUT"
fi
- name: Build review_status JSON
id: build-status
env:
CI_REVIEW: ${{ inputs.ci_review }}
CI_REVIEW_FILES: ${{ inputs.ci_review_files }}
MCP_CATALOG: ${{ inputs.mcp_catalog }}
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
args=()
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
args+=(--ci-review-files "$CI_REVIEW_FILES")
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
python3 scripts/ci/emit_review_status.py "${args[@]}" \
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
--output "$GITHUB_OUTPUT"
- name: Fail on missing label
if: steps.label-check.outputs.ci_reviewed != 'true'
run: |
echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check."
exit 1

View file

@ -21,6 +21,7 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: trusted-automation
steps:
- name: Probe live index
id: probe
@ -108,10 +109,18 @@ jobs:
echo "Summary: ${{ steps.probe.outputs.summary }}"
fi
- name: Get GitHub App token
if: steps.probe.outputs.status != 'ok'
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Open issue on degraded / failed probe
if: steps.probe.outputs.status != 'ok'
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STATUS: ${{ steps.probe.outputs.status }}
DETAIL: ${{ steps.probe.outputs.detail }}
run: |

View file

@ -21,9 +21,17 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
@ -35,7 +43,7 @@ jobs:
- name: Build skills index
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
@ -53,8 +61,15 @@ jobs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
steps:
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}

View file

@ -10,9 +10,18 @@ name: Supply Chain Audit
# 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.
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as
# inputs; this workflow's jobs gate on those inputs instead of re-computing
# the diff. MCP catalog review was previously here but has moved to
# ``review-labels.yml`` so it can be rerun independently.
#
# Outputs:
# review_status — JSON array of status objects consumed by the review
# comment assembler (scripts/ci/assemble_review_comment.py).
# critical_findings — "true" when the narrow critical-pattern scan found
# something. The review-label gate consumes this and
# owns the action-required result, so adding
# ``ci-reviewed`` can heal the run on rerun.
on:
workflow_call:
@ -29,10 +38,13 @@ on:
description: Whether pyproject.toml changed.
type: boolean
required: true
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
required: true
outputs:
review_status:
description: JSON array of review status objects for the review comment assembler.
value: ${{ jobs.aggregate.outputs.review_status }}
critical_findings:
description: Whether the critical-pattern scan found a risk requiring maintainer review.
value: ${{ jobs.aggregate.outputs.critical_findings }}
permissions:
pull-requests: write
@ -44,6 +56,9 @@ jobs:
if: inputs.scan
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.emit-status.outputs.review_status }}
critical_findings: ${{ steps.scan.outputs.found }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -53,7 +68,8 @@ jobs:
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ github.token }}
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
run: |
set -euo pipefail
@ -61,7 +77,7 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
# Added lines only, excluding lockfiles.
# Three-dot diff (base...head) diffs from the merge base to HEAD,
# Three-point diff (base...head) diffs from the merge base to HEAD,
# so only changes introduced by this PR are included — not changes
# that landed on main after the PR branched off.
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
@ -71,7 +87,7 @@ jobs:
# --- .pth files (auto-execute on Python startup) ---
# The exact mechanism used in the litellm supply chain attack:
# https://github.com/BerriAI/litellm/issues/24512
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
@ -119,8 +135,11 @@ jobs:
# auto-loaded by the interpreter via site.py. Any nested file with the
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
# and produced false positives that trained reviewers to ignore the scanner.
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
if [ -n "$SETUP_HITS" ]; then
SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
# A maintainer-applied ci-reviewed label records the manual review
# required for intentional changes to an install hook. The scanner
# still blocks every unreviewed addition or modification.
if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: Install-hook file added or modified
These files can execute code during package installation or interpreter startup.
@ -139,33 +158,32 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Post critical finding comment
if: steps.scan.outputs.found == 'true'
- name: Emit review_status
id: emit-status
if: always()
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
FOUND: ${{ steps.scan.outputs.found }}
run: |
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
python3 - <<'PYEOF'
import json, os
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
# The review-label gate renders and blocks critical findings. Keep
# this scan a fact-finder so adding ci-reviewed can rerun the gate
# without requiring the scanner itself to fail again.
status = []
$(cat /tmp/findings.md)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
---
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
- name: Fail on critical findings
if: steps.scan.outputs.found == 'true'
run: |
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
dep-bounds:
name: Check PyPI dependency upper bounds
if: inputs.deps
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.emit-status.outputs.review_status }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -188,7 +206,7 @@ jobs:
exit 0
fi
# Match PyPI dep specs that have >= but no < ceiling.
# Match PyPI dep specs that have >= and no < ceiling.
# Pattern: "package>=version" without a following ",<" bound.
# Excludes git+ URLs (which use commit SHAs) and comments.
UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true)
@ -200,26 +218,36 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Post unbounded dep warning
if: steps.bounds.outputs.found == 'true'
- name: Emit review_status
id: emit-status
if: always()
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
FOUND: ${{ steps.bounds.outputs.found }}
run: |
BODY="## ⚠️ Unbounded PyPI Dependency Detected
python3 - <<'PYEOF'
import json, os
This PR adds PyPI dependencies without a \`<next_major\` upper bound. Per our [supply chain policy](../blob/main/CONTRIBUTING.md#dependency-pinning-policy-supply-chain-hardening), all PyPI deps must be pinned as \`>=floor,<next_major\`.
found = os.environ.get("FOUND", "") == "true"
**Unbounded specs found:**
\`\`\`
$(cat /tmp/unbounded.txt)
\`\`\`
if found:
with open("/tmp/unbounded.txt", encoding="utf-8") as f:
detail = f.read()
status = [{
"source": "supply chain",
"results": [{
"kind": "action_required",
"title": "Unbounded PyPI dependencies",
"summary": "This PR adds PyPI dependencies without upper bounds.",
"detail": detail,
"how_to_fix": 'Add a `<next_major` upper bound, e.g. `"package>=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.'
}]
}]
else:
status = []
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
---
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
- name: Fail on unbounded deps
if: steps.bounds.outputs.found == 'true'
@ -227,45 +255,39 @@ jobs:
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
exit 1
mcp-catalog-review:
name: MCP catalog security review
if: inputs.mcp_catalog
aggregate:
name: Aggregate review statuses
needs: [scan, dep-bounds]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.merge.outputs.review_status }}
critical_findings: ${{ steps.merge.outputs.critical_findings }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Require explicit MCP catalog review label
- name: Merge review statuses
id: merge
env:
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
echo "MCP catalog review label present."
exit 0
fi
python3 - <<'PYEOF'
import json, os
BODY="## ⚠️ MCP catalog security review required
merged = []
for key in ("SCAN_STATUS", "DEP_STATUS"):
raw = os.environ.get(key, "")
if not raw:
continue
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(data, list):
merged.extend(data)
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
A maintainer should verify:
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
- stdio transports do not use shell+egress/exfiltration payloads,
- git install refs are pinned and bootstrap commands are minimal,
- requested env vars/secrets match the upstream MCP's documented needs.
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
exit 1
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(merged)}\n")
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
PYEOF

View file

@ -215,11 +215,6 @@ jobs:
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Packaged-wheel i18n smoke test
run: |
source .venv/bin/activate
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
- name: Run e2e tests
run: |
source .venv/bin/activate

View file

@ -1,181 +0,0 @@
name: Publish to PyPI
# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
# Can also be triggered manually from the Actions tab as an escape hatch.
on:
push:
tags:
- "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."
required: true
type: string
# Restrict default token to read-only; each job escalates as needed.
permissions:
contents: read
# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
concurrency:
group: pypi-publish
cancel-in-progress: false
jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
ref: ${{ inputs.confirm_tag || github.ref }}
fetch-tags: true
- name: Validate tag exists
if: github.event_name == 'workflow_dispatch'
run: |
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
exit 1
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Build web dashboard
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: web
- name: Compile web dashboard
run: npm run build
working-directory: web
- name: Build TUI bundle
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: ui-tui
- name: Compile TUI bundle
run: npm run build
working-directory: ui-tui
- name: Bundle TUI into hermes_cli
run: |
mkdir -p hermes_cli/tui_dist
cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js
- name: Verify frontend assets exist
run: |
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }
- name: Bundle install scripts into wheel
run: |
mkdir -p hermes_cli/scripts
cp scripts/install.sh hermes_cli/scripts/install.sh
cp scripts/install.ps1 hermes_cli/scripts/install.ps1
- name: Build wheel and sdist
run: uv build --sdist --wheel
- name: Upload distribution artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-package-distributions
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: pypi
url: https://pypi.org/p/hermes-agent
permissions:
id-token: write # OIDC trusted publishing
steps:
- name: Download distribution artifacts
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
with:
skip-existing: true
sign:
name: Sign and attach to GitHub Release
# Only runs on tag pushes — release.py creates the GitHub Release,
# and workflow_dispatch won't have a matching release to attach to.
if: startsWith(github.ref, 'refs/tags/')
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
for i in $(seq 1 30); do
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME found"
exit 0
fi
echo "Waiting for release... ($i/30)"
sleep 10
done
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
echo "skip_sign=true" >> "$GITHUB_ENV"
- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
gh release upload
"$GITHUB_REF_NAME" dist/*.sigstore.json
--repo "$GITHUB_REPOSITORY"
--clobber

View file

@ -45,6 +45,10 @@ name: uv.lock check
on:
workflow_call:
outputs:
review_status:
description: "JSON review status for the review-status aggregator"
value: ${{ jobs.check.outputs.review_status }}
permissions:
contents: read
@ -58,6 +62,8 @@ jobs:
name: uv lock --check
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
review_status: ${{ steps.verify.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -73,6 +79,7 @@ jobs:
# of this file) — failures often mean "your branch is behind main,
# rebase and regenerate uv.lock."
- name: Verify uv.lock is up-to-date
id: verify
run: |
# uv lock --check re-resolves against PyPI (network). Retry so a
# registry blip doesn't read as "lockfile stale". A genuinely stale
@ -117,5 +124,9 @@ jobs:
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."
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
exit 1
fi
review_status='[]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"

19
.gitignore vendored
View file

@ -4,6 +4,8 @@
/_pycache/
*.pyc*
__pycache__/
act/
.act-sandbox-agent.*
.venv/
.venv
.vscode/
@ -42,7 +44,10 @@ run_datagen_sonnet.sh
source-data/*
run_datagen_megascience_glm4-6.sh
data/*
node_modules/
# No trailing slash: also matches node_modules SYMLINKS (worktrees often
# symlink node_modules to the main checkout; the dir-only pattern let one
# slip into a commit and break `npm ci` on CI with ENOTDIR).
node_modules
browser-use/
agent-browser/
# Private keys
@ -54,6 +59,10 @@ __pycache__/
hermes_agent.egg-info/
wandb/
testlogs
playwright-report/
test-results/
# Playwright visual regression baselines — cached from main in CI, not committed
*-snapshots/
# CLI config (may contain sensitive SSH paths)
cli-config.yaml
@ -66,6 +75,8 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Cross-process web UI build lock (flock target, always empty)
.web_ui_build.lock
apps/desktop/build/
apps/desktop/dist/
@ -139,6 +150,11 @@ docs/superpowers/*
.update-incomplete
.update-incomplete.lock
# Installer-written method stamp in the managed checkout root (scripts/install.sh).
# Runtime metadata only — never a code change. Ignore so `git status` stays clean
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
/.install_method
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
@ -158,3 +174,4 @@ apps/desktop/demo/
# PR body is the archive. See the hermes-agent-dev skill's
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
infographic/
native/fts5_cjk/*.so

View file

@ -998,7 +998,8 @@ Two shapes:
Roles:
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
`clarify`, `memory`, `send_message`, `execute_code`.
`clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
(programmatic tool calling).
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
own workers. Gated by `delegation.orchestrator_enabled` (default true)
and bounded by `delegation.max_spawn_depth` (default 2).

View file

@ -73,17 +73,19 @@ RUN set -eu; \
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
# #34192: backward-compat shim for orchestration templates that still\
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
# of this file), but external wrappers pinned to /usr/bin/tini will\
# crash with 'tini: No such file or directory' on startup. The shim\
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
# PID-1 reaper without behavior change for users on the current\
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
ln -sf /init /usr/bin/tini
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
# #34192 / #66679: backward-compat shim for orchestration templates that
# still reference the legacy /usr/bin/tini entrypoint (Hostinger's
# 'Hermes WebUI' catalog, NAS compose projects that preserve an old
# entrypoint on image update, etc.). A plain symlink to /init made the
# path exist, but forwarded tini flags like `-g` into s6-overlay's
# rc.init as the container CMD (`rc.init: 91: -g: not found`) and
# boot-looped any `restart: unless-stopped` deploy. The shim strips the
# tini CLI surface, then exec's /init + main-wrapper — see
# docker/tini-shim.sh. Safe to drop once the affected catalogs are
# updated.
COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes

View file

@ -1,13 +0,0 @@
graft skills
graft optional-skills
graft optional-mcps
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
recursive-include gateway/assets *
global-exclude __pycache__
global-exclude *.py[cod]

View file

@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
"""Bootstrap agent-browser + Chromium.
Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code
with ``hermes postinstall`` and the runtime lazy installer.
with the runtime lazy installer.
Returns 0 on success, 1 on failure.
"""

View file

@ -74,6 +74,10 @@ from acp_adapter.permissions import make_approval_callback
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 agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
ContextCompressor,
)
from tools.approval import (
reset_hermes_interactive_context,
set_hermes_interactive_context,
@ -456,7 +460,7 @@ class HermesACPAgent(acp.Agent):
"tools": "List available tools",
"context": "Show conversation context info",
"reset": "Clear conversation history",
"compact": "Compress conversation context",
"compress": "Compress conversation context",
"steer": "Inject guidance into the currently running agent turn",
"queue": "Queue a prompt to run after the current turn finishes",
"version": "Show Hermes version",
@ -485,7 +489,7 @@ class HermesACPAgent(acp.Agent):
"description": "Clear conversation history",
},
{
"name": "compact",
"name": "compress",
"description": "Compress conversation context",
},
{
@ -969,11 +973,49 @@ class HermesACPAgent(acp.Agent):
return text
return ""
@staticmethod
def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None:
"""Build the ``_meta`` payload for a replayed compaction summary.
Compaction summaries are persisted as ordinary history messages
standalone handoffs under ``role="user"`` OR ``role="assistant"``
(the compressor picks whichever role keeps alternation valid), and
merge-into-tail messages where the summary is appended after the
first preserved tail message's real content. Without a wire flag,
ACP frontends render all of these as ordinary turns.
Two distinct keys under ``_meta.hermes`` (ACP's extensibility
channel), so clients cannot accidentally hide real content:
* ``compactionSummary: true`` the entire chunk is the handoff
summary. Safe to restyle or collapse wholesale.
* ``containsCompactionSummary: true`` a merged-tail message: real
preserved turn content followed by the summary. Clients may style
it, but collapsing the whole chunk would hide the preserved
content, hence the separate key.
Detection honors the in-process ``_compressed_summary`` flag and
falls back to content classification, so it also works for a
DB-reloaded session that lost the in-memory flag.
"""
kind = ContextCompressor.classify_summary_content(text)
if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY):
# Flagged in-process but content didn't classify (e.g. future
# prefix drift): treat as a standalone summary — the flag is only
# ever set on summary-bearing messages.
kind = "standalone"
if kind == "standalone":
return {"hermes": {"compactionSummary": True}}
if kind == "merged":
return {"hermes": {"containsCompactionSummary": True}}
return None
@staticmethod
def _history_message_update(
*,
role: str,
text: str,
field_meta: dict[str, Any] | None = None,
) -> UserMessageChunk | AgentMessageChunk | None:
"""Build an ACP history replay update for a user/assistant message."""
block = TextContentBlock(type="text", text=text)
@ -981,11 +1023,13 @@ class HermesACPAgent(acp.Agent):
return UserMessageChunk(
session_update="user_message_chunk",
content=block,
field_meta=field_meta,
)
if role == "assistant":
return AgentMessageChunk(
session_update="agent_message_chunk",
content=block,
field_meta=field_meta,
)
return None
@ -1056,7 +1100,11 @@ class HermesACPAgent(acp.Agent):
if role == "user":
text = self._history_message_text(message)
if text:
update = self._history_message_update(role=role, text=text)
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
if update is not None and not await _send(update):
return
continue
@ -1068,7 +1116,11 @@ class HermesACPAgent(acp.Agent):
text = self._history_message_text(message)
if text:
update = self._history_message_update(role=role, text=text)
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
if update is not None and not await _send(update):
return
@ -1218,12 +1270,19 @@ class HermesACPAgent(acp.Agent):
with state.runtime_lock:
if state.is_running and state.current_prompt_text:
state.interrupted_prompt_text = state.current_prompt_text
state.cancel_event.set()
try:
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
state.agent.interrupt()
except Exception:
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
# Publish cancellation and hard-stop the agent before another
# prompt can acquire this lock and mistake the turn for
# redirectable work.
state.cancel_event.set()
try:
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
state.agent.interrupt()
except Exception:
logger.debug(
"Failed to interrupt ACP session %s",
session_id,
exc_info=True,
)
logger.info("Cancelled session %s", session_id)
async def fork_session(
@ -1352,6 +1411,26 @@ class HermesACPAgent(acp.Agent):
elif rewrite_idle:
user_text = steer_text
user_content = steer_text
elif (
text_only_prompt
and isinstance(user_content, str)
and not user_text.startswith("/")
):
# Some ACP clients implement "stop and send" as two protocol calls:
# cancel the active prompt, then submit plain correction text. Keep
# the cancelled request attached so deictic follow-ups ("not that
# file") still have an explicit target.
interrupted_prompt = ""
with state.runtime_lock:
if not state.is_running and state.interrupted_prompt_text:
interrupted_prompt = state.interrupted_prompt_text
state.interrupted_prompt_text = ""
if interrupted_prompt:
user_text = (
f"{interrupted_prompt}\n\n"
f"User correction/guidance after interrupt: {user_text}"
)
user_content = user_text
# Intercept slash commands — handle locally without calling the LLM.
# Slash commands are text-only; if the client included images/resources,
@ -1366,23 +1445,54 @@ class HermesACPAgent(acp.Agent):
await self._send_usage_update(state)
return PromptResponse(stop_reason="end_turn")
# If Zed sends another regular prompt while the same ACP session is
# still running, queue it instead of racing two AIAgent loops against
# the same state.history. /steer and /queue are handled above and can
# land immediately.
# If the client sends another regular text prompt while this ACP session
# is running, route it through the core active-turn redirect. Rich media
# and older runtimes retain the proven next-turn queue fallback.
redirected = False
queued_depth: int | None = None
with state.runtime_lock:
if state.is_running:
queued_text = user_text or "[Image attachment]"
state.queued_prompts.append(queued_text)
depth = len(state.queued_prompts)
if self._conn:
update = acp.update_agent_message_text(
f"Queued for the next turn. ({depth} queued)"
if (
text_only_prompt
and isinstance(user_content, str)
and getattr(
state.agent,
"_supports_active_turn_redirect",
False,
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
state.is_running = True
state.current_prompt_text = user_text or "[Image attachment]"
is True
and hasattr(state.agent, "redirect")
):
try:
redirected = bool(state.agent.redirect(user_content))
except Exception:
logger.debug(
"ACP active-turn redirect failed for %s",
session_id,
exc_info=True,
)
if not redirected:
queued_text = user_text or "[Image attachment]"
state.queued_prompts.append(queued_text)
queued_depth = len(state.queued_prompts)
else:
state.is_running = True
state.current_prompt_text = user_text or "[Image attachment]"
if redirected:
if self._conn:
update = acp.update_agent_message_text(
"Redirected the active turn with your correction."
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
if queued_depth is not None:
if self._conn:
update = acp.update_agent_message_text(
f"Queued for the next turn. ({queued_depth} queued)"
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
@ -1756,7 +1866,7 @@ class HermesACPAgent(acp.Agent):
"tools": self._cmd_tools,
"context": self._cmd_context,
"reset": self._cmd_reset,
"compact": self._cmd_compact,
"compress": self._cmd_compress,
"steer": self._cmd_steer,
"queue": self._cmd_queue,
"version": self._cmd_version,
@ -1898,7 +2008,7 @@ class HermesACPAgent(acp.Agent):
lines.append(
f"Compression: due now (threshold ~{threshold_tokens:,}"
+ (f", {threshold_pct:.0f}%" if threshold_pct else "")
+ "). Run /compact."
+ "). Run /compress."
)
else:
lines.append(
@ -1911,9 +2021,12 @@ class HermesACPAgent(acp.Agent):
lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens")
if getattr(agent, "compression_enabled", True) is False:
lines.append("Compression is disabled for this agent.")
lines.append(
"Auto-compaction is disabled (compression.enabled: false); "
"/compress still compresses manually."
)
else:
lines.append("Tip: run /compact to compress manually before the threshold.")
lines.append("Tip: run /compress to compress manually before the threshold.")
return "\n".join(lines)
@ -1933,13 +2046,14 @@ class HermesACPAgent(acp.Agent):
return "Conversation history cleared. Agent session state reset failed; see logs."
return "Conversation history cleared."
def _cmd_compact(self, args: str, state: SessionState) -> str:
def _cmd_compress(self, args: str, state: SessionState) -> str:
if not state.history:
return "Nothing to compress — conversation is empty."
try:
agent = state.agent
if not getattr(agent, "compression_enabled", True):
return "Context compression is disabled for this agent."
# No compression_enabled gate: the flag disables *automatic*
# compaction only; manual /compress must keep working (matches
# the CLI /compress and gateway handlers).
if not hasattr(agent, "_compress_context"):
return "Context compression not available for this agent."
@ -1964,6 +2078,7 @@ class HermesACPAgent(acp.Agent):
getattr(agent, "_cached_system_prompt", "") or "",
approx_tokens=approx_tokens,
task_id=state.session_id,
force=True,
)
finally:
agent._session_db = original_session_db

View file

@ -1,16 +0,0 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"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",
"authors": ["Nous Research"],
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.18.2",
"args": ["hermes-acp"]
}
}
}

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 882 B

View file

@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
details.append("(or run /topup)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
"""Surface-agnostic data for the ``/topup`` balance view.
One portal fetch, one parse consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
@ -356,11 +356,11 @@ class CreditsView:
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
"""Build the /topup balance view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
supplies its own affordance). Fail-open ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
# appends for the /usage surface. /topup renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
@ -701,6 +701,18 @@ def redeem_codex_reset_credit(
remaining = max(0, available - 1)
plural = "s" if remaining != 1 else ""
if code == "reset":
# The redeemed reset restores the account's quota upstream — lift any
# persisted pool cooldowns so Hermes doesn't keep the credential
# frozen behind the now-stale ``last_error_reset_at`` (issue #43747).
try:
from hermes_cli.auth import clear_codex_pool_quota_cooldowns
clear_codex_pool_quota_cooldowns()
except Exception:
logger.debug(
"Failed to clear Codex pool cooldowns after reset redemption",
exc_info=True,
)
return CodexResetRedeemResult(
status="reset",
message=(

View file

@ -28,7 +28,7 @@ import time
import uuid
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse
from urllib.parse import parse_qs, urlparse, urlunparse
from agent.context_compressor import ContextCompressor
from agent.iteration_budget import IterationBudget
@ -48,6 +48,7 @@ from agent.tool_guardrails import (
ToolGuardrailDecision,
)
from hermes_cli.config import cfg_get
from hermes_cli.route_identity import normalize_route_base_url
from hermes_cli.timeouts import get_provider_request_timeout
from hermes_constants import get_hermes_home
from utils import base_url_host_matches, is_truthy_value
@ -68,18 +69,151 @@ def _ra():
return run_agent
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
def _normalize_route_base_url(base_url: Any) -> str:
"""Canonicalize an endpoint URL for model-route identity comparisons."""
return normalize_route_base_url(base_url)
def _provider_default_routes(provider: str) -> set[str]:
"""Return known exact default routes for a canonical provider id."""
routes: set[str] = set()
try:
from hermes_cli.providers import HERMES_OVERLAYS, get_provider
overlay = HERMES_OVERLAYS.get(provider)
provider_def = get_provider(provider)
for value in (
getattr(overlay, "base_url_override", ""),
getattr(provider_def, "base_url", ""),
):
route = _normalize_route_base_url(value)
if route:
routes.add(route)
except Exception:
pass
try:
from providers import get_provider_profile
profile = get_provider_profile(provider)
route = _normalize_route_base_url(
getattr(profile, "base_url", "")
)
if route:
routes.add(route)
except Exception:
pass
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.models import normalize_provider as normalize_model_provider
from hermes_cli.providers import normalize_provider as normalize_registry_provider
for provider_id, config in PROVIDER_REGISTRY.items():
canonical_id = normalize_registry_provider(
normalize_model_provider(provider_id)
)
if canonical_id != provider:
continue
route = _normalize_route_base_url(
getattr(config, "inference_base_url", "")
)
if route:
routes.add(route)
except Exception:
pass
if provider == "gemini":
routes.update(
f"{route.rstrip('/')}/openai"
for route in list(routes)
)
return routes
def _context_route_mismatch(
configured_base_url: Any,
active_base_url: Any,
configured_provider: Any,
active_provider: Any,
*,
already_normalized: bool = False,
) -> bool:
"""Return whether a context pin's configured route differs from runtime."""
if already_normalized:
configured_route = str(configured_base_url or "")
active_route = str(active_base_url or "")
else:
configured_route = _normalize_route_base_url(configured_base_url)
active_route = _normalize_route_base_url(active_base_url)
if configured_route:
return configured_route != active_route
configured_provider = str(configured_provider or "").strip()
active_provider = str(active_provider or "").strip()
if not configured_provider:
return False
try:
from hermes_cli.models import normalize_provider as normalize_model_provider
configured_provider = normalize_model_provider(configured_provider)
active_provider = normalize_model_provider(active_provider)
except Exception:
configured_provider = configured_provider.lower()
active_provider = active_provider.lower()
try:
from hermes_cli.providers import normalize_provider as normalize_registry_provider
configured_provider = normalize_registry_provider(configured_provider)
active_provider = normalize_registry_provider(active_provider)
except Exception:
pass
if active_route:
configured_routes = _provider_default_routes(configured_provider)
return not configured_routes or active_route not in configured_routes
return bool(
configured_provider
and active_provider
and configured_provider != active_provider
)
def _normalize_custom_provider_name(value: Any) -> str:
"""Mirror runtime normalization for a requested custom-provider identity."""
return str(value or "").strip().lower().replace(" ", "-")
def _custom_provider_runtime_ids(value: Any) -> set[str]:
"""Return raw/menu identities that runtime accepts for a configured name."""
normalized = _normalize_custom_provider_name(value)
if not normalized:
return set()
return {normalized, f"custom:{normalized}"}
def _build_codex_gpt5_autoraise_notice(
autoraise: Dict[str, Any], context_length: Optional[int] = None
) -> str:
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
The same text is printed inline for CLI users and replayed via
``context_length`` is the live-resolved window from the context compressor
(Codex's /models catalog is authoritative and can change server-side, e.g.
the gpt-5.6 family's 272K → 372K → 272K shifts in July 2026), so the banner
reports what this session actually got rather than a hardcoded cap. 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/5.6 family
# is capped at 272K by the Codex OAuth backend.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
if isinstance(context_length, int) and context_length > 0:
cap = f"{round(context_length / 1000)}K"
else:
# Static fallback when the resolved window isn't available:
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6
# 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 (
@ -586,6 +720,8 @@ def init_agent(
agent._execution_thread_id: int | None = None # Set at run_conversation() start
agent._interrupt_thread_signal_pending = False
agent._client_lock = threading.RLock()
agent._model_request_active = threading.Event()
agent._supports_active_turn_redirect = True
# /steer mechanism — inject a user note into the next tool result
# without interrupting the agent. Unlike interrupt(), steer() does
@ -597,6 +733,13 @@ def init_agent(
agent._pending_steer: Optional[str] = None
agent._pending_steer_lock = threading.Lock()
# Active-turn redirect mechanism. A regular follow-up sent while the model
# is generating is different from a hard /stop: preserve the valid turn
# prefix, cancel only the in-flight model request, and rebuild its tail with
# the correction. The loop drains this slot at a role-safe boundary.
agent._pending_redirect: Optional[str] = None
agent._pending_redirect_lock = threading.Lock()
# Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent`
# runs each tool on its own ThreadPoolExecutor worker — those worker
# threads have tids distinct from `_execution_thread_id`, so
@ -763,6 +906,12 @@ def init_agent(
agent._stream_writer_tls = threading.local()
agent._stream_writer_dropped = 0
# Displayed reasoning text streamed during the current model response,
# captured only when a surface consumed it via a reasoning callback. Used
# by active-turn redirect to checkpoint what the user actually saw without
# ever persisting hidden provider reasoning.
agent._current_streamed_reasoning_text = ""
# Optional current-turn user-message override used when the API-facing
# user message intentionally differs from the persisted transcript
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
@ -1427,7 +1576,14 @@ def init_agent(
agent._memory_nudge_interval = 10
agent._turns_since_memory = 0
agent._iters_since_skill = 0
if not skip_memory:
# A flush/background agent may pass skip_memory=True to avoid spinning up an
# external memory *provider*, but if the caller also explicitly enables the
# "memory" toolset it still needs the built-in file-backed store — otherwise
# the memory tool dispatches with store=None and every call fails (#65429).
# So the built-in store is created unless memory is globally disabled, while
# the external-provider block below stays gated on skip_memory.
_memory_toolset_requested = "memory" in (agent.enabled_toolsets or [])
if not skip_memory or _memory_toolset_requested:
try:
mem_config = _agent_cfg.get("memory", {})
agent._memory_enabled = mem_config.get("memory_enabled", False)
@ -1647,6 +1803,34 @@ def init_agent(
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
# Cap on compression retry rounds before a turn gives up with "max
# compression attempts reached" (compression.max_attempts). Hardcoding 3
# strands sessions that legitimately need more rounds — e.g. a restart
# history reload whose incompressible tool schemas keep the request
# estimate above the threshold even though the messages compress fine
# (the #62605 failure class). Default 3 preserves current behavior, so
# an unset key is behavior-neutral; validated >= 1, hard-capped at 10,
# and any non-int-like value falls back to 3. Booleans are rejected
# (bool subclasses int, so int(True) would silently become 1) and
# fractional floats are rejected rather than truncated — "4.7 attempts"
# is a config mistake, not a request for 4.
_raw_max_attempts = _compression_cfg.get("max_attempts", 3)
if isinstance(_raw_max_attempts, bool):
compression_max_attempts = 3
elif isinstance(_raw_max_attempts, int):
compression_max_attempts = _raw_max_attempts
elif isinstance(_raw_max_attempts, float):
compression_max_attempts = (
int(_raw_max_attempts) if _raw_max_attempts.is_integer() else 3
)
else:
try:
compression_max_attempts = int(str(_raw_max_attempts).strip())
except (TypeError, ValueError):
compression_max_attempts = 3
if compression_max_attempts < 1:
compression_max_attempts = 3
compression_max_attempts = min(compression_max_attempts, 10)
# protect_first_n is the number of non-system messages to protect at
# the head, in addition to the system prompt (which is always
# implicitly protected by the compressor). Floor at 0 — a value of
@ -1659,6 +1843,29 @@ def init_agent(
compression_abort_on_summary_failure = str(
_compression_cfg.get("abort_on_summary_failure", False)
).lower() in {"true", "1", "yes"}
# Per-model threshold overrides: keys are substring-matched against the
# model name (longest match wins). Empty dict = use the global threshold
# for all models (backward compatible).
_raw_model_thresholds = _compression_cfg.get("model_thresholds", {})
if isinstance(_raw_model_thresholds, dict):
compression_model_thresholds = {
str(k): float(v) for k, v in _raw_model_thresholds.items()
if isinstance(v, (int, float)) and not isinstance(v, bool)
}
else:
compression_model_thresholds = {}
# Absolute token cap: when set, compression triggers at the lower of
# the ratio-based threshold and this absolute count. Clamped to the
# model's context length at apply-time so a cap above the window is
# a no-op (ratio-based threshold wins).
compression_threshold_tokens = _compression_cfg.get("threshold_tokens")
if compression_threshold_tokens is not None:
try:
compression_threshold_tokens = int(compression_threshold_tokens)
if compression_threshold_tokens <= 0:
compression_threshold_tokens = None
except (TypeError, ValueError):
compression_threshold_tokens = None
# 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
@ -1677,6 +1884,12 @@ def init_agent(
codex_app_server_auto_compaction,
)
codex_app_server_auto_compaction = "native"
# Opt-in idle compaction: compact a session up front when it resumes after
# this many seconds of inactivity (0 = disabled). Time-based, so it
# complements the size-based threshold above. Consumed by build_turn_context().
compression_idle_compact_after_seconds = max(
0, int(_compression_cfg.get("idle_compact_after_seconds", 0))
)
# Read optional explicit context_length override for the auxiliary
# compression model. Custom endpoints often cannot report this via
@ -1747,8 +1960,9 @@ def init_agent(
)
_config_context_length = None
# Resolve custom_providers list once for reuse below (startup
# context-length override and plugin context-engine init).
# Resolve custom_providers once before route-scoping a global context pin:
# a named custom provider may keep its base URL only in this list rather
# than repeating it under ``model``.
try:
from hermes_cli.config import get_compatible_custom_providers
_custom_providers = get_compatible_custom_providers(_agent_cfg)
@ -1757,6 +1971,163 @@ def init_agent(
if not isinstance(_custom_providers, list):
_custom_providers = []
# ``model.context_length`` describes the configured default model. A
# process launched directly with ``--model`` / ``-m`` has already replaced
# ``agent.model`` before this initializer loads config, so carrying the
# default model's explicit window into that different runtime is stale. The
# live switch/fallback paths already clear this override; keep direct-start
# overrides consistent with them and let provider metadata resolve the
# active model's window instead.
if _config_context_length is not None and isinstance(_model_cfg, dict):
_configured_default_model = str(_model_cfg.get("default") or "").strip()
_configured_default_runtime_model = _configured_default_model
_active_runtime_model = agent.model
if _configured_default_model:
try:
from hermes_cli.model_normalize import normalize_model_for_provider
_configured_default_runtime_model = normalize_model_for_provider(
_configured_default_model, agent.provider
)
_active_runtime_model = normalize_model_for_provider(
agent.model, agent.provider
)
except Exception:
pass
_configured_provider = str(_model_cfg.get("provider") or "").strip()
_configured_base_url = _normalize_route_base_url(
_model_cfg.get("base_url")
)
_configured_provider_norm = _normalize_custom_provider_name(
_configured_provider
)
_custom_provider_candidate = bool(_configured_provider_norm)
_runtime_first_provider_ids = {
"auto",
"moa",
"vertex",
"google-vertex",
"vertex-ai",
"gcp-vertex",
"vertexai",
}
if _configured_provider_norm in _runtime_first_provider_ids:
_custom_provider_candidate = False
elif (
_custom_provider_candidate
and _configured_provider_norm != "custom"
and not _configured_provider_norm.startswith("custom:")
):
try:
from hermes_cli.auth import resolve_provider as resolve_auth_provider
_resolved_auth_provider = resolve_auth_provider(
_configured_provider_norm
)
_custom_provider_candidate = (
str(_resolved_auth_provider or "").strip().lower()
!= _configured_provider_norm
)
except Exception:
pass
if not _configured_base_url and _custom_provider_candidate:
_configured_custom_provider = _normalize_custom_provider_name(
_configured_provider
)
_user_providers = _agent_cfg.get("providers")
_disabled_custom_provider_ids: set[str] = set()
if isinstance(_user_providers, dict):
from hermes_cli.config import is_provider_enabled
for _provider_key, _provider_entry in _user_providers.items():
if not isinstance(_provider_entry, dict):
continue
_entry_name = str(
_provider_entry.get("name") or ""
).strip()
_entry_provider_ids = _custom_provider_runtime_ids(
_provider_key
) | _custom_provider_runtime_ids(_entry_name)
if not is_provider_enabled(_provider_entry):
_disabled_custom_provider_ids.update(
provider_id
for provider_id in _entry_provider_ids
if provider_id
)
continue
if _configured_custom_provider not in _entry_provider_ids:
continue
_configured_base_url = _normalize_route_base_url(
_provider_entry.get("api")
or _provider_entry.get("url")
or _provider_entry.get("base_url")
)
if _configured_base_url:
break
if not _configured_base_url:
for _provider_entry in _custom_providers:
if not isinstance(_provider_entry, dict):
continue
_entry_name = str(
_provider_entry.get("name") or ""
).strip()
_entry_provider_key = str(
_provider_entry.get("provider_key") or ""
).strip().lower()
_entry_provider_ids = _custom_provider_runtime_ids(
_entry_name
) | _custom_provider_runtime_ids(_entry_provider_key)
if (
_entry_provider_key
and _custom_provider_runtime_ids(_entry_provider_key)
& _disabled_custom_provider_ids
):
continue
if _configured_custom_provider not in _entry_provider_ids:
continue
_configured_base_url = _normalize_route_base_url(
_provider_entry.get("base_url")
)
if _configured_base_url:
break
_active_route_url = str(agent.base_url or "")
_requested_route_url = str(base_url or "")
if "?" in _requested_route_url.split("#", 1)[0]:
try:
_requested_parts = urlparse(_requested_route_url)
_requested_without_query = urlunparse(
_requested_parts._replace(query="")
)
if _normalize_route_base_url(
_requested_without_query
) == _normalize_route_base_url(_active_route_url):
_active_route_url = _requested_route_url
except (TypeError, ValueError):
pass
_active_base_url = _normalize_route_base_url(_active_route_url)
_route_mismatch = _context_route_mismatch(
_configured_base_url,
_active_base_url,
_configured_provider,
agent.provider,
already_normalized=True,
)
_model_mismatch = bool(
_configured_default_runtime_model
and _configured_default_runtime_model != _active_runtime_model
)
if _model_mismatch or _route_mismatch:
_ra().logger.debug(
"Ignoring model.context_length=%s for startup runtime %s at %s "
"(configured default is %s at %s)",
_config_context_length,
agent.model,
_active_base_url or agent.provider,
_configured_default_model,
_configured_base_url or _model_cfg.get("provider"),
)
_config_context_length = None
# Store for reuse by _check_compression_model_feasibility (auxiliary
# compression model context-length detection needs the same list).
agent._custom_providers = _custom_providers
@ -1779,11 +2150,11 @@ def init_agent(
# Surface a clear warning if the user set a context_length but it
# wasn't a valid positive int — the helper silently skips those.
if _config_context_length is None:
_target = agent.base_url.rstrip("/") if agent.base_url else ""
_target = _normalize_route_base_url(agent.base_url)
for _cp_entry in _custom_providers:
if not isinstance(_cp_entry, dict):
continue
_cp_url = (_cp_entry.get("base_url") or "").rstrip("/")
_cp_url = _normalize_route_base_url(_cp_entry.get("base_url"))
if _target and _cp_url == _target:
_cp_models = _cp_entry.get("models", {})
if isinstance(_cp_models, dict):
@ -1896,6 +2267,16 @@ def init_agent(
provider=agent.provider,
custom_providers=_custom_providers,
)
# Per-model threshold overrides are part of the explicit
# context-engine contract: assign them BEFORE the initial
# update_model() call so the first resolution (which derives
# threshold_percent/threshold_tokens for the initial model) already
# sees the overrides. Assigning after update_model() left the initial
# model on the engine's global threshold until the first /model
# switch. Engines that override update_model() own their own policy
# and may ignore the attribute.
if compression_model_thresholds:
agent.context_compressor.model_thresholds = compression_model_thresholds
agent.context_compressor.update_model(
model=agent.model,
context_length=_plugin_ctx_len,
@ -1922,6 +2303,8 @@ def init_agent(
api_mode=agent.api_mode,
abort_on_summary_failure=compression_abort_on_summary_failure,
max_tokens=agent.max_tokens,
model_thresholds=compression_model_thresholds,
threshold_tokens_cap=compression_threshold_tokens,
)
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(_bind_session_state):
@ -1932,6 +2315,10 @@ def init_agent(
agent.compression_enabled = compression_enabled
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.max_compression_attempts = compression_max_attempts
agent.compression_idle_compact_after_seconds = (
compression_idle_compact_after_seconds
)
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@ -2116,7 +2503,7 @@ def init_agent(
# 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)
_autoraise = getattr(agent, "_compression_threshold_autoraised", None) or {}
_show_autoraise_notice = (
bool(_autoraise)
and compression_enabled
@ -2132,14 +2519,21 @@ def init_agent(
_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:,})")
_cap_note = ""
_cap = getattr(agent.context_compressor, "threshold_tokens_cap", None)
if _cap and _cap > 0:
_cap_note = f" (capped at {_cap:,} tokens)"
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# 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))
print(_build_codex_gpt5_autoraise_notice(
_autoraise,
context_length=getattr(agent.context_compressor, "context_length", None),
))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
@ -2149,7 +2543,10 @@ def init_agent(
# 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.).
if _show_autoraise_notice:
agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
agent._compression_warning = _build_codex_gpt5_autoraise_notice(
_autoraise,
context_length=getattr(agent.context_compressor, "context_length", None),
)
# 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

View file

@ -26,10 +26,11 @@ import copy
import json
import logging
import re
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from hermes_cli.timeouts import get_provider_request_timeout
from agent.prompt_builder import format_steer_marker
@ -37,6 +38,7 @@ from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_res
from agent.trajectory import convert_scratchpad_to_think
from agent.credential_pool import STATUS_EXHAUSTED
from agent.error_classifier import FailoverReason
from agent.turn_context import drop_stale_api_content
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
logger = logging.getLogger(__name__)
@ -357,9 +359,25 @@ def sanitize_tool_call_arguments(
return repaired
# Session-scoped in-flight registry backing note_turn_start's cross-agent
# check. The per-agent marker catches a second turn on the SAME AIAgent
# object, but the gateway caches agents per *routing key* (``_agent_cache``
# in gateway/run.py) while the durable transcript is keyed by *session_id* —
# and the key→id mapping is many-to-one (``switch_session``: /resume from a
# second chat/topic, CLI-continuity rebinding, async-delegation pinning,
# topic-binding tip-walks). Two routing keys mapped to one session_id run
# concurrent turns on two different agent objects, which per-agent state can
# never see (#64934). Keyed by session_id so that route produces the same
# named warning. Process-local by design — same visibility scope as the
# per-agent marker it extends.
_INFLIGHT_TURNS_BY_SESSION: Dict[str, Tuple[str, float]] = {}
_INFLIGHT_TURNS_LOCK = threading.Lock()
def note_turn_start(agent, turn_id: str):
"""Tripwire: detect a turn starting while the previous turn of the SAME
agent/session has not completed its turn-end persist.
"""Tripwire: detect a turn starting while a previous turn of the same
agent or of the same underlying *session* on a different agent object
has not completed its turn-end persist.
Two turns interleaving on one session corrupt the durable transcript:
their flushes race (user rows can persist out of arrival order), a row
@ -376,6 +394,7 @@ def note_turn_start(agent, turn_id: str):
prev_started = getattr(agent, "_inflight_turn_started", 0.0)
agent._inflight_turn_id = turn_id
agent._inflight_turn_started = time.time()
overlap = None
if prev and prev != turn_id:
logger.warning(
"turn %s starting while turn %s (started %.0fs ago) has not "
@ -386,8 +405,39 @@ def note_turn_start(agent, turn_id: str):
time.time() - prev_started if prev_started else -1.0,
getattr(agent, "session_id", None) or "-",
)
return prev
return None
overlap = prev
# Cross-agent leg: same session_id in flight under a different agent
# object means two routing keys resolve to one durable session — the
# busy guard (keyed by routing key) cannot see this overlap at all.
# Persist-disabled agents (background-review forks) deliberately share
# the live parent's session_id for prompt-cache warmth but can never
# write to the transcript — they must not register here (would warn a
# false overlap against the parent's real turn) nor pop the parent's
# slot at their persist (note_turn_persisted skips them symmetrically).
session_id = getattr(agent, "session_id", None)
if session_id and not getattr(agent, "_persist_disabled", False):
now = time.time()
with _INFLIGHT_TURNS_LOCK:
entry = _INFLIGHT_TURNS_BY_SESSION.get(session_id)
_INFLIGHT_TURNS_BY_SESSION[session_id] = (turn_id, now)
# Stamp the session id this turn registered under: compression can
# rotate agent.session_id mid-turn, and the persist-time clear must
# pop the slot the turn actually holds, not the rotated id.
agent._inflight_turn_session_id = session_id
if entry and entry[0] not in (turn_id, prev):
logger.warning(
"turn %s starting while turn %s (started %.0fs ago) is still "
"in flight on session %s under a different agent object — "
"two routing keys are mapped to one session_id; concurrent "
"turns on one session; transcript writes may interleave",
turn_id,
entry[0],
now - entry[1] if entry[1] else -1.0,
session_id,
)
overlap = overlap or entry[0]
return overlap
def note_turn_persisted(agent):
@ -398,6 +448,18 @@ def note_turn_persisted(agent):
and the tripwire under-reports instead of double-reporting. A diagnostic
must never be noisier than the defect it hunts."""
agent._inflight_turn_id = None
# Symmetric with note_turn_start's cross-agent leg: persist-disabled
# forks never registered a session slot, and their persist funnel still
# runs — popping here would steal the live parent turn's slot and make
# the tripwire under-report the real overlap it exists to catch.
if not getattr(agent, "_persist_disabled", False):
session_id = getattr(agent, "_inflight_turn_session_id", None) or getattr(
agent, "session_id", None
)
if session_id:
with _INFLIGHT_TURNS_LOCK:
_INFLIGHT_TURNS_BY_SESSION.pop(session_id, None)
agent._inflight_turn_session_id = None
def repair_message_sequence(agent, messages: List[Dict]) -> int:
@ -468,6 +530,12 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
or m.get("finish_reason") == "incomplete"
)
def _is_verification_candidate(m: Dict) -> bool:
return m.get("finish_reason") in {
"verification_required",
"verify_hook_continue",
}
collapsed: List[Dict] = []
for msg in messages:
if (
@ -480,6 +548,16 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
and not _is_codex_interim(collapsed[-1])
):
prev = collapsed[-1]
# Verification candidate collapsing: when the earlier assistant
# message is a provisional candidate (finish_reason =
# verification_required / verify_hook_continue), the later
# response supersedes it for model replay — replace rather than
# union. Both remain durable in state.db; this only affects the
# in-memory sequence sent to the model. (#65919 §7)
if _is_verification_candidate(prev):
collapsed[-1] = msg
repairs += 1
continue
# 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 [])
@ -587,6 +665,10 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
if prev_content and new_content
else (prev_content or new_content)
)
# Merged content invalidates the api_content sidecar (exact
# bytes previously sent for the pre-merge message) — drop it
# so replay can't substitute stale bytes.
drop_stale_api_content(prev)
repairs += 1
continue
merged.append(msg)
@ -643,16 +725,16 @@ def strip_think_blocks(agent, content: str) -> str:
"""Remove reasoning/thinking blocks from content, returning only visible text.
Handles four cases:
1. Closed tag pairs (``<think></think>``) the common path when
1. Closed tag pairs (`` <think> ``) the common path when
the provider emits complete reasoning blocks.
2. Unterminated open tag at a block boundary (start of text or
after a newline) e.g. MiniMax M2.7 / NIM endpoints where the
closing tag is dropped. Everything from the open tag to end
of string is stripped. The block-boundary check mirrors
``gateway/stream_consumer.py``'s filter so models that mention
``<think>`` in prose aren't over-stripped.
`` <think>`` in prose aren't over-stripped.
3. Stray orphan open/close tags that slip through.
4. Tag variants: ``<think>``, ``<thinking>``, ``<reasoning>``,
4. Tag variants: `` <think>``, ``<thinking>``, ``<reasoning>``,
``<REASONING_SCRATCHPAD>``, ``<thought>`` (Gemma 4), all
case-insensitive.

View file

@ -127,6 +127,8 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
_ANTHROPIC_OUTPUT_LIMITS = {
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
"claude-fable": 128_000,
# Claude Sonnet 5
"claude-sonnet-5": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@ -247,7 +249,13 @@ def _supports_adaptive_thinking(model: str) -> bool:
only returns False for the explicit legacy list of older Claude families
that require manual budget-based thinking. Non-Claude Anthropic-Messages
models (minimax, qwen3, ) return False so they keep the manual path.
Kimi / Moonshot models are the exception: their Anthropic-compatible
endpoints implement the adaptive contract (``thinking.type="adaptive"``
+ ``output_config.effort``, including ``xhigh`` and ``display``).
"""
if _model_name_is_kimi_family(model):
return True
if not _is_claude_model(model):
return False
m = model.lower()
@ -449,7 +457,8 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``,
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
# Matched case-insensitively against the post-``normalize_model_name`` form,
# so a caller's ``provider/vendor/model`` slug is handled the same as a
# bare name.
@ -459,8 +468,14 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
"k1.", "k1-",
"k2.", "k2-",
"k25", "k2.5",
"k3.", "k3-",
)
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
# merely start with the same characters don't get misclassified.
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
def _model_name_is_kimi_family(model: str | None) -> bool:
if not isinstance(model, str):
@ -471,6 +486,8 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
if "/" in m:
m = m.rsplit("/", 1)[-1]
if m in _KIMI_FAMILY_EXACT_SLUGS:
return True
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
@ -1574,7 +1591,10 @@ def _is_bedrock_model_id(model: str) -> bool:
"""
lower = model.lower()
# Regional inference-profile prefixes
if any(lower.startswith(p) for p in ("global.", "us.", "eu.", "ap.", "jp.")):
if any(lower.startswith(p) for p in (
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.",
"ca.", "sa.", "me.", "af.",
)):
return True
# Bare Bedrock model IDs: provider.model-family
if lower.startswith("anthropic."):
@ -2276,13 +2296,6 @@ def _manage_thinking_signatures(
"""
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
# Kimi / DeepSeek share a contract: strip signed Anthropic blocks
# (neither upstream can validate Anthropic signatures), preserve unsigned
# ones synthesised from reasoning_content. See #13848, #16748.
_preserve_unsigned_thinking = (
_is_kimi_family_endpoint(base_url, model)
or _is_deepseek_anthropic_endpoint(base_url)
)
last_assistant_idx = None
for i in range(len(result) - 1, -1, -1):
@ -2294,8 +2307,12 @@ def _manage_thinking_signatures(
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
continue
if _preserve_unsigned_thinking:
# Kimi / DeepSeek: strip signed, preserve unsigned.
if _is_kimi_family_endpoint(base_url, model):
# Kimi does not enforce thinking signatures — replay as-is
# (shared cleanup below still strips cache markers + the internal flag).
pass
elif _is_deepseek_anthropic_endpoint(base_url):
# DeepSeek: strip signed, preserve unsigned.
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
@ -2395,6 +2412,24 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
]
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
"""Anthropic requires messages[0] to have role=user.
After a second context compaction on the auto path the summary can be
emitted as role=assistant with nothing in front of it (the system prompt
lives outside messages[] or is extracted into the separate ``system``
param), so messages[0] ends up assistant and the Messages API rejects
the request with HTTP 400 often masked by a misleading
"tool_use ids were found without tool_result blocks" error (#52160).
Mirror the Bedrock Converse adapter, which unconditionally prepends a
minimal user turn when the first message is not user
(convert_messages_to_converse).
"""
if result and result[0].get("role") != "user":
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
@ -2453,6 +2488,7 @@ def convert_messages_to_anthropic(
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_ensure_leading_user_turn(result)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)
@ -2627,25 +2663,19 @@ def build_anthropic_kwargs(
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
#
# Kimi's /coding endpoint speaks the Anthropic Messages protocol but has
# its own thinking semantics: when ``thinking.enabled`` is sent, Kimi
# validates the message history and requires every prior assistant
# tool-call message to carry OpenAI-style ``reasoning_content``. The
# Anthropic path never populates that field, and
# ``convert_messages_to_anthropic`` strips all Anthropic thinking blocks
# on third-party endpoints — so the request fails with HTTP 400
# "thinking is enabled but reasoning_content is missing in assistant
# tool call message at index N". Kimi's reasoning is driven server-side
# on the /coding route, so skip Anthropic's thinking parameter entirely
# for that host. (Kimi on chat_completions enables thinking via
# extra_body in the ChatCompletionsTransport — see #13503.)
# Kimi / Moonshot models also use adaptive thinking: their
# Anthropic-compatible endpoints (api.moonshot.cn/anthropic,
# api.kimi.com/coding) accept ``thinking.type="adaptive"`` +
# ``output_config.effort``, and the replay-validation 400s that
# originally motivated dropping the parameter (#13848) no longer
# occur. (Kimi on chat_completions enables thinking via extra_body
# in the ChatCompletionsTransport — see #13503.)
#
# On 4.7+ the `thinking.display` field defaults to "omitted", which
# silently hides reasoning text that Hermes surfaces in its CLI. We
# request "summarized" so the reasoning blocks stay populated — matching
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
_is_kimi_coding = _is_kimi_family_endpoint(base_url, model)
if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding:
if reasoning_config and isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
effort = str(reasoning_config.get("effort", "medium")).lower()
budget = THINKING_BUDGET.get(effort, 8000)

View file

@ -66,3 +66,19 @@ def safe_schedule_threadsafe(
coro.close()
log.log(log_level, "%s: %s", log_message, exc)
return None
def consume_detached_task_result(task: "asyncio.Future[Any]") -> None:
"""Retrieve a detached task's result without surfacing cancellation.
Used as an ``add_done_callback`` on tasks that were cancelled and
detached (e.g. an adapter close path that swallows ``CancelledError``
past its teardown deadline). Observing ``task.exception()`` prevents
"exception was never retrieved" noise on the event loop; cancellation
and any terminal error are deliberately swallowed the task's owner
already gave up on it.
"""
try:
task.exception()
except (asyncio.CancelledError, Exception):
pass

View file

@ -6253,6 +6253,13 @@ def _resolve_task_provider_model(
cfg_model = str(task_config.get("model", "")).strip() or None
cfg_base_url = str(task_config.get("base_url", "")).strip() or None
cfg_api_key = str(task_config.get("api_key", "")).strip() or None
# Resolve key_env → env var when api_key is not set directly
if not cfg_api_key:
cfg_key_env = str(
task_config.get("key_env") or task_config.get("api_key_env") or ""
).strip()
if cfg_key_env:
cfg_api_key = os.getenv(cfg_key_env, "").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

131
agent/battery.py Normal file
View file

@ -0,0 +1,131 @@
"""System-battery read-out for the CLI/TUI status bar.
Reads the host battery through ``psutil`` (already a Hermes dependency) and
exposes a compact, colour-coded label. Everything degrades to "unavailable"
when there is no battery (desktops, servers, VMs) or when the read fails, so
callers can render the result unconditionally and simply show nothing.
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
:func:`read_battery` memoises the last reading for a few seconds instead of
hitting ``psutil`` on every frame.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class BatteryStatus:
"""A single battery reading.
``available`` is False on machines without a battery (or when the read
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
power, False on battery, and None when the platform can't tell.
"""
available: bool
percent: Optional[int] = None
plugged: Optional[bool] = None
@property
def charging(self) -> bool:
return bool(self.plugged)
UNAVAILABLE = BatteryStatus(available=False)
# Colour buckets, mirroring the status-bar context styles but inverted (a full
# battery is "good", an empty one is "critical").
CATEGORY_GOOD = "good"
CATEGORY_WARN = "warn"
CATEGORY_BAD = "bad"
CATEGORY_CRITICAL = "critical"
CATEGORY_DIM = "dim"
_CACHE_TTL_SECONDS = 8.0
_cache: Optional[tuple[float, BatteryStatus]] = None
def _read_battery_uncached() -> BatteryStatus:
try:
import psutil
except Exception:
return UNAVAILABLE
# ``sensors_battery`` is missing on some platforms/builds of psutil.
reader = getattr(psutil, "sensors_battery", None)
if reader is None:
return UNAVAILABLE
try:
batt = reader()
except Exception:
return UNAVAILABLE
if batt is None:
return UNAVAILABLE
percent: Optional[int] = None
raw_percent = getattr(batt, "percent", None)
if raw_percent is not None:
try:
percent = max(0, min(100, int(round(float(raw_percent)))))
except (TypeError, ValueError):
percent = None
plugged = getattr(batt, "power_plugged", None)
if plugged is not None:
plugged = bool(plugged)
return BatteryStatus(available=True, percent=percent, plugged=plugged)
def read_battery(use_cache: bool = True) -> BatteryStatus:
"""Return the current battery status (cached for a few seconds)."""
global _cache
if use_cache and _cache is not None:
ts, cached = _cache
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
return cached
status = _read_battery_uncached()
_cache = (time.monotonic(), status)
return status
def clear_cache() -> None:
"""Drop the memoised reading (used by tests)."""
global _cache
_cache = None
def battery_category(status: BatteryStatus) -> str:
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
if not status.available or status.percent is None:
return CATEGORY_DIM
# On AC power the level isn't a concern — always read as healthy.
if status.charging:
return CATEGORY_GOOD
pct = status.percent
if pct <= 10:
return CATEGORY_CRITICAL
if pct <= 20:
return CATEGORY_BAD
if pct <= 50:
return CATEGORY_WARN
return CATEGORY_GOOD
def battery_glyph(status: BatteryStatus) -> str:
"""Return the leading glyph: a bolt while charging, else a battery."""
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
def format_battery(status: BatteryStatus) -> str:
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
if not status.available or status.percent is None:
return ""
return f"{battery_glyph(status)} {status.percent}%"

View file

@ -448,7 +448,10 @@ def is_anthropic_bedrock_model(model_id: str) -> bool:
"""
model_lower = model_id.lower()
# Strip regional prefix if present
for prefix in ("us.", "global.", "eu.", "ap.", "jp."):
for prefix in (
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.",
"ca.", "sa.", "me.", "af.",
):
if model_lower.startswith(prefix):
model_lower = model_lower[len(prefix):]
break
@ -490,6 +493,26 @@ def convert_tools_to_converse(tools: List[Dict]) -> List[Dict]:
return result
# Bedrock's Converse API rejects any text content block whose text is empty
# OR whitespace-only (ValidationException: "text content blocks must contain
# non-whitespace text"). A lone space is whitespace and is rejected too — the
# placeholder MUST itself be non-whitespace. Ref: issue #9486.
_EMPTY_TEXT_PLACEHOLDER = "(empty)"
def _safe_text(text) -> str:
"""Return ``text`` if it's non-whitespace, else a non-whitespace placeholder.
Handles None, empty string, and whitespace-only string (spaces, tabs,
newlines) all of which Bedrock's Converse API rejects as text content.
"""
if text is None:
return _EMPTY_TEXT_PLACEHOLDER
if not isinstance(text, str):
text = str(text)
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
def _convert_content_to_converse(content) -> List[Dict]:
"""Convert OpenAI message content (string or list) to Converse content blocks.
@ -497,26 +520,27 @@ def _convert_content_to_converse(content) -> List[Dict]:
- Plain text strings [{"text": "..."}]
- Content arrays with text/image_url parts mixed text/image blocks
Filters out empty text blocks Bedrock's Converse API rejects messages
where a text content block has an empty ``text`` field (ValidationException:
"text content blocks must be non-empty"). Ref: issue #9486.
Replaces empty/whitespace-only text blocks with a non-whitespace
placeholder Bedrock's Converse API rejects messages where a text
content block is empty or whitespace-only (ValidationException:
"text content blocks must contain non-whitespace text"). Ref: issue #9486.
"""
if content is None:
return [{"text": " "}]
return [{"text": _safe_text(content)}]
if isinstance(content, str):
return [{"text": content}] if content.strip() else [{"text": " "}]
return [{"text": _safe_text(content)}]
if isinstance(content, list):
blocks = []
for part in content:
if isinstance(part, str):
blocks.append({"text": part})
blocks.append({"text": _safe_text(part)})
continue
if not isinstance(part, dict):
continue
part_type = part.get("type", "")
if part_type == "text":
text = part.get("text", "")
blocks.append({"text": text if text else " "})
blocks.append({"text": _safe_text(text)})
elif part_type == "image_url":
image_url = part.get("image_url", {})
url = image_url.get("url", "") if isinstance(image_url, dict) else ""
@ -547,8 +571,8 @@ def _convert_content_to_converse(content) -> List[Dict]:
# Remote URL — Converse doesn't support URLs directly,
# include as text reference for the model.
blocks.append({"text": f"[Image: {url}]"})
return blocks if blocks else [{"text": " "}]
return [{"text": str(content)}]
return blocks if blocks else [{"text": _EMPTY_TEXT_PLACEHOLDER}]
return [{"text": _safe_text(content)}]
def convert_messages_to_converse(
@ -578,14 +602,18 @@ def convert_messages_to_converse(
content = msg.get("content")
if role == "system":
# System messages become the system prompt
# System messages become the system prompt. Blank/whitespace-only
# parts are dropped entirely (not placeholder-filled) since a
# system prompt made up of only placeholder text is meaningless.
if isinstance(content, str) and content.strip():
system_blocks.append({"text": content})
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
system_blocks.append({"text": part.get("text", "")})
elif isinstance(part, str):
text = part.get("text", "")
if isinstance(text, str) and text.strip():
system_blocks.append({"text": text})
elif isinstance(part, str) and part.strip():
system_blocks.append({"text": part})
continue
@ -596,7 +624,7 @@ def convert_messages_to_converse(
tool_result_block = {
"toolResult": {
"toolUseId": tool_call_id,
"content": [{"text": result_content}],
"content": [{"text": _safe_text(result_content)}],
}
}
# In Converse, tool results go in a "user" role message
@ -635,7 +663,7 @@ def convert_messages_to_converse(
})
if not content_blocks:
content_blocks = [{"text": " "}]
content_blocks = [{"text": _EMPTY_TEXT_PLACEHOLDER}]
# Merge with previous assistant message if needed (strict alternation)
if converse_msgs and converse_msgs[-1]["role"] == "assistant":
@ -661,11 +689,11 @@ def convert_messages_to_converse(
# Converse requires the first message to be from the user
if converse_msgs and converse_msgs[0]["role"] != "user":
converse_msgs.insert(0, {"role": "user", "content": [{"text": " "}]})
converse_msgs.insert(0, {"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]})
# Converse requires the last message to be from the user
if converse_msgs and converse_msgs[-1]["role"] != "user":
converse_msgs.append({"role": "user", "content": [{"text": " "}]})
converse_msgs.append({"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]})
return (system_blocks if system_blocks else None, converse_msgs)
@ -789,6 +817,7 @@ def stream_converse_with_callbacks(
on_tool_start=None,
on_reasoning_delta=None,
on_interrupt_check=None,
on_event=None,
) -> SimpleNamespace:
"""Process a Bedrock ConverseStream event stream with real-time callbacks.
@ -808,6 +837,12 @@ def stream_converse_with_callbacks(
on supported models (Claude 4.6+).
on_interrupt_check: Called on each event. Should return True if the
agent has been interrupted and streaming should stop.
on_event: Called once at the top of the loop body for EVERY yielded
Bedrock event (text/tool-input/reasoning/metadata deltas alike),
before any branching. Provides a wire-level liveness signal so an
external watchdog can distinguish "still receiving events" from
"stream wedged with no data". Errors raised by the callback are
swallowed so a liveness hook can never abort the stream.
Returns:
An OpenAI-compatible SimpleNamespace response, identical in shape to
@ -823,6 +858,15 @@ def stream_converse_with_callbacks(
usage_data: Dict[str, int] = {}
for event in event_stream.get("stream", []):
# Wire-level liveness signal: fire on EVERY yielded event (text, tool
# input, reasoning, metadata) before branching so an external watchdog
# can tell a still-flowing stream from a wedged one. Best-effort — a
# liveness callback must never be able to abort the stream.
if on_event is not None:
try:
on_event()
except Exception:
pass
# Check for interrupt
if on_interrupt_check and on_interrupt_check():
break
@ -1305,9 +1349,24 @@ def classify_bedrock_error(error_message: str) -> str:
# detection is unavailable.
BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
# Anthropic Claude models on Bedrock
"anthropic.claude-opus-4-6": 200_000,
"anthropic.claude-sonnet-4-6": 200_000,
# Anthropic Claude models on Bedrock.
# Context windows per Anthropic's official models comparison
# (https://platform.claude.com/docs/en/about-claude/models/overview).
# Fable / Sonnet 5 / Opus 4.8 / 4.7 / 4.6 / Sonnet 4.6 have 1M generally
# available (no beta header required as of April 2026). Sonnet 4.5 and
# Sonnet 4 had their `context-1m-2025-08-07` beta retired on
# April 30, 2026, so they are standard 200K; Haiku 4.5 is 200K.
# These 1M entries must match agent/model_metadata.py
# DEFAULT_CONTEXT_LENGTHS or the agent compresses context prematurely.
# Keys are matched by longest-substring, so the versioned 4-6/4-7/4-8
# entries win over the generic "anthropic.claude-opus-4" fallback.
"anthropic.claude-fable-5": 1_000_000,
"anthropic.claude-fable": 1_000_000,
"anthropic.claude-sonnet-5": 1_000_000,
"anthropic.claude-opus-4-8": 1_000_000,
"anthropic.claude-opus-4-7": 1_000_000,
"anthropic.claude-opus-4-6": 1_000_000,
"anthropic.claude-sonnet-4-6": 1_000_000,
"anthropic.claude-sonnet-4-5": 200_000,
"anthropic.claude-haiku-4-5": 200_000,
"anthropic.claude-opus-4": 200_000,
@ -1334,9 +1393,22 @@ BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
# Default for unknown Bedrock models
BEDROCK_DEFAULT_CONTEXT_LENGTH = 128_000
# Probe tiers (in tokens). We send a request padded just past each tier and
# read the real window from Bedrock's length-validation error. Two reasons
# this is tiered rather than one giant request:
# 1. A wildly oversized payload (e.g. 5M tokens) makes Bedrock return an
# opaque InternalServerException after retries instead of a clean
# ValidationException — so we must stay within a sane overage.
# 2. Stepping up lets us discover larger windows (2M+) without over-padding
# smaller ones.
# Each tier value is the *padding target*; the error reports the true maximum,
# which is what we actually return.
_BEDROCK_PROBE_TIERS = (1_300_000, 2_200_000)
_WORDS_PER_TOKEN = 0.9 # conservative: ensures the padded prompt clears the tier
def get_bedrock_context_length(model_id: str) -> int:
"""Look up the context window size for a Bedrock model.
def _static_bedrock_context_length(model_id: str) -> int:
"""Longest-substring-match lookup against the static fallback table.
Uses substring matching so versioned IDs like
``anthropic.claude-sonnet-4-6-20250514-v1:0`` resolve correctly.
@ -1349,3 +1421,103 @@ def get_bedrock_context_length(model_id: str) -> int:
best_key = key
best_val = val
return best_val
def probe_bedrock_context_length(model_id: str, region: str) -> Optional[int]:
"""Discover a Bedrock model's real context window by provoking a length error.
Bedrock does not expose the context window via any metadata API
(``get-foundation-model`` omits it, ``Converse`` metrics omit it,
``CountTokens`` is unsupported on several models). The only authoritative
source is the ``ValidationException`` raised when a prompt exceeds the
window:
"The model returned the following errors: prompt is too long:
1300032 tokens > 1000000 maximum"
Length validation happens *before* inference, so an oversized request is
rejected immediately and cheaply no tokens are generated and no input is
actually processed. We pad a request just past each tier in
``_BEDROCK_PROBE_TIERS`` and parse the reported ``maximum``. Tiers exist
because (a) a *wildly* oversized payload makes Bedrock fail with an opaque
InternalServerException instead of a clean length error, and (b) stepping
up discovers larger windows without over-padding smaller ones.
Returns the detected window, or ``None`` if the probe could not run
(missing credentials, network error, or no parseable limit) so the caller
can fall back to the static table.
"""
try:
from agent.model_metadata import parse_context_limit_from_error
except ImportError: # pragma: no cover — same package
return None
try:
client = _get_bedrock_runtime_client(region)
except Exception as exc: # boto3 missing / credential resolution failure
logger.debug("Bedrock context probe skipped for %s: %s", model_id, exc)
return None
last_error = ""
for tier_tokens in _BEDROCK_PROBE_TIERS:
pad_words = int(tier_tokens / _WORDS_PER_TOKEN)
oversized = "data " * pad_words
try:
client.converse(
modelId=model_id,
messages=[{"role": "user", "content": [{"text": oversized}]}],
inferenceConfig={"maxTokens": 8},
)
# Accepted a prompt this large → the window is at least this tier.
# Returning the tier as a lower bound is safe and avoids inventing
# a number we can't confirm.
logger.debug(
"Bedrock context probe for %s accepted ~%s-token prompt; "
"window is at least that", model_id, f"{tier_tokens:,}",
)
return tier_tokens
except Exception as exc:
msg = str(exc)
last_error = msg
limit = parse_context_limit_from_error(msg)
if limit and limit >= 1024:
logger.info(
"Probed Bedrock context window for %s: %s tokens",
model_id, f"{limit:,}",
)
return limit
# No parseable limit at this tier (opaque server error, auth,
# throttle). Try the next, smaller-overage strategy is N/A here —
# tiers ascend — so just continue; if all fail we return None.
continue
logger.debug(
"Bedrock context probe for %s returned no parseable limit: %s",
model_id, last_error[:200],
)
return None
def get_bedrock_context_length(model_id: str, region: str = "", probe: bool = True) -> int:
"""Resolve the context window for a Bedrock model.
Resolution order:
1. Live probe against Bedrock (authoritative; cached by the caller).
2. Static fallback table (longest-substring match).
3. Conservative default.
The static table is intentionally a *fallback*, not the primary source:
AWS ships new model versions (opus-4-7, opus-4-8, ...) faster than the
table can track, and a stale entry silently caps the window (e.g. a
1M-token Opus pinned to 200K via an ``opus-4`` substring match). The
probe asks Bedrock directly so every model current or future gets its
real window with no table maintenance.
``probe=False`` (or an empty ``region``) skips the network call and uses
the static table only used by pure-offline/display code paths.
"""
if probe and region:
probed = probe_bedrock_context_length(model_id, region)
if probed:
return probed
return _static_bedrock_context_length(model_id)

124
agent/billing_links.py Normal file
View file

@ -0,0 +1,124 @@
"""Provider-agnostic billing/credit recovery links.
Maps a billing-classified failure onto a recovery link + label. *Detection*
is not done here that is :mod:`agent.error_classifier`
(``FailoverReason.billing``), the single source of truth for "credit wall vs.
rate limit / auth / transport". The resulting :class:`BillingBlock` rides the
turn result and the gateway ``message.complete`` event so every surface (CLI,
TUI, desktop) renders one structured signal instead of re-parsing error text.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Optional
from utils import base_url_host_matches
@dataclass
class BillingBlock:
"""Structured billing-wall descriptor shared across every surface.
``is_nous`` is the routing bit: Nous has a first-class in-app billing surface
(desktop Settings Billing, TUI/CLI ``/topup``), so surfaces prefer that over
``billing_url``; third-party providers have no in-app flow, so ``billing_url``
is the deep link the user actually needs.
"""
provider: str
provider_label: str
model: str
billing_url: Optional[str]
is_nous: bool
message: str
def to_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class _Provider:
label: str
url: str
slugs: tuple[str, ...]
hosts: tuple[str, ...] = ()
# Single source of truth: internal slug(s) + base_url host(s) → billing page.
# Curated "add credits / manage billing" landing pages, not marketing homes.
# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket
# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown
# provider degrades to a readable label with no invented URL.
_PROVIDERS: tuple[_Provider, ...] = (
_Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)),
_Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)),
_Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)),
_Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)),
_Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)),
_Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)),
_Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)),
_Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")),
_Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)),
_Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)),
_Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)),
_Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)),
_Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)),
_Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)),
)
_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs}
def is_nous_inference_route(provider: str, base_url: str) -> bool:
"""True when the failing route is the Nous-managed inference gateway."""
if (provider or "").strip().lower() == "nous":
return True
return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com")
def _nous_billing_url() -> Optional[str]:
"""Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow)."""
try:
from hermes_cli.nous_account import nous_portal_billing_url
return nous_portal_billing_url(None)
except Exception:
return "https://portal.nousresearch.com/billing"
def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]:
"""Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback."""
hit = _BY_SLUG.get(slug)
if hit:
return hit.label, hit.url
base = str(base_url or "")
for p in _PROVIDERS:
if any(base_url_host_matches(base, host) for host in p.hosts):
return p.label, p.url
return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None
def build_billing_block(
*,
provider: str,
base_url: str,
model: str,
message: str = "",
) -> BillingBlock:
"""Build the billing descriptor for a billing-classified failure.
``message`` is the guidance already assembled by the agent loop
(:func:`agent.conversation_loop._billing_or_entitlement_message`), carried
through unchanged so every surface shows identical copy.
"""
slug = (provider or "").strip().lower()
model = (model or "").strip()
if is_nous_inference_route(slug, base_url):
return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "")
label, url = _resolve_provider_link(slug, base_url)
return BillingBlock(slug, label, model, url, False, message or "")

323
agent/billing_usage.py Normal file
View file

@ -0,0 +1,323 @@
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
The single source of truth behind the ``/usage`` and ``/subscription`` usage
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
**dollars**, never "credits", and every usage bar must make the monthly
subscription allowance and separately-purchased top-up dollars distinctly
visible.
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
``paid_service_access_info`` carries the three dollar magnitudes we render
(despite the legacy ``*_credits`` field names, these are USD floats):
- ``subscription_credits_remaining`` -> plan dollars left this month
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
- ``total_usable_credits`` -> total spendable
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
Design: two SEPARATE bars (decided with the user) rather than one crammed
three-segment bar at terminal widths three same-glyph density segments are
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
the top-up bar is "money you bought, doesn't expire". Each gets full
resolution and a single fill glyph, so the bar is never ambiguous and never
relies on color.
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
magnitudes-only view; a logged-out / unreachable portal yields
``available=False`` and the surface shows nothing.
"""
from __future__ import annotations
import logging
import math
import os
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
# (user feedback): "any amount below $5 should be an alert status."
LOW_BALANCE_THRESHOLD_USD = 5.0
def _finite(value: Any) -> Optional[float]:
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
f = float(value)
return f if math.isfinite(f) else None
def _fmt_usd(value: Optional[float]) -> str:
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
return f"${(value or 0.0):,.2f}"
def format_renews(value: Optional[str]) -> Optional[str]:
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
string unchanged if it can't be parsed (never raises), and ``None`` for
empty input.
"""
if not value:
return None
from datetime import datetime
text = str(value).strip()
if not text:
return None
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
try:
dt = datetime.fromisoformat(iso)
except ValueError:
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
try:
dt = datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return text
# %-d isn't portable to Windows; build the day without a leading zero.
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
@dataclass(frozen=True)
class UsageBar:
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
(purchased dollars, no denominator ``spent`` is 0 and ``total`` ==
``remaining`` so it renders as a full bar of available balance).
"""
kind: str # "plan" | "topup"
remaining_usd: float
total_usd: float
spent_usd: float = 0.0
@property
def pct_used(self) -> Optional[int]:
if self.kind != "plan" or self.total_usd <= 0:
return None
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
@property
def fill_fraction(self) -> float:
"""Fraction of the bar that should read as 'remaining' (filled)."""
if self.total_usd <= 0:
return 0.0
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
@dataclass(frozen=True)
class UsageModel:
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
``status`` classifies the account for copy selection:
- ``"free"`` : no paid access / no subscription (free models only)
- ``"low"`` : paid, but total spendable < $5 (ALERT)
- ``"healthy"`` : paid, total spendable >= $5
- ``"depleted"`` : paid access lost (balance exhausted)
"""
available: bool
status: str = "free"
plan_name: Optional[str] = None
renews_at: Optional[str] = None
renews_display: Optional[str] = None
subscription_remaining_usd: Optional[float] = None
topup_remaining_usd: Optional[float] = None
total_spendable_usd: Optional[float] = None
plan_bar: Optional[UsageBar] = None
topup_bar: Optional[UsageBar] = None
@property
def has_topup(self) -> bool:
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
def usage_model_from_account(account_info: Any) -> UsageModel:
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
Returns ``UsageModel(available=False)`` when there's no usable account info
(logged out, no entitlement block). Never raises.
"""
try:
if account_info is None or not getattr(account_info, "logged_in", False):
return UsageModel(available=False)
access = getattr(account_info, "paid_service_access_info", None)
sub = getattr(account_info, "subscription", None)
paid = getattr(account_info, "paid_service_access", None)
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
plan_name = getattr(sub, "plan", None) if sub is not None else None
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
# Total spendable: prefer the server's total; else sum the parts we have.
if total_usable is not None:
total_spendable = total_usable
else:
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
total_spendable = sum(parts) if parts else None
# Status classification.
if paid is False:
status = "depleted"
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
# No plan and no purchased balance -> free-models-only.
status = "free"
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
status = "low"
else:
status = "healthy"
# Plan bar — only with a positive monthly allowance AND a remaining we
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
# balance reads as fully spent rather than a nonsensical negative).
plan_bar: Optional[UsageBar] = None
if monthly is not None and monthly > 0 and sub_remaining is not None:
remaining = max(0.0, min(monthly, sub_remaining))
plan_bar = UsageBar(
kind="plan",
remaining_usd=remaining,
total_usd=monthly,
spent_usd=max(0.0, monthly - sub_remaining),
)
# Top-up bar — only when there are purchased dollars to show. No
# denominator (top-up has no monthly cap), so it renders full = balance.
topup_bar: Optional[UsageBar] = None
if topup_remaining is not None and topup_remaining > 0:
topup_bar = UsageBar(
kind="topup",
remaining_usd=topup_remaining,
total_usd=topup_remaining,
spent_usd=0.0,
)
return UsageModel(
available=True,
status=status,
plan_name=plan_name,
renews_at=renews_at,
renews_display=format_renews(renews_at),
subscription_remaining_usd=sub_remaining,
topup_remaining_usd=topup_remaining,
total_spendable_usd=total_spendable,
plan_bar=plan_bar,
topup_bar=topup_bar,
)
except Exception:
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
return UsageModel(available=False)
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
"""Fetch account-info and build the shared usage model. Fail-open.
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
every usage state is testable without a live account (mirrors the existing
``/usage`` credits-block fixture path).
"""
fixture = _dev_fixture_usage_model()
if fixture is not None:
return fixture
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return UsageModel(available=False)
except Exception:
return UsageModel(available=False)
try:
import concurrent.futures
from hermes_cli.nous_account import get_nous_portal_account_info
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
return usage_model_from_account(account)
except Exception:
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
return UsageModel(available=False)
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_usage_model() -> Optional[UsageModel]:
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
Recognized names: ``free | healthy | low | topup | depleted``. Returns
``None`` when the env var is unset (real portal path runs).
"""
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
if not name:
return None
if name == "free":
return UsageModel(available=True, status="free", plan_name=None)
if name in ("healthy", "mid"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
total_spendable_usd=14.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
)
if name in ("topup", "top-up"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
topup_remaining_usd=12.0,
total_spendable_usd=26.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
)
if name == "low":
return UsageModel(
available=True,
status="low",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=3.4,
total_spendable_usd=3.4,
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
)
if name == "depleted":
return UsageModel(
available=True,
status="depleted",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=0.0,
total_spendable_usd=0.0,
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
)
return None

View file

@ -1,4 +1,4 @@
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods
@ -15,6 +15,7 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
from __future__ import annotations
import logging
import os
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
@ -64,15 +65,47 @@ def format_money(value: Optional[Decimal]) -> str:
# =============================================================================
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
# so the display degrades cleanly on servers that don't send resolvedVia yet.
_CARD_PROVENANCE_LABELS = {
"subPin": "the card on your subscription",
"customerDefault": "your default card saved on the portal",
"autoRefill": "your auto-reload card",
}
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
# NAS card-on-file field (post card-resolver): which ladder rung found the
# card. Defaults off so pre-resolver payloads parse unchanged.
resolved_via: Optional[str] = None
@property
def masked(self) -> str:
# A Link payment method has no card number (last4 = "") — render the
# brand alone, not "Link ····".
if not self.last4:
return self.brand
return f"{self.brand} ····{self.last4}"
@property
def provenance(self) -> Optional[str]:
"""Human label for why this card was picked, or None (unknown rung /
server too old to say)."""
if self.resolved_via is None:
return None
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
@property
def display(self) -> str:
"""The one-line card display: ``Visa ····4242 — the card on your
subscription`` (or just the masked card when provenance is unknown)."""
label = self.provenance
return f"{self.masked}{label}" if label else self.masked
@dataclass(frozen=True)
class MonthlyCap:
@ -81,11 +114,20 @@ class MonthlyCap:
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReloadCard:
kind: str # "canonical" | "distinct" | "none"
payment_method_id: Optional[str] = None
brand: Optional[str] = None
last4: Optional[str] = None
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
card: Optional[AutoReloadCard] = None
@dataclass(frozen=True)
@ -100,7 +142,8 @@ class BillingState:
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
@ -115,17 +158,33 @@ class BillingState:
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can manage billing."""
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_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.)
Uses the server-granted plan-change capability (``can_change_plan``,
which itself falls back to the legacy OWNER/ADMIN role check when the
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
deprecated 3-role admin check. (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
return self.can_change_plan and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
@ -133,9 +192,13 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
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
if not (isinstance(brand, str) and isinstance(last4, str)):
return None
# Post-resolver fields — all optional so both payload generations parse.
resolved_via = raw.get("resolvedVia")
if not isinstance(resolved_via, str):
resolved_via = None
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
@ -155,6 +218,27 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
card=_parse_auto_reload_card(raw.get("card")),
)
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if kind not in ("canonical", "distinct", "none"):
return None
if kind in ("canonical", "none"):
return AutoReloadCard(kind=kind)
payment_method_id = raw.get("paymentMethodId")
brand = raw.get("brand")
last4 = raw.get("last4")
return AutoReloadCard(
kind=kind,
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
brand=brand if isinstance(brand, str) else None,
last4=last4 if isinstance(last4, str) else None,
)
@ -179,6 +263,11 @@ def billing_state_from_payload(
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
@ -202,7 +291,15 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
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.
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
card-on-file / admin / scope states are testable offline (mirrors
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
"""
fixture = _dev_fixture_billing_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
@ -243,6 +340,72 @@ def _fallback_portal_url(base: str) -> str:
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_billing_state() -> Optional[BillingState]:
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
Recognized names::
nocard logged in · billing on · admin · NO card on file
card card on file · auto-reload off
card-autoreload card on file · auto-reload on
notadmin logged in · MEMBER role (billing actions disabled)
billing-off logged in · admin · per-org kill-switch OFF
logged-out not logged in
Returns ``None`` when the env var is unset (the real portal path runs).
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
"""
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
if not name:
return None
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
portal = "https://portal.nousresearch.com/billing?topup=open"
common: dict[str, Any] = dict(
org_id="org_acme",
org_slug="acme",
org_name="Acme Inc",
role="OWNER",
balance_usd=Decimal("3.40"),
cli_billing_enabled=True,
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
min_usd=Decimal("5"),
max_usd=Decimal("500"),
portal_url=portal,
)
card = CardInfo(brand="Visa", last4="4242")
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
if name in ("logged-out", "logged_out", "loggedout"):
return BillingState(logged_in=False)
if name == "nocard":
return BillingState(logged_in=True, card=None, **common)
if name == "card":
return BillingState(logged_in=True, card=card, **common)
if name in ("card-sub", "card_sub"):
# Post-resolver: the card came from the subscription (provenance label).
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
return BillingState(logged_in=True, card=_sub_card, **common)
if name in ("card-autoreload", "card_autoreload", "autoreload"):
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
if name in ("notadmin", "not-admin", "member"):
opts = {**common, "role": "MEMBER"}
return BillingState(logged_in=True, card=card, **opts)
if name in ("billing-off", "billing_off", "off"):
opts = {**common, "cli_billing_enabled": False}
return BillingState(logged_in=True, card=None, **opts)
# Unknown name → logged-out so the misconfiguration is visible.
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
# =============================================================================
# Idempotency
# =============================================================================

View file

@ -30,8 +30,10 @@ from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
from agent.errors import EmptyStreamError
from agent.turn_context import substitute_api_content
from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
from agent.message_content import flatten_message_text
from agent.message_sanitization import (
_sanitize_surrogates,
_repair_tool_call_arguments,
@ -264,6 +266,109 @@ def _check_stale_giveup(agent) -> None:
)
def _derive_stream_stale_timeout(agent, api_kwargs: dict) -> float:
"""Stale-stream patience for a provider that is never a local endpoint.
Mirrors the main streaming path's derivation — provider config → env base
context-size scaling reasoning-model floor minus the local-endpoint
``float('inf')``/900s disable branch, which cannot apply to Bedrock (its
endpoint is always the AWS cloud). Factored so the Bedrock streaming
watchdog shares the exact same patience budget as the OpenAI/Anthropic
stale-stream detector below.
"""
_cfg_stale = get_provider_stale_timeout(agent.provider, agent.model)
if _cfg_stale is not None:
_base = _cfg_stale
else:
_base = env_float("HERMES_STREAM_STALE_TIMEOUT", 180.0)
_est_tokens = estimate_request_context_tokens(api_kwargs)
if _est_tokens > 100_000:
_timeout = max(_base, 300.0)
elif _est_tokens > 50_000:
_timeout = max(_base, 240.0)
else:
_timeout = _base
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
# Resolve the model id from BOTH the OpenAI/Anthropic key (``model``) and
# the Bedrock key (``modelId``). OpenAI/Anthropic wins first via the ``or``
# chain, so those paths are unchanged. Bedrock carries the model as a
# dotted, region-prefixed inference-profile id (e.g.
# ``us.anthropic.claude-opus-4-6-v1:0``) that the floor's start-of-slug
# regex cannot match directly — normalize it to a canonical slug first.
_model_id = api_kwargs.get("model") or api_kwargs.get("modelId") or ""
_reasoning_floor = get_reasoning_stale_timeout_floor(_model_id)
if _reasoning_floor is None and api_kwargs.get("modelId"):
_reasoning_floor = _bedrock_reasoning_stale_floor(api_kwargs["modelId"])
if _reasoning_floor is not None:
_timeout = max(_timeout, _reasoning_floor)
return _timeout
def _bedrock_reasoning_stale_floor(model_id: object) -> "float | None":
"""Map a Bedrock inference-profile id to its reasoning stale-timeout floor.
Bedrock carries the model as a dotted, region-prefixed id such as
``us.anthropic.claude-opus-4-6-v1:0``, whereas
:func:`get_reasoning_stale_timeout_floor` anchors its slug patterns at the
start of a bare slug (``claude-opus-4``). Strip the region prefix
(``us.``/``eu.``/``apac.``/...) and try two candidate slugs against the
floor:
* the segment after the provider namespace (``claude-opus-4-6-v1:0``)
matches Anthropic-style slugs whose floor key excludes the provider
(``claude-opus-4``); and
* the region-stripped id with the provider dot rewritten to a dash
(``deepseek-r1-v1:0``) matches provider-qualified floor keys
(``deepseek-r1``).
The floor's right-anchor (``$`` or ``-``/``.``/``_``) tolerates the
trailing date-stamp / ``-v1:0`` version suffix, so no suffix stripping is
needed. First non-None wins; returns None for unknown models.
The floor table mixes version-separator conventions: some keys are
keyed with a dashed version (``claude-opus-4``) while others embed a
dotted version (``claude-sonnet-4.5``, ``claude-sonnet-4.6``). Bedrock
always dashes the version (``claude-sonnet-4-5-v1:0``), so for every
candidate slug we also try the alternate version-separator form
digit-dash-digit rewritten to digit-dot-digit and vice-versa so a
dashed Bedrock id matches a dotted floor key (and the reverse). The
rewrite only touches version-number separators (a dash/dot flanked by
digits), never other dashes in the slug, so ``claude-sonnet`` is left
intact while ``4-5`` becomes ``4.5``.
"""
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
if not model_id or not isinstance(model_id, str):
return None
name = model_id.strip().lower()
for prefix in (
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.",
"ca.", "sa.", "me.", "af.",
):
if name.startswith(prefix):
name = name[len(prefix):]
break
base_candidates = [name]
if "." in name:
base_candidates.append(name.rsplit(".", 1)[1]) # claude-opus-4-6-v1:0
base_candidates.append(name.replace(".", "-", 1)) # deepseek-r1-v1:0
candidates: list[str] = []
for cand in base_candidates:
# Try the slug as-is plus both alternate version-separator forms.
# ``4-5`` <-> ``4.5`` only; a dash/dot not flanked by digits is
# left alone (e.g. ``claude-sonnet`` stays dashed).
dashed_to_dotted = re.sub(r"(?<=\d)-(?=\d)", ".", cand)
dotted_to_dashed = re.sub(r"(?<=\d)\.(?=\d)", "-", cand)
for form in (cand, dashed_to_dotted, dotted_to_dashed):
if form not in candidates:
candidates.append(form)
for cand in candidates:
floor = get_reasoning_stale_timeout_floor(cand)
if floor is not None:
return floor
return None
def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
"""Run one non-streaming LLM request for the active api_mode and return it.
@ -271,13 +376,14 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
inline path (``direct_api_call``) so the per-api_mode dispatch codex /
anthropic / bedrock / MoA / OpenAI-compatible lives in exactly one place.
``make_client(reason)`` builds the per-request OpenAI client for the codex
and OpenAI-compatible branches; the worker path uses it to register the
client with its stranger-thread abort machinery, the inline path uses it to
capture the client for its own ``finally`` close. The anthropic / bedrock /
MoA branches manage their own clients and never call it. All interrupt,
abort, cancellation, and close semantics stay in the callers this helper
only issues the request.
``make_client(reason, kind=...)`` builds the per-request client for the
codex / OpenAI-compatible (``kind="openai"``) and anthropic
(``kind="anthropic_messages"``) branches; the worker path uses it to
register the client with its stranger-thread abort machinery, the inline
path uses it to capture the client for its own ``finally`` close. The
bedrock / MoA branches manage their own clients and never call it. All
interrupt, abort, cancellation, and close semantics stay in the callers
this helper only issues the request.
"""
if agent.api_mode == "codex_responses":
request_client = make_client("codex_stream_request")
@ -287,7 +393,13 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
)
if agent.api_mode == "anthropic_messages":
return agent._anthropic_messages_create(api_kwargs)
# #67142: use a request-local Anthropic client so the stale/interrupt
# watchdog aborts sockets from the stranger thread while the worker
# owns the SDK close — never closing the shared client mid-flight.
request_client = make_client(
"anthropic_messages_request", kind="anthropic_messages"
)
return agent._anthropic_messages_create(api_kwargs, client=request_client)
if agent.api_mode == "bedrock_converse":
# Bedrock uses boto3 directly — no OpenAI client needed.
# normalize_converse_response produces an OpenAI-compatible
@ -357,7 +469,11 @@ def direct_api_call(agent, api_kwargs: dict):
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)
def _make_client(reason: str):
def _make_client(reason: str, kind: str = "openai"):
# direct_api_call only runs for OpenAI-wire chat_completions cron
# requests (see should_use_direct_api_call), so the anthropic branch of
# the dispatch — the only caller that passes kind — is never reached
# here; the ``kind`` parameter exists purely for signature parity.
client = agent._create_request_openai_client(reason=reason, api_kwargs=api_kwargs)
with request_client_lock:
request_client_holder["client"] = client
@ -416,6 +532,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
_check_stale_giveup(agent)
request_client_holder = {"client": None, "owner_tid": None}
# Transport kind of the registered request client ("openai" or
# "anthropic_messages") so _close_request_client_once routes to the right
# abort/close helpers (#67142).
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
# because that flag is cleared at run_conversation() turn boundaries, but
@ -427,9 +547,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
# hang.)
_request_cancelled = {"value": False}
def _set_request_client(client):
def _set_request_client(client, *, kind: str = "openai"):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# #29507: stamp the owning thread so a stranger-thread interrupt
# only shuts the connection down rather than racing the worker
# for FD ownership during ``client.close()``.
@ -463,24 +584,34 @@ def interruptible_api_call(agent, api_kwargs: dict):
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
def _call():
try:
# _set_request_client registers each per-request OpenAI client with
# the stranger-thread abort machinery above; the shared dispatch
# helper builds it via this callback so the interrupt / stale-call
# detectors can force-close the worker's connection.
# _set_request_client registers each per-request client with the
# stranger-thread abort machinery above; the shared dispatch helper
# builds it via this callback (openai- or anthropic-kind) so the
# interrupt / stale-call detectors can force-close the worker's
# connection without touching the shared client (#67142).
result["response"] = _dispatch_nonstreaming_api_request(
agent,
api_kwargs,
make_client=lambda reason: _set_request_client(
agent._create_request_openai_client(
make_client=lambda reason, kind="openai": _set_request_client(
agent._create_request_anthropic_client(reason=reason)
if kind == "anthropic_messages"
else agent._create_request_openai_client(
reason=reason, api_kwargs=api_kwargs
)
),
kind=kind,
),
)
except Exception as e:
@ -792,11 +923,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
f"Aborting call."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stale_call_kill")
# #67142: routes by client kind — anthropic now aborts the
# request-local client's sockets from this poll (stranger)
# thread instead of closing the shared _anthropic_client.
_close_request_client_once("stale_call_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
@ -832,13 +962,12 @@ def interruptible_api_call(agent, api_kwargs: dict):
)
# Force-close the in-flight worker-local HTTP connection to stop
# token generation without poisoning the shared client used to
# seed future retries.
# seed future retries. #67142: for anthropic this aborts the
# request-local client's sockets from this poll (stranger) thread
# rather than closing the shared _anthropic_client, which could
# release a TLS FD mid-SSL-BIO and corrupt an unrelated SQLite DB.
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("interrupt_abort")
_close_request_client_once("interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during API call")
@ -1126,7 +1255,7 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
# reasoning fields are present (some models/providers embed thinking
# directly in the content rather than returning separate API fields).
if not reasoning_text:
content = assistant_message.content or ""
content = flatten_message_text(getattr(assistant_message, "content", None))
think_blocks = re.findall(r'<think>(.*?)</think>', content, flags=re.DOTALL)
if think_blocks:
combined = "\n\n".join(b.strip() for b in think_blocks if b.strip())
@ -1152,7 +1281,7 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
# Sanitize surrogates from API response — some models (e.g. Kimi/GLM via Ollama)
# can return invalid surrogate code points that crash json.dumps() on persist.
_raw_content = assistant_message.content or ""
_raw_content = flatten_message_text(getattr(assistant_message, "content", None))
_san_content = _sanitize_surrogates(_raw_content)
if reasoning_text:
reasoning_text = _sanitize_surrogates(reasoning_text)
@ -1804,6 +1933,15 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
api_msg.pop(schema_foreign, None)
# api_content (the persist-what-you-send sidecar) carries the
# exact bytes every main-loop call sent for this message —
# substitute it before dropping the key (Hermes bookkeeping,
# never a provider field), mirroring the loop's api_messages
# build. Popping without substituting would send CLEAN content
# here, diverging the summary request's prefix at the EARLIEST
# sidecar-carrying message and re-prefilling the whole transcript
# at exactly the moment the context is largest.
substitute_api_content(api_msg)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
if _needs_sanitize:
@ -2024,6 +2162,11 @@ def cleanup_task_resources(agent, task_id: str) -> None:
``terminal.lifetime_seconds`` is exceeded. Non-persistent backends are
torn down per-turn as before to prevent resource leakage (the original
intent of this hook for the Morph backend, see commit fbd3a2fd).
Skips ``cleanup_browser`` in headed mode so the browser window stays
visible between turns. The inactivity reaper in
``browser_tool._cleanup_inactive_browser_sessions`` still handles
idle sessions.
"""
try:
if is_persistent_env(task_id):
@ -2038,12 +2181,55 @@ def cleanup_task_resources(agent, task_id: str) -> None:
if agent.verbose_logging:
logger.warning(f"Failed to cleanup VM for task {task_id}: {e}")
try:
_ra().cleanup_browser(task_id)
headed = False
try:
from tools.browser_tool import _is_headed_mode
headed = _is_headed_mode()
except Exception:
headed = bool(os.environ.get("AGENT_BROWSER_HEADED"))
if headed:
if agent.verbose_logging:
logging.debug(
f"Skipping per-turn cleanup_browser for headed session {task_id}; "
f"idle reaper will handle it."
)
else:
_ra().cleanup_browser(task_id)
except Exception as e:
if agent.verbose_logging:
logger.warning(f"Failed to cleanup browser for task {task_id}: {e}")
def _build_partial_stream_stub(
role, full_content, full_reasoning, model_name, usage_obj, *,
dropped_tool_names=None,
):
"""Build a partial-stream-stub response for mid-stream drop scenarios.
Used when the SSE stream ends without a ``finish_reason`` after
delivering content (text-only drops, tool-call-arg drops). The stub
is tagged ``PARTIAL_STREAM_STUB_ID`` with ``FINISH_REASON_LENGTH`` so
the conversation loop enters its continuation/retry path instead of
silently accepting truncated output as a complete turn (#32086).
"""
mock_message = SimpleNamespace(
role=role,
content=full_content,
tool_calls=None,
reasoning_content=full_reasoning,
)
mock_choice = SimpleNamespace(
index=0,
message=mock_message,
finish_reason=FINISH_REASON_LENGTH,
)
return SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model=model_name,
choices=[mock_choice],
usage=usage_obj,
_dropped_tool_names=dropped_tool_names or None,
)
def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=None):
@ -2091,6 +2277,24 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result = {"response": None, "error": None}
first_delta_fired = {"done": False}
deltas_were_sent = {"yes": False}
# Wire-level liveness for the boto3 converse_stream worker: the worker
# thread blocks inside ``for event in event_stream`` with NO read
# timeout, so a provider that opens the stream then stops yielding
# events wedges the thread forever. on_event stamps this on EVERY
# yielded Bedrock event (text/tool/metadata) — the poll loop below
# trips a watchdog when the gap exceeds the stale timeout.
_bedrock_last_event = {"t": time.time()}
# Region captured for the poll-loop client eviction below. Read
# (not popped) here so the worker's own pop inside _bedrock_call still
# resolves the same value.
_bedrock_region = api_kwargs.get("__bedrock_region__", "us-east-1")
# Same patience budget as the OpenAI/Anthropic stale detector.
_bedrock_stale_timeout = _derive_stream_stale_timeout(agent, api_kwargs)
# Cross-turn stale-stream circuit breaker (#58962): a pre-elevated
# streak from prior wedged turns aborts before we even start — mirrors
# the entry check on the OpenAI/Anthropic path below.
_check_stale_giveup(agent)
def _fire_first():
if not first_delta_fired["done"] and on_first_delta:
@ -2168,6 +2372,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
on_tool_start=_on_tool,
on_reasoning_delta=_on_reasoning if agent.reasoning_callback or agent.stream_delta_callback else None,
on_interrupt_check=lambda: agent._interrupt_requested,
on_event=lambda: _bedrock_last_event.__setitem__("t", time.time()),
)
except Exception as e:
result["error"] = e
@ -2178,6 +2383,56 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
t.join(timeout=0.3)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call")
# Liveness watchdog: no Bedrock event for longer than the stale
# timeout means the stream has wedged (open socket, keep-alives but
# no data, or a silently hung provider). Without this the worker
# blocks in ``for event in event_stream`` indefinitely.
_stale_elapsed = time.time() - _bedrock_last_event["t"]
if _stale_elapsed > _bedrock_stale_timeout:
logger.warning(
"Bedrock stream stale for %.0fs (threshold %.0fs) — no events "
"received. region=%s model=%s. Aborting call.",
_stale_elapsed, _bedrock_stale_timeout,
_bedrock_region, api_kwargs.get("modelId", "unknown"),
)
agent._buffer_status(
f"⚠️ No events from Bedrock for {int(_stale_elapsed)}s "
f"(model: {api_kwargs.get('modelId', 'unknown')}). Aborting..."
)
# Count the stale kill in the SAME cross-turn breaker as the
# OpenAI/Anthropic path (#58962).
_bump_stale_streak(agent)
# Best-effort: evict the region's cached bedrock-runtime client
# so the NEXT call reconnects with a fresh pool. NOTE: this does
# NOT abort the in-flight botocore EventStream the worker thread
# is blocked on — botocore exposes no external cancellation for
# it — so the daemon worker keeps reading until its socket read
# ultimately errors. We therefore end THIS call by raising
# below and let the streak+give-up breaker escalate across turns.
try:
from agent.bedrock_adapter import invalidate_runtime_client
invalidate_runtime_client(_bedrock_region)
except Exception as _inval_exc:
logger.debug(
"bedrock: stale client eviction failed: %s", _inval_exc
)
# Reset the timer so a repeated trip (should the worker somehow
# survive) waits a fresh interval rather than re-firing instantly.
_bedrock_last_event["t"] = time.time()
# Escalate across turns: raises RuntimeError once the streak
# crosses HERMES_STREAM_STALE_GIVEUP, so a persistently wedged
# Bedrock provider aborts fast instead of re-waiting the timeout.
_check_stale_giveup(agent)
# Streak still under the give-up threshold: end THIS call with a
# TimeoutError so the outer retry loop / next turn re-evaluates
# and the streak carries forward. Break rather than keep polling
# a worker we cannot abort.
result["error"] = TimeoutError(
f"Bedrock stream produced no events for {int(_stale_elapsed)}s "
f"(threshold {int(_bedrock_stale_timeout)}s) — aborting stalled "
f"stream so the retry/fallback path can recover."
)
break
# 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
@ -2190,6 +2445,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
if result["error"] is not None:
raise result["error"]
# Success — clear the cross-turn breaker (#58962): Bedrock proved
# responsive. Mirrors the OpenAI/Anthropic success reset below so a
# recovered provider doesn't carry a stale streak into later turns.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
@ -2200,6 +2460,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_check_stale_giveup(agent)
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
# Transport kind of the registered request client — see the non-streaming
# variant. Routes _close_request_client_once to anthropic vs openai abort/
# close helpers (#67142).
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
# rationale. The streaming retry loop is where the 7-minute cascading-
@ -2210,9 +2474,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# exit immediately instead of retrying. (PR #6600.)
_request_cancelled = {"value": False}
def _set_request_client(client):
def _set_request_client(client, *, kind: str = "openai"):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# See #29507 explanation in the non-streaming variant above.
request_client_holder["owner_tid"] = threading.get_ident()
return client
@ -2235,7 +2500,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
@ -2254,6 +2525,68 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# resolved, so the builder degrades to its plain default if it ever runs
# first.
_stream_stale_timeout = None
stream_attempt_lock = threading.Lock()
stream_attempt_state = {
"current": 0,
"cancelled": set(),
"discarded_chunks": 0,
"discarded_bytes": 0,
}
def _start_stream_attempt() -> int:
with stream_attempt_lock:
stream_attempt_state["current"] += 1
return int(stream_attempt_state["current"])
def _cancel_current_stream_attempt(reason: str) -> None:
with stream_attempt_lock:
current = int(stream_attempt_state.get("current") or 0)
if current:
stream_attempt_state["cancelled"].add(current)
if current:
logger.debug(
"Marked stream attempt %s cancelled: %s",
current,
reason,
)
def _stream_attempt_is_active(stream_attempt_id: int) -> bool:
with stream_attempt_lock:
return (
stream_attempt_id == int(stream_attempt_state.get("current") or 0)
and stream_attempt_id not in stream_attempt_state["cancelled"]
)
def _stream_attempt_was_cancelled(stream_attempt_id: int) -> bool:
with stream_attempt_lock:
return stream_attempt_id in stream_attempt_state["cancelled"]
def _discard_stale_stream_chunk(stream_attempt_id: int, chunk) -> None:
try:
chunk_bytes = len(repr(chunk))
except Exception:
chunk_bytes = 0
with stream_attempt_lock:
stream_attempt_state["discarded_chunks"] += 1
stream_attempt_state["discarded_bytes"] += chunk_bytes
discarded_chunks = stream_attempt_state["discarded_chunks"]
discarded_bytes = stream_attempt_state["discarded_bytes"]
if discarded_chunks == 1:
logger.warning(
"Discarding chunk from superseded stream attempt %s "
"(discarded_chunks=%s discarded_bytes=%s)",
stream_attempt_id,
discarded_chunks,
discarded_bytes,
)
else:
logger.debug(
"Discarded stale stream chunk from attempt %s "
"(discarded_chunks=%s discarded_bytes=%s)",
stream_attempt_id,
discarded_chunks,
discarded_bytes,
)
def _fire_first_delta():
if not first_delta_fired["done"] and on_first_delta:
@ -2263,7 +2596,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
except Exception:
pass
def _call_chat_completions():
def _call_chat_completions(stream_attempt_id: int):
"""Stream a chat completions response."""
import httpx as _httpx
# Per-provider / per-model request_timeout_seconds (from config.yaml)
@ -2460,6 +2793,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if agent._interrupt_requested:
break
if not _stream_attempt_is_active(stream_attempt_id):
_discard_stale_stream_chunk(stream_attempt_id, chunk)
continue
if not chunk.choices:
if hasattr(chunk, "model") and chunk.model:
model_name = chunk.model
@ -2585,6 +2922,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if hasattr(chunk, "usage") and chunk.usage:
usage_obj = chunk.usage
if _stream_attempt_was_cancelled(stream_attempt_id):
raise _httpx.RemoteProtocolError(
f"stream attempt {stream_attempt_id} was superseded"
)
# Build mock response matching non-streaming shape
full_content = "".join(content_parts) or None
mock_tool_calls = None
@ -2669,24 +3011,32 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"mid-tool-call stream drop, not an output-length truncation.",
_dropped_names,
)
full_reasoning = "".join(reasoning_parts) or None
mock_message = SimpleNamespace(
role=role,
content=full_content,
tool_calls=None,
reasoning_content=full_reasoning,
return _build_partial_stream_stub(
role, full_content,
"".join(reasoning_parts) or None,
model_name, usage_obj,
dropped_tool_names=_dropped_names or None,
)
mock_choice = SimpleNamespace(
index=0,
message=mock_message,
finish_reason=FINISH_REASON_LENGTH,
# Text-only stream drop: the upstream closed the connection (or the
# SSE stream simply ended) with no finish_reason after delivering
# text content but no tool calls. Without this guard the partial
# text is silently stamped finish_reason="stop" and the turn ends as
# if complete — the model's intended next step is lost (#32086).
_text_only_dropped_no_finish = (
finish_reason is None
and content_parts
and not tool_calls_acc
)
if _text_only_dropped_no_finish:
logger.warning(
"Stream ended with no finish_reason after delivering text "
"with no tool calls; treating as a mid-stream drop."
)
return SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model=model_name,
choices=[mock_choice],
usage=usage_obj,
_dropped_tool_names=_dropped_names or None,
return _build_partial_stream_stub(
role, full_content,
"".join(reasoning_parts) or None,
model_name, usage_obj,
)
effective_finish_reason = finish_reason or "stop"
@ -2712,13 +3062,18 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
usage=usage_obj,
)
def _call_anthropic():
def _call_anthropic(request_client):
"""Stream an Anthropic Messages API response.
Fires delta callbacks for real-time token delivery, but returns
the native Anthropic Message object from get_final_message() so
the rest of the agent loop (validation, tool extraction, etc.)
works unchanged.
Uses ``request_client`` (a per-request Anthropic client registered with
the stranger-thread abort machinery) rather than the shared
``_anthropic_client``, so the stale/interrupt watchdog can abort this
stream's socket without closing the shared client mid-flight (#67142).
"""
has_tool_use = False
# Zero-event guard parity with the chat_completions path: track
@ -2747,7 +3102,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
)
# Use the Anthropic SDK's streaming context manager
with agent._anthropic_client.messages.stream(**api_kwargs) as stream:
with request_client.messages.stream(**api_kwargs) as stream:
# The Anthropic SDK exposes the raw httpx response on
# ``stream.response``. Snapshot diagnostic headers
# immediately so they survive a stream that dies before the
@ -2873,6 +3228,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
try:
for _stream_attempt in range(_max_stream_retries + 1):
stream_attempt_id = _start_stream_attempt()
# Check for interrupt before each retry attempt. Without
# this, /stop closes the HTTP connection (outer poll loop),
# but the retry loop opens a FRESH connection — negating the
@ -2880,13 +3236,22 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# retry can block for the full stream-read timeout (120s+),
# causing multi-minute delays between /stop and response.
if agent._interrupt_requested:
_cancel_current_stream_attempt("interrupt_before_stream_retry")
raise InterruptedError("Agent interrupted before stream retry")
try:
if agent.api_mode == "anthropic_messages":
agent._try_refresh_anthropic_client_credentials()
result["response"] = _call_anthropic()
# #67142: per-request client (credential refresh happens
# inside _create_request_anthropic_client) registered so
# the watchdog aborts its socket, not the shared client.
request_client = _set_request_client(
agent._create_request_anthropic_client(
reason="anthropic_stream_request"
),
kind="anthropic_messages",
)
result["response"] = _call_anthropic(request_client)
else:
result["response"] = _call_chat_completions()
result["response"] = _call_chat_completions(stream_attempt_id)
return # success
except Exception as e:
# If the main poll loop force-closed this request because
@ -3008,14 +3373,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
mid_tool_call=True,
diag=request_client_holder.get("diag"),
)
_cancel_current_stream_attempt("stream_mid_tool_retry_cleanup")
_close_request_client_once("stream_mid_tool_retry_cleanup")
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
# #67142: anthropic streams on a request-local client,
# already worker-owned-closed by _close_request_client_once
# above; the next attempt builds a fresh one. The shared
# _anthropic_client is never closed from inside a request.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_mid_tool_retry_pool_cleanup"
@ -3072,16 +3436,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
diag=request_client_holder.get("diag"),
)
# Close the stale request client before retry
_cancel_current_stream_attempt("stream_retry_cleanup")
_close_request_client_once("stream_retry_cleanup")
# Also rebuild the primary client to purge
# any dead connections from the pool.
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
# Also rebuild the primary client to purge any dead
# connections from the pool. #67142: anthropic uses a
# request-local client (already worker-owned-closed
# above; next attempt builds fresh), so the shared
# _anthropic_client is never closed from inside a
# request — only the OpenAI-wire primary is refreshed.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_retry_pool_cleanup"
@ -3196,11 +3559,34 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
else:
_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.
# for prefill on large contexts, so tolerate far longer silence than
# the cloud default — but a wedged local server must EVENTUALLY trip the
# detector rather than hang forever (an infinite timeout meant a crashed
# or deadlocked local endpoint stalled the session indefinitely). 900s
# tolerates slow prefill while still bounding a hung endpoint. Applies
# unless the user explicitly set HERMES_STREAM_STALE_TIMEOUT; override the
# local ceiling with HERMES_LOCAL_STREAM_STALE_TIMEOUT (documented in
# website/docs/reference/environment-variables.md).
if _stream_stale_timeout_base == 180.0 and agent.base_url and is_local_endpoint(agent.base_url):
_stream_stale_timeout = float("inf")
logger.debug("Local provider detected (%s) — stale stream timeout disabled", agent.base_url)
# Read config.yaml ``agent.local_stream_stale_timeout`` (default 900),
# env var ``HERMES_LOCAL_STREAM_STALE_TIMEOUT`` overrides for escape-hatch.
_local_default = 900.0
try:
from hermes_cli.config import load_config
_cfg = load_config()
_agent_cfg = _cfg.get("agent") if isinstance(_cfg, dict) else None
if isinstance(_agent_cfg, dict):
_v = _agent_cfg.get("local_stream_stale_timeout")
if isinstance(_v, (int, float)):
_local_default = float(_v)
except Exception:
pass
_stream_stale_timeout = env_float("HERMES_LOCAL_STREAM_STALE_TIMEOUT", _local_default)
logger.debug(
"Local provider detected (%s) — stale stream timeout set to %.0fs",
agent.base_url, _stream_stale_timeout,
)
else:
# Scale the stale timeout for large contexts: slow models (like Opus)
# can legitimately think for minutes before producing the first token
@ -3288,6 +3674,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
f"Reconnecting..."
)
try:
_cancel_current_stream_attempt("stale_stream_kill")
_close_request_client_once("stale_stream_kill")
except Exception:
pass
@ -3297,11 +3684,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
# #67142: the stale stream ran on a request-local anthropic
# client, already socket-aborted above via
# _close_request_client_once (which unblocks the worker and
# preserves the #28161 no-hang guarantee). The shared
# _anthropic_client is NOT the in-flight transport, so we must
# not close it from this poll (stranger) thread — that was the
# FD-recycle corruption vector. Nothing further is needed.
pass
else:
try:
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
@ -3329,11 +3719,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"(not a network error)."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stream_interrupt_abort")
_cancel_current_stream_attempt("stream_interrupt_abort")
# #67142: kind-aware — anthropic aborts the request-local
# client's socket from this poll thread; the shared
# _anthropic_client is never closed here.
_close_request_client_once("stream_interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")

View file

@ -702,6 +702,16 @@ def run_codex_app_server_turn(
except Exception:
pass
agent._codex_session = None
_user_interrupted = bool(
getattr(agent, "_interrupt_requested", False)
)
_interrupt_message = (
getattr(agent, "_interrupt_message", None)
if _user_interrupted
else None
)
if _user_interrupted:
agent.clear_interrupt()
return {
"final_response": (
f"Codex app-server turn failed: {exc}. "
@ -711,9 +721,27 @@ def run_codex_app_server_turn(
"api_calls": 0,
"completed": False,
"partial": True,
"interrupted": _user_interrupted,
**(
{"interrupt_message": _interrupt_message}
if _interrupt_message
else {}
),
"error": str(exc),
}
# This runtime bypasses the normal conversation-loop finalizer. Mirror its
# interrupt handoff/cleanup so a hard stop cannot poison the next turn and a
# message-bearing compatibility interrupt can still be replayed by callers.
_user_interrupted = bool(
turn.interrupted and getattr(agent, "_interrupt_requested", False)
)
_interrupt_message = (
getattr(agent, "_interrupt_message", None) if _user_interrupted else None
)
if _user_interrupted:
agent.clear_interrupt()
# If the turn signalled the underlying client is wedged (deadline
# blown, post-tool watchdog tripped, OAuth refresh died, subprocess
# exited), retire the session so the next turn respawns codex
@ -819,6 +847,12 @@ def run_codex_app_server_turn(
"api_calls": api_calls,
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"interrupted": _user_interrupted,
**(
{"interrupt_message": _interrupt_message}
if _interrupt_message
else {}
),
"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

View file

@ -55,13 +55,12 @@ import json
import logging
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
from hermes_cli._subprocess_compat import bounded_git_probe
logger = logging.getLogger("hermes.coding_context")
@ -689,18 +688,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 ""
return out.stdout.strip() if out.returncode == 0 else ""
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
on Windows a plain ``subprocess.run(timeout=...)`` here deadlocked the agent
turn inside ``build_coding_workspace_block`` when a killed git left a suspended
descendant holding the pipe handles (issue #66037).
"""
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,31 @@ Lifecycle:
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from agent.redact import redact_sensitive_text
MEMORY_CONTEXT_MAX_CHARS = 6_000
_MEMORY_CONTEXT_HEAD_CHARS = 4_000
_MEMORY_CONTEXT_TAIL_CHARS = 1_500
_MEMORY_CONTEXT_TRUNCATION_MARKER = "\n...[memory provider context truncated]...\n"
def sanitize_memory_context(memory_context: str) -> str:
"""Prepare provider context for a context-engine/LLM egress boundary."""
sanitized = redact_sensitive_text(
memory_context.strip(),
force=True,
redact_url_credentials=True,
)
if len(sanitized) <= MEMORY_CONTEXT_MAX_CHARS:
return sanitized
return (
sanitized[:_MEMORY_CONTEXT_HEAD_CHARS]
+ _MEMORY_CONTEXT_TRUNCATION_MARKER
+ sanitized[-_MEMORY_CONTEXT_TAIL_CHARS:]
)
class ContextEngine(ABC):
@ -87,8 +111,10 @@ class ContextEngine(ABC):
def compress(
self,
messages: List[Dict[str, Any]],
current_tokens: int = None,
focus_topic: str = None,
current_tokens: Optional[int] = None,
focus_topic: Optional[str] = None,
force: bool = False,
memory_context: str = "",
) -> List[Dict[str, Any]]:
"""Compact the message list and return the new message list.
@ -103,6 +129,12 @@ class ContextEngine(ABC):
Engines that support guided compression should prioritise
preserving information related to this topic. Engines that
don't support it may simply ignore this argument.
force: Whether a user-requested compression should bypass an
engine-owned cooldown. Engines without cooldowns may ignore it.
memory_context: Text returned by memory providers immediately before
compaction. Summarizing engines should include non-empty text in
their handoff prompt. Older engines may omit this parameter; the
host filters unsupported optional arguments by signature.
"""
# -- Optional: pre-flight check ----------------------------------------
@ -228,4 +260,19 @@ class ContextEngine(ABC):
(e.g. recalculate DAG budgets, switch summary models).
"""
self.context_length = context_length
# Apply per-model threshold overrides if set (longest substring match).
# Falls back to _config_threshold_percent (the raw config value) when
# no override matches. Plugin engines that override update_model() can
# call resolve_model_threshold() for the same logic.
from agent.context_compressor import resolve_model_threshold
if not hasattr(self, "_config_threshold_percent"):
# Snapshot the pre-override percent ONCE so repeated model
# switches fall back to the engine's configured value, not the
# previous model's override.
self._config_threshold_percent = self.threshold_percent
self._base_threshold_percent = resolve_model_threshold(
model, getattr(self, "model_thresholds", {}),
self._config_threshold_percent,
)
self.threshold_percent = self._base_threshold_percent
self.threshold_tokens = int(context_length * self.threshold_percent)

File diff suppressed because it is too large Load diff

View file

@ -28,13 +28,24 @@ import uuid
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.conversation_compression import (
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE,
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE,
PRE_API_COMPRESSION_STATUS_TEMPLATE,
conversation_history_after_compression,
)
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
from agent.turn_context import build_turn_context
from agent.turn_context import (
_compression_warrants_another_preflight_pass,
build_turn_context,
compose_user_api_content,
reanchor_current_turn_user_idx,
)
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,
@ -49,6 +60,7 @@ from agent.message_sanitization import (
)
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH,
_estimate_tools_tokens_rough,
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
get_context_length_from_provider_error,
@ -78,6 +90,72 @@ logger = logging.getLogger(__name__)
# to treat it as cancellation metadata rather than assistant prose.
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
# Modules that indicate a deterministic local processing error when they
# appear in an exception traceback WITHOUT any API-call module. Used by the
# outer-loop error classifier to avoid retrying bugs that will fail
# identically every time (e.g. TypeError from passing list content into a
# regex helper). IMPORTANT: do NOT include "conversation_loop" or
# "run_agent" here — those are the container modules for the try/except
# itself, so every exception passes through them, which would make
# _hit_local always True and misclassify transient API/network errors as
# non-retryable local bugs. (#66267)
_LOCAL_PROCESSING_MODULES = frozenset({
"agent_runtime_helpers",
"message_content",
"message_sanitization",
"chat_completion_helpers", # only local when NOT also an API-call module
})
_API_CALL_MODULES = frozenset({
"chat_completion_helpers",
})
def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None:
"""Append a provider-safe checkpoint and correction to the live turn.
Incomplete provider reasoning blocks are not valid replay items (Anthropic
signs them; Responses reasoning items require their following output).
Preserve only what Hermes actually displayed, demoted to ordinary text,
then add the correction as a real user message. This keeps role alternation
valid and leaves every previously cached message byte-for-byte unchanged.
"""
reasoning = str(
getattr(agent, "_current_streamed_reasoning_text", "") or ""
).strip()
visible = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
).strip()
checkpoint_parts = ["[This response was interrupted by a user correction.]"]
if reasoning:
checkpoint_parts.extend(
["Reasoning shown before the interruption:", reasoning]
)
if visible:
checkpoint_parts.extend(
["Visible response before the interruption:", visible]
)
checkpoint = "\n\n".join(checkpoint_parts)
# The normal live tail is user or tool, so an assistant checkpoint followed
# by the correction preserves strict alternation. If a transport already
# committed an assistant item, attribute the checkpoint inside the user
# correction instead of creating assistant→assistant.
if messages and messages[-1].get("role") == "assistant":
correction = (
"[Context from the interrupted assistant response]\n"
f"{checkpoint}\n\n"
f"{text}"
)
messages.append({"role": "user", "content": correction})
else:
messages.append({"role": "assistant", "content": checkpoint})
messages.append({"role": "user", "content": text})
agent._current_streamed_assistant_text = ""
agent._current_streamed_reasoning_text = ""
agent._stream_needs_break = True
def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
@ -229,6 +307,19 @@ def _billing_or_entitlement_message(
]
return "\n".join(lines)
# Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq,
# OpenRouter, …) so every text surface — CLI, gateway messaging, TUI
# transcript — shows the same actionable link, not just OpenRouter.
try:
from agent.billing_links import build_billing_block
_link = build_billing_block(provider=provider, base_url=base_url, model=model)
if _link.provider_label:
provider_label = _link.provider_label
billing_url = _link.billing_url
except Exception:
billing_url = None
lines = [
(
f"{provider_label} reported that billing, credits, or account "
@ -236,12 +327,24 @@ def _billing_or_entitlement_message(
),
"Add credits or update billing with that provider, then retry.",
]
if base_url_host_matches(str(base_url or ""), "openrouter.ai"):
lines.append("OpenRouter credits: https://openrouter.ai/settings/credits")
if billing_url:
lines.append(f"{provider_label} billing: {billing_url}")
lines.append("You can switch providers temporarily with /model <model> --provider <provider>.")
return "\n".join(lines)
def _billing_block_dict(provider, base_url, model, message="") -> Optional[dict]:
"""Best-effort structured billing descriptor (None if billing_links is unavailable)."""
try:
from agent.billing_links import build_billing_block
return build_billing_block(
provider=provider, base_url=str(base_url), model=model, message=message
).to_dict()
except Exception:
return None
def _print_billing_or_entitlement_guidance(
agent,
*,
@ -610,8 +713,8 @@ def run_conversation(
# ── 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-
# build, crash-resilience persistence, preflight compression, the
# ``pre_llm_call`` plugin hook, and external-memory prefetch — lives in
# build, preflight compression, the ``pre_llm_call`` plugin hook,
# external-memory prefetch, and crash-resilience persistence — lives in
# ``build_turn_context``. It mutates ``agent`` exactly as the inline code
# did and returns the locals the loop below reads back. See
# ``agent/turn_context.py``.
@ -631,6 +734,9 @@ def run_conversation(
set_session_context=set_session_context,
set_current_write_origin=set_current_write_origin,
ra=_ra,
# MoA turns append per-call aggregated context to the API copy of the
# user message, so no byte-stable api_content sidecar can be stamped.
moa_active=bool(moa_config),
)
user_message = _ctx.user_message
original_user_message = _ctx.original_user_message
@ -658,12 +764,31 @@ def run_conversation(
truncated_tool_call_retries = 0
truncated_response_parts: List[str] = []
compression_attempts = 0
# One resolved per-turn compression attempt cap, shared by every site that
# consumes ``compression_attempts``: the pre-API pressure gate, the
# overflow/413 retry handlers, and the post-tool compaction gate.
# Config-driven via compression.max_attempts (parsed + validated in
# agent_init); default 3 preserves the prior hardcoded behavior for
# objects without the attribute (older pickles / minimal stubs).
max_compression_attempts = getattr(agent, "max_compression_attempts", 3)
_last_preflight_pressure: Optional[int] = None
_preflight_compression_blocked = _ctx.preflight_compression_blocked
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Last composed answer intentionally held back by a verification gate. If
# that continuation consumes the remaining budget, this is the best
# user-facing result available; it must not be confused with error or
# recovery text produced by unrelated exit paths.
_pending_verification_response = None
# Tracks whether the pending verification candidate was already streamed
# to the user as interim content. The finalizer uses this to set
# ``_response_was_previewed`` ONLY when the pending candidate is actually
# reused as the final response — not merely because any interim was
# streamed. (#65919 review: response-loss blocker)
_pending_verification_response_previewed = False
# If pre-API compression fires after MoA advisors have produced guidance,
# retain that ephemeral output and rebase it onto the compacted transcript
# on the next loop iteration. This prevents a second advisor fan-out.
pending_moa_prepared_request = None
# Per-turn tally of consecutive successful credential-pool token refreshes,
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
@ -687,6 +812,16 @@ def run_conversation(
)
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
_redirect_text = agent._drain_pending_redirect()
if _redirect_text:
_apply_active_turn_redirect(agent, messages, _redirect_text)
if isinstance(original_user_message, str):
original_user_message = (
f"{original_user_message}\n\n"
f"User correction during the turn: {_redirect_text}"
)
agent._persist_session(messages, conversation_history)
# Reset per-turn checkpoint dedup so each iteration can take one snapshot
agent._checkpoint_mgr.new_turn()
@ -839,23 +974,51 @@ def run_conversation(
for idx, msg in enumerate(messages):
api_msg = msg.copy()
# api_content is the persistence sidecar carrying the exact bytes
# sent to the API for this message when they differ from the clean
# stored content (see compose_user_api_content in turn_context).
# It is bookkeeping, never a provider field — pop it from EVERY
# outgoing copy.
_api_content = api_msg.pop("api_content", None)
# Inject ephemeral context into the current turn's user message.
# Sources: memory manager prefetch + plugin pre_llm_call hooks
# with target="user_message" (the default). Both are
# API-call-time only — the original message in `messages` is
# never mutated, so nothing leaks into session persistence.
# never mutated beyond the api_content stamp, so nothing leaks
# into the clean transcript content.
if idx == current_turn_user_idx and msg.get("role") == "user":
_injections = []
if _ext_prefetch_cache:
_fenced = build_memory_context_block(_ext_prefetch_cache)
if _fenced:
_injections.append(_fenced)
if _plugin_user_context:
_injections.append(_plugin_user_context)
if _injections:
_base = api_msg.get("content", "")
if isinstance(_base, str):
api_msg["content"] = _base + "\n\n" + "\n\n".join(_injections)
if isinstance(_api_content, str) and _api_content:
# Stamped by the prologue from the same composition —
# reuse it so the persisted sidecar and the wire cannot
# drift, and so every pass this turn sends identical
# bytes (composed from msg["content"], never from a
# previously-injected copy).
api_msg["content"] = _api_content
else:
# Callers that bypass the prologue stamping: compose live.
_composed = compose_user_api_content(
api_msg.get("content", ""),
_ext_prefetch_cache,
_plugin_user_context,
)
if _composed is not None:
api_msg["content"] = _composed
elif (
isinstance(_api_content, str)
and _api_content
and msg.get("role") in ("user", "assistant")
):
# Historical message: replay the exact bytes sent when it was
# live, so the provider prompt-cache prefix stays byte-stable
# instead of diverging at the injection point and
# re-prefilling everything after it. User rows carry the
# prefetch/plugin injection sidecar; user AND assistant rows
# can carry a sanitize-divergence sidecar (content that
# ``get_messages_as_conversation``'s sanitize_context/strip
# would rewrite on reload — see the capture in
# ``_flush_messages_to_session_db``).
api_msg["content"] = _api_content
# For ALL assistant messages, pass reasoning back to the API
# This ensures multi-turn reasoning context is preserved
@ -1019,17 +1182,39 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
# 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)
# Build a persistent-MoA request before measuring compression pressure.
# MoA reference output is injected into the aggregator prompt, but it
# is deliberately ephemeral and therefore absent from ``messages``.
# Preparing here makes the pre-API guard measure the exact prompt the
# aggregator will receive; ``create()`` consumes this private prepared
# request later without running the advisors a second time.
_moa_prepared_request = None
if agent.provider == "moa":
_moa_completions = getattr(getattr(agent.client, "chat", None), "completions", None)
if pending_moa_prepared_request is not None:
_rebase_moa_request = getattr(_moa_completions, "rebase_prepared_request", None)
if callable(_rebase_moa_request):
_moa_prepared_request = _rebase_moa_request(
pending_moa_prepared_request, api_messages
)
pending_moa_prepared_request = None
if _moa_prepared_request is None:
_prepare_moa_request = getattr(_moa_completions, "prepare", None)
if callable(_prepare_moa_request):
_moa_prepared_request = _prepare_moa_request(api_messages)
if _moa_prepared_request is not None:
api_messages = _moa_prepared_request["messages"]
# One image-stripped message estimate feeds both figures. Was: a
# str(msg) char walk (re-serialized base64 every call) + a second
# messages walk inside estimate_request_tokens_rough. Tools added
# separately (compression needs them: 50+ tools = 20-30K tokens).
# total_chars is a rough (~) proxy — verbose log + hook metric only.
approx_tokens = estimate_messages_tokens_rough(api_messages)
request_pressure_tokens = estimate_request_tokens_rough(
api_messages, tools=agent.tools or None
request_pressure_tokens = approx_tokens + (
_estimate_tools_tokens_rough(agent.tools) if agent.tools else 0
)
total_chars = approx_tokens * 4
_runtime_context_error = _ollama_context_limit_error(
agent, request_pressure_tokens
@ -1066,6 +1251,37 @@ def run_conversation(
# LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
# hard per-turn backstop shared with the overflow error handlers.
_compressor = agent.context_compressor
_preflight_threshold = int(
getattr(_compressor, "threshold_tokens", 0) or 0
)
# A previous mid-turn preflight pass deliberately continued the loop so
# API-only context and all sanitization could be rebuilt. Compare that
# fully assembled request with the fully assembled request that caused
# the pass. Raw ``messages`` are not equivalent here: they omit
# api_content/plugin injections, prefills, MoA context, and ephemeral
# system text.
_previous_preflight_pressure = _last_preflight_pressure
_last_preflight_pressure = None
if (
_previous_preflight_pressure is not None
and request_pressure_tokens >= _preflight_threshold
and not _compression_warrants_another_preflight_pass(
_previous_preflight_pressure,
request_pressure_tokens,
_preflight_threshold,
)
):
# Stop proactive retries for this turn without consuming the
# shared overflow-recovery budget. If the provider proves the
# request truly does not fit, its error handler may still compact
# with that stronger signal.
_preflight_compression_blocked = True
logger.warning(
"Pre-API compression made insufficient progress: ~%s -> "
"~%s request tokens; skipping additional preflight passes",
f"{_previous_preflight_pressure:,}",
f"{request_pressure_tokens:,}",
)
_defer_preflight = getattr(
_compressor, "should_defer_preflight_to_real_usage", lambda _t: False
)
@ -1075,25 +1291,31 @@ def run_conversation(
if (
agent.compression_enabled
and len(messages) > 1
and compression_attempts < 3
and compression_attempts < max_compression_attempts
and not _preflight_compression_blocked
and not _defer_preflight(request_pressure_tokens)
and not _compression_cooldown
and _compressor.should_compress(request_pressure_tokens)
):
if _moa_prepared_request is not None:
pending_moa_prepared_request = _moa_prepared_request
compression_attempts += 1
logger.info(
"Pre-API compression: ~%s request tokens >= %s threshold "
"(context=%s, attempt=%s/3)",
"(context=%s, attempt=%s/%s)",
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,
max_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."
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
tokens=request_pressure_tokens
)
)
_last_preflight_pressure = request_pressure_tokens
messages, active_system_prompt = agent._compress_context(
messages,
system_message,
@ -1157,7 +1379,6 @@ def run_conversation(
retry_count = 0
max_retries = agent._api_max_retries
_retry = TurnRetryState()
max_compression_attempts = 3
finish_reason = "stop"
response = None # Guard against UnboundLocalError if all retries fail
@ -1326,6 +1547,12 @@ def run_conversation(
if env_var_enabled("HERMES_DUMP_REQUESTS"):
agent._dump_api_request_debug(api_kwargs, reason="preflight")
# This object is private to the in-process MoA facade. Add it
# only after middleware, hooks, and debug dumps so none of them
# attempts to serialize it as part of the provider payload.
if _moa_prepared_request is not None and agent.provider == "moa":
api_kwargs["_moa_prepared_request"] = _moa_prepared_request
# Always prefer the streaming path — even without stream
# consumers. Streaming gives us fine-grained health
# checking (90s stale-stream detection, 60s read timeout)
@ -1395,22 +1622,59 @@ def run_conversation(
from hermes_cli.middleware import run_llm_execution_middleware
response = run_llm_execution_middleware(
api_kwargs,
_perform_api_call,
original_request=_original_api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
middleware_trace=list(_llm_middleware_trace),
)
_model_request_active = getattr(agent, "_model_request_active", None)
_redirect_lock = getattr(agent, "_pending_redirect_lock", None)
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.set()
elif _model_request_active is not None:
_model_request_active.set()
_redirect_crossed_response = False
try:
response = run_llm_execution_middleware(
api_kwargs,
_perform_api_call,
original_request=_original_api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
middleware_trace=list(_llm_middleware_trace),
)
finally:
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = bool(
agent._pending_redirect
)
else:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = agent._has_pending_redirect()
if _redirect_crossed_response:
# The response and redirect can cross on different threads:
# redirect() observed the request as active just before this
# call returned. Discard that now-stale response and rebuild
# from the correction rather than silently losing it.
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
else:
interrupted = True
break
api_duration = time.time() - api_start_time
@ -2372,6 +2636,15 @@ def run_conversation(
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if agent._has_pending_redirect():
# redirect() deliberately used the interrupt machinery to
# cancel only this provider request. Keep its correction
# queued, clear the cancellation bit, and let the outer
# loop rebuild a clean request tail. Never materialize
# incomplete signed/encrypted reasoning items.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
api_elapsed = time.time() - api_start_time
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
interrupted = True
@ -3254,8 +3527,9 @@ def run_conversation(
)
if len(messages) < original_len or old_ctx > _reduced_ctx:
agent._buffer_status(
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
f"(was {old_ctx:,}), retrying..."
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
new_ctx=_reduced_ctx, old_ctx=old_ctx
)
)
time.sleep(2)
_retry.restart_with_compressed_messages = True
@ -3516,9 +3790,9 @@ def run_conversation(
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...")
agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
else:
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@ -3736,7 +4010,7 @@ def run_conversation(
"failed": True,
"compression_exhausted": True,
}
agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
agent._buffer_status(COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=approx_tokens, attempt=compression_attempts, cap=max_compression_attempts))
original_len = len(messages)
original_tokens = estimate_messages_tokens_rough(messages)
@ -3757,9 +4031,9 @@ def run_conversation(
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...")
agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
elif new_tokens > 0 and new_tokens < original_tokens * 0.95:
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@ -4035,6 +4309,31 @@ def run_conversation(
final_response=_policy_response,
error_detail=_nonretryable_summary,
)
# Billing walls are the common non-retryable abort: enrich
# the result with the same structured recovery descriptor as
# the max-retries path so every surface (CLI, TUI, desktop)
# renders one consistent billing signal.
if classified.reason == FailoverReason.billing:
_ce_guidance = _billing_or_entitlement_message(
capability="model access",
provider=_provider,
base_url=str(_base),
model=_model,
)
_ce_final = f"Billing or credits exhausted: {_nonretryable_summary}"
if _ce_guidance:
_ce_final += f"\n\n{_ce_guidance}"
_ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance)
return {
"final_response": _ce_final,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": _nonretryable_summary,
"failure_reason": classified.reason.value,
"billing_block": _ce_block,
}
return {
"final_response": _nonretryable_summary,
"messages": messages,
@ -4195,10 +4494,14 @@ def run_conversation(
api_kwargs, reason="max_retries_exhausted", error=api_error,
)
agent._persist_session(messages, conversation_history)
_billing_block = None
if classified.reason == FailoverReason.billing:
_final_response = f"Billing or credits exhausted: {_final_summary}"
if _billing_guidance:
_final_response += f"\n\n{_billing_guidance}"
# Structured recovery descriptor so every surface renders
# the same link + label from one signal (see helper).
_billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance)
else:
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
if _is_thinking_timeout:
@ -4238,6 +4541,9 @@ def run_conversation(
# different exit code. ``rate_limit`` / ``billing`` here
# mean "quota wall, not a task error".
"failure_reason": classified.reason.value,
# Present only for billing walls: structured recovery
# descriptor (provider, billing_url, is_nous, message).
"billing_block": _billing_block,
}
# For rate limits, respect the Retry-After header if present
@ -4320,6 +4626,15 @@ def run_conversation(
f"{int(sleep_end - time.time())}s remaining"
)
if _retry.restart_with_redirected_messages:
# The cancelled request produced no valid assistant item. Reuse the
# same logical iteration after the outer loop appends the displayed
# partial context and correction to ``messages``.
api_call_count -= 1
agent.iteration_budget.refund()
_retry.restart_with_redirected_messages = False
continue
# If the API call was interrupted, skip response processing
if interrupted:
_turn_exit_reason = "interrupted_during_api_call"
@ -4333,6 +4648,16 @@ def run_conversation(
# to fit the context window.
retry_count += 1
_retry.restart_with_compressed_messages = False
# In-loop compression rebuilt `messages` with fresh compaction
# copies, so the pre-compression current-turn index is stale.
# Re-anchor exactly like the prologue does: a stale index that
# lands on a historical user message would make the live-compose
# fallback inject this turn's prefetch into that message on the
# wire only, diverging the next turn's replayed prefix there.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
continue
if _retry.restart_with_rebuilt_messages:
@ -5070,7 +5395,12 @@ def run_conversation(
messages, tools=agent.tools or None
)
if agent.compression_enabled and _compressor.should_compress(_real_tokens):
if (
agent.compression_enabled
and compression_attempts < max_compression_attempts
and _compressor.should_compress(_real_tokens)
):
compression_attempts += 1
agent._safe_print(" ⟳ compacting context…")
messages, active_system_prompt = agent._compress_context(
messages, system_message,
@ -5459,17 +5789,17 @@ def run_conversation(
getattr(agent, "_verification_stop_nudges", 0) + 1
)
final_msg["finish_reason"] = "verification_required"
final_msg["_verification_stop_synthetic"] = True
# The assistant response is real content — persist it and
# emit to the UI as an interim message so the user sees the
# attempted final answer before the verification loop runs.
# Only the nudge is flagged synthetic so it gets stripped
# from the durable transcript (#65919 §7).
agent._emit_interim_assistant_message(final_msg)
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)
try:
agent._flush_messages_to_session_db(messages, conversation_history)
except Exception:
logger.debug("verify-on-stop interim flush failed", exc_info=True)
messages.append({
"role": "user",
"content": _verify_nudge,
@ -5485,7 +5815,13 @@ def run_conversation(
# continuation-budget exhaustion. ``final_response`` itself
# must be cleared so the finalizer can distinguish this gate
# from unrelated error/recovery exits. (#61631)
# Track whether this candidate was already streamed so the
# finalizer can mark the turn previewed only if the
# candidate is actually reused as the final response.
_pending_verification_response = final_response
_pending_verification_response_previewed = (
agent._interim_content_was_streamed(final_response or "")
)
final_response = None
continue
@ -5524,12 +5860,17 @@ def run_conversation(
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)
# The assistant response is real content — persist it and
# emit to the UI as an interim message so the user sees the
# attempted final answer before the pre_verify loop runs.
# Only the nudge is flagged synthetic so it gets stripped
# from the durable transcript (#65919 §7).
agent._emit_interim_assistant_message(final_msg)
messages.append(final_msg)
try:
agent._flush_messages_to_session_db(messages, conversation_history)
except Exception:
logger.debug("pre_verify interim flush failed", exc_info=True)
messages.append({
"role": "user",
"content": _verify_nudge2,
@ -5539,6 +5880,9 @@ def run_conversation(
logger.debug("pre_verify nudge issued (attempt %d)",
agent._pre_verify_nudges)
_pending_verification_response = final_response
_pending_verification_response_previewed = (
agent._interim_content_was_streamed(final_response or "")
)
final_response = None
continue
@ -5586,6 +5930,9 @@ def run_conversation(
# exhaustion path does not treat the narrated stop as
# a completed answer.
_pending_verification_response = final_response
_pending_verification_response_previewed = (
agent._interim_content_was_streamed(final_response or "")
)
final_response = None
continue
@ -5597,7 +5944,36 @@ def run_conversation(
break
except Exception as e:
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
# Phase-aware error classification. The huge outer try/except spans
# both the actual API request and all local post-processing of the
# returned assistant message. Deterministic local bugs (e.g.
# passing a multimodal content list into a regex helper after a
# vision turn or context compaction) should not be retried: they
# will fail identically on every iteration and only burn the
# iteration budget. We classify an error as local by inspecting the
# traceback: if the exception propagated through any of the known
# local post-processing helpers and never entered the interruptible
# API-call helpers, it is almost certainly a local processing bug.
# (#66267)
tb_module_names: set[str] = set()
_tb = e.__traceback__
while _tb is not None:
_fname = os.path.splitext(os.path.basename(_tb.tb_frame.f_code.co_filename))[0]
tb_module_names.add(_fname)
_tb = _tb.tb_next
_hit_local = bool(tb_module_names & _LOCAL_PROCESSING_MODULES)
_hit_api = bool(tb_module_names & _API_CALL_MODULES)
_is_local_processing_error = _hit_local and not _hit_api
if _is_local_processing_error:
error_msg = (
f"Error during local message processing after "
f"OpenAI-compatible API call #{api_call_count}: {str(e)}"
)
else:
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
try:
print(f"{error_msg}")
except (OSError, ValueError):
@ -5644,10 +6020,19 @@ def run_conversation(
# message pollutes history, burns tokens, and risks violating
# role-alternation invariants.
# If we're near the limit, break to avoid infinite loops
if api_call_count >= agent.max_iterations - 1:
_turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
final_response = f"I apologize, but I encountered repeated errors: {error_msg}"
# If we're near the limit, break to avoid infinite loops.
# Local processing errors are deterministic — stop immediately
# rather than retrying until the budget is exhausted.
if (
_is_local_processing_error
or api_call_count >= agent.max_iterations - 1
):
if _is_local_processing_error:
_turn_exit_reason = f"local_processing_error({error_msg[:80]})"
final_response = f"I apologize, but I encountered an error while processing the model response: {error_msg}"
else:
_turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
final_response = f"I apologize, but I encountered repeated errors: {error_msg}"
# Append as assistant so the history stays valid for
# session resume (avoids consecutive user messages).
messages.append({"role": "assistant", "content": final_response})
@ -5672,6 +6057,7 @@ def run_conversation(
_should_review_memory=_should_review_memory,
_turn_exit_reason=_turn_exit_reason,
_pending_verification_response=_pending_verification_response,
_pending_verification_response_previewed=_pending_verification_response_previewed,
)

View file

@ -43,11 +43,19 @@ logger = logging.getLogger(__name__)
def _load_config_safe() -> Optional[dict]:
"""Load config.yaml, returning None on any error."""
try:
from hermes_cli.config import load_config
"""Load config.yaml read-only, returning None on any error.
return load_config()
Uses ``load_config_readonly()``: every consumer in this module only reads
(``get_pool_strategy``, ``_iter_custom_providers``, the model-config seed),
and the deepcopy that ``load_config()`` pays per call is what made
credential-pool checks the dominant cost of ``model.options`` the picker
calls ``load_pool()`` once per provider row, each of which loaded (and
deep-copied) the full config again.
"""
try:
from hermes_cli.config import load_config_readonly
return load_config_readonly()
except Exception:
return None
@ -1476,6 +1484,43 @@ class CredentialPool:
self._sync_device_code_entry_to_auth_store(updated)
return updated
def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool:
"""Live-check whether an exhausted Codex entry's quota reset early.
A Codex 429 persists a ``last_error_reset_at`` that can be days in
the future (weekly windows), but the upstream window can reopen
before then the user redeems a banked rate-limit reset via the
Codex CLI / ChatGPT UI, upgrades their plan, or OpenAI resets the
window. Without this check the pool keeps the credential frozen
until the stale timestamp elapses even though the account is
usable (issue #43747).
Only fires for openai-codex entries frozen by a 429/quota-shaped
error. The underlying probe is throttled per token (5 min) so this
is safe on the hot selection path.
"""
if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED:
return False
if not auth_mod._is_codex_rate_limit_shaped(
entry.last_error_code,
entry.last_error_reason,
entry.last_error_message,
):
return False
token = entry.access_token or ""
if not token:
return False
try:
return bool(
auth_mod._probe_codex_quota_restored(
token,
base_url=entry.base_url,
)
)
except Exception:
logger.debug("Codex quota-restored probe failed", exc_info=True)
return False
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
@ -1597,7 +1642,18 @@ class CredentialPool:
if entry.last_status == STATUS_EXHAUSTED:
exhausted_until = _exhausted_until(entry)
if exhausted_until is not None and now < exhausted_until:
continue
# Codex quota windows can reopen EARLY: the user redeems a
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
# their plan, or OpenAI resets the window. The persisted
# ``last_error_reset_at`` can then be days in the future
# while the account is already usable again — a throttled
# live probe of the Codex usage endpoint detects that and
# lifts the stale cooldown (issue #43747).
if not (
clear_expired
and self._codex_quota_restored_upstream(entry)
):
continue
if clear_expired:
cleared = replace(
entry,
@ -2301,9 +2357,10 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
raw = env_file.get(key, "").strip()
env_val = os.environ.get(key, "").strip()
scoped_value = (_get_secret(key, "") or "").strip()
# If .env contains an unresolved op:// reference, prefer the
# already-resolved value from os.environ (set by
# already-resolved value supplied by the active secret scope (or by
# os.environ in legacy single-profile mode), 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
@ -2311,9 +2368,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# 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
if raw.startswith("op://") and scoped_value:
return scoped_value
return raw or scoped_value
# Honour user suppression — `hermes auth remove <provider> <N>` for an
# env-seeded credential marks the env:<VAR> source as suppressed so it

View file

@ -355,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /credits to top up",
text="✕ Credit access paused · run /topup to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",

View file

@ -98,7 +98,12 @@ def _backup_cron_jobs_into(dest: Path) -> Dict[str, Any]:
info["reason"] = "no cron/jobs.json present"
return info
try:
raw = src.read_text(encoding="utf-8")
# utf-8-sig: same dialect as cron/jobs.load_jobs — a UTF-8 BOM left
# by Windows editors otherwise survives decoding as U+FEFF, breaks
# json.loads below, and misreports jobs_count as 0 with a spurious
# parse warning. The BOM-less text is also what gets written to the
# backup, so a later rollback restores a loadable file.
raw = src.read_text(encoding="utf-8-sig")
except OSError as e:
logger.debug("Failed to read cron/jobs.json for backup: %s", e)
info["reason"] = f"read error: {e}"

View file

@ -645,6 +645,52 @@ def verb_drops_preview(tool_name: str) -> bool:
return tool_name in _TOOL_VERBS_NO_PREVIEW
def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> str | None:
"""Build a short present-tense status phrase for platform status surfaces.
Used by text-rendering "typing" indicators (Slack's
``assistant.threads.setStatus`` line) to show what the agent is doing
right now: ``is running scripts/run_tests.sh`` instead of a static
``is thinking...``. The phrase is phrased to follow the bot's display
name ("Hermes is running …"), so it starts lowercase with "is".
Pass ``args=None`` for a verb-only phrase (``is running``) used when
``display.live_status`` is ``verb`` to keep argument previews out of
shared channels.
Returns None for the ``_thinking`` pseudo-tool and when friendly labels
are disabled (callers fall back to their static default). ``max_len``
caps the total phrase length; Slack truncates its status line around 50
characters, so the default stays just under that.
"""
if not tool_name or tool_name == "_thinking":
return None
if not _friendly_tool_labels:
return None
verb = _TOOL_VERBS.get(tool_name)
if verb:
head = f"is {verb[0].lower()}{verb[1:]}"
else:
# Custom / plugin / MCP tools: generic but still informative.
head = f"is using {tool_name}"
phrase = head
if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW:
preview = build_tool_preview(tool_name, args, max_len=None)
if preview:
# Previews can contain newlines (terminal commands); keep the
# status to the first line.
preview = preview.splitlines()[0].strip()
phrase = f"{head}{tool_verb_connector(tool_name)}{preview}"
if len(phrase) > max_len - 1:
phrase = phrase[: max_len - 2].rstrip() + ""
else:
phrase = phrase + ""
return phrase
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.

View file

@ -269,6 +269,11 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"context window",
"prompt is too long",
"prompt exceeds max length",
# NOTE: bare "max_tokens" is load-bearing — the output-cap-retry path keys
# off it (e.g. "max_tokens: 65536 > context_window: 200000 ..."). Do NOT
# remove it. Provider empty-response advisories also contain "very low
# max_tokens", but those are intercepted by _EMPTY_PROVIDER_RESPONSE_PATTERNS
# BEFORE this list is consulted, so they never mis-route into compression.
"max_tokens",
"maximum number of tokens",
# vLLM / local inference server patterns
@ -426,6 +431,19 @@ _THINKING_SIG_PATTERNS = [
# the exception type is generic (e.g. RuntimeError from a local shim that
# wraps a subprocess timeout). Checked before the type-based transport
# heuristics so custom-provider "timed out" errors don't fall through to
# Provider empty-response advisories (OpenRouter / nano-gpt / similar).
# Checked before context-overflow matching because the advisory text often
# mentions "max_tokens" as a possible cause, which historically sat in
# _CONTEXT_OVERFLOW_PATTERNS and sent healthy sessions into a compression
# death spiral ending in "Cannot compress further".
_EMPTY_PROVIDER_RESPONSE_PATTERNS = [
"returned an empty response",
"empty response despite retries",
"provider returned an empty response",
"model returning empty responses",
"empty response stream",
]
# the unknown bucket and get misreported as empty responses.
_TIMEOUT_MESSAGE_PATTERNS = [
"timed out",
@ -1077,6 +1095,14 @@ def _classify_by_status(
# 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.
# Empty-response advisories that mention "max_tokens" must not enter
# that compression path.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
@ -1090,6 +1116,12 @@ def _classify_by_status(
# 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 _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
@ -1215,8 +1247,8 @@ def _classify_400(
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# That string contains the literal substring "max_tokens", which historically
# sat in _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
@ -1238,6 +1270,17 @@ def _classify_400(
should_fallback=True,
)
# Empty-provider-response advisories must not enter compression. They
# often mention "max_tokens" as a possible cause and used to match the
# bare overflow pattern, then thrash compress until "Cannot compress
# further" on an otherwise healthy session (custom endpoints / nano-gpt).
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
@ -1441,6 +1484,15 @@ def _classify_by_message(
should_fallback=True,
)
# Empty-provider-response advisories (often mention "max_tokens") must
# retry without compression — see the matching 400-path guard above.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow patterns
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(

View file

@ -46,6 +46,9 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
# Bitwarden Secrets Manager encrypted disk cache.
str(hermes_home / "cache" / "bws_cache.enc.json"),
str(hermes_root / "cache" / "bws_cache.enc.json"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),

View file

@ -32,7 +32,6 @@ from __future__ import annotations
import logging
import os
import sysconfig
import threading
from functools import lru_cache
from pathlib import Path
@ -92,12 +91,8 @@ def _locales_dir() -> Path:
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
sealed-packaging system) to point at the installed catalog directory.
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
2. ``<repo-root>/locales`` -- source checkouts and editable installs,
where the working tree sits next to ``agent/``.
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
interpreter's ``data`` scheme; the other schemes are checked as a
safety net for nonstandard layouts.
Falling through to the source-style path (even when missing) keeps
``_load_catalog`` error messages informative -- it logs the path it
@ -116,25 +111,6 @@ def _locales_dir() -> Path:
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
source_dir = Path(__file__).resolve().parent.parent / "locales"
if source_dir.is_dir():
return source_dir
# pip wheel install: data-files lands under the interpreter data scheme.
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
# both are out of scope; see the plan header.
for scheme in ("data", "purelib", "platlib"):
raw = sysconfig.get_path(scheme)
if not raw:
continue
candidate = Path(raw) / "locales"
if candidate.is_dir():
return candidate
# Last resort: return the source-style path so _load_catalog's catalog-missing
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
return source_dir

View file

@ -18,9 +18,15 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
Implementation notes:
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
- All per-document state lives in one :class:`_DocState` keyed by
absolute path. Freshness is tracked with **document versions**,
not timestamps: every didChange bumps ``version``, and each stored
push/pull result is tagged with the version it describes. A
result is fresh iff its tag >= the version being waited on, so a
didChange implicitly invalidates everything older no clearing,
no clock comparisons, no race windows. This is what prevents
"ghost diagnostics": a slow server's leftovers from the previous
edit can never masquerade as a verdict on the current content.
- Whole-document sync. Even when the server advertises incremental
sync, we send a single ``contentChanges`` entry replacing the
@ -45,6 +51,7 @@ import asyncio
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote
@ -124,6 +131,40 @@ def _end_position(text: str) -> Dict[str, int]:
return {"line": last_line, "character": last_col}
@dataclass
class _DocState:
"""Everything the client tracks for one open document.
``version`` is the LSP document version we last sent (didOpen=0,
each didChange +1). It doubles as the freshness token: stored
push/pull results are tagged with the version they describe
(``push_version`` / ``pull_version``), and a result is *fresh*
iff its tag has caught up to ``version``. Bumping the version on
didChange therefore invalidates all older results implicitly
no store-clearing, no timestamps.
``push_version``/``pull_version`` start at -1 = "no data yet".
Servers that echo a document version in publishDiagnostics get
exact tagging; those that don't are credited with the current
version at receipt time (a push observed after we sent the
change describes the changed content or newer).
"""
version: int = 0
text: str = ""
push: List[Dict[str, Any]] = field(default_factory=list)
pull: List[Dict[str, Any]] = field(default_factory=list)
push_version: int = -1
pull_version: int = -1
seed_seen: bool = False
def fresh_push(self, version: Optional[int] = None) -> bool:
return self.push_version >= (self.version if version is None else version)
def fresh_pull(self, version: Optional[int] = None) -> bool:
return self.pull_version >= (self.version if version is None else version)
class LSPClient:
"""Async LSP client tied to one server process and one workspace root.
@ -186,18 +227,10 @@ class LSPClient:
# is silently dropped by default.
}
# Tracked file state — required for didChange version bumps.
self._files: Dict[str, Dict[str, Any]] = {}
# Diagnostic stores, keyed by file path (NOT URI).
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
# Per-path "last published" time so wait-for-fresh logic works.
self._published: Dict[str, float] = {}
# Per-path version of the latest push (matches our didChange
# version when the server respects it).
self._published_version: Dict[str, int] = {}
# First-push seen flag, for typescript-style seed-on-first-push.
self._first_push_seen: Set[str] = set()
# Per-document state (version, text, diagnostic stores, and
# their freshness tags), keyed by absolute file path (NOT URI).
# See _DocState for the version-based freshness model.
self._docs: Dict[str, _DocState] = {}
# Capability registrations — only diagnostic ones are tracked.
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
@ -647,25 +680,25 @@ class LSPClient:
if not isinstance(diagnostics, list):
diagnostics = []
version = params.get("version")
loop_time = asyncio.get_event_loop().time()
if self._seed_first_push and path not in self._first_push_seen:
# First push: seed without firing the event so a waiter
# doesn't resolve on the very first push (which arrives
# before the user-triggered didChange could've produced
# fresh diagnostics).
self._first_push_seen.add(path)
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
doc = self._docs.setdefault(path, _DocState(version=-1))
if self._seed_first_push and not doc.seed_seen:
# First push: seed the store WITHOUT a freshness tag. It
# arrives before the user-triggered didChange could've
# produced fresh diagnostics, so it must never satisfy a
# waiter — it's baseline data only.
doc.seed_seen = True
doc.push = diagnostics
return
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
self._first_push_seen.add(path)
doc.seed_seen = True
doc.push = diagnostics
# Tag with the echoed document version when the server provides
# one; otherwise credit the current version — a push observed
# after we sent the change describes the changed content (or
# newer). Note doc.version is -1 for never-opened paths
# (e.g. relatedDocuments spillover), keeping them unfresh.
doc.push_version = version if isinstance(version, int) else doc.version
# Bump the monotonic push counter and wake every waiter. We
# keep the Event sticky-set so any wait already in progress
# resolves; waiters re-check their predicate after waking and
@ -694,16 +727,16 @@ class LSPClient:
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
uri = file_uri(abs_path)
existing = self._files.get(abs_path)
doc = self._docs.get(abs_path)
if existing is not None:
if doc is not None and doc.version >= 0:
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
await self._send_notification(
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
)
new_version = existing["version"] + 1
old_text = existing["text"]
new_version = doc.version + 1
old_text = doc.text
content_changes: List[Dict[str, Any]]
if self._sync_kind == 2:
content_changes = [
@ -724,7 +757,11 @@ class LSPClient:
"contentChanges": content_changes,
},
)
self._files[abs_path] = {"version": new_version, "text": text}
# Bumping the version is the whole invalidation story:
# every stored result tagged with an older version is now
# stale by definition (see _DocState).
doc.version = new_version
doc.text = text
return new_version
# First open: didChangeWatchedFiles CREATED + didOpen.
@ -732,12 +769,9 @@ class LSPClient:
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
)
# Clear any stale push/pull entries — fresh open should start
# from scratch.
self._push_diagnostics.pop(abs_path, None)
self._pull_diagnostics.pop(abs_path, None)
self._published.pop(abs_path, None)
self._published_version.pop(abs_path, None)
# Fresh doc state — anything stashed under this path by a
# pre-open push (relatedDocuments spillover etc.) is discarded.
self._docs[abs_path] = _DocState(version=0, text=text)
await self._send_notification(
"textDocument/didOpen",
{
@ -749,7 +783,6 @@ class LSPClient:
}
},
)
self._files[abs_path] = {"version": 0, "text": text}
return 0
async def save_file(self, path: str) -> None:
@ -769,12 +802,19 @@ class LSPClient:
async def _pull_document_diagnostics(self, path: str) -> None:
"""Send ``textDocument/diagnostic`` for one file.
Stores results into :attr:`_pull_diagnostics`. Silently
no-ops on errors (server may not support the pull endpoint).
Stores results into the doc's pull store, tagged with the
document version captured at request send time. If a didChange
races past the in-flight request, the version bump makes the
stored result stale automatically no explicit invalidation.
Silently no-ops on errors (server may not support the pull
endpoint).
"""
abs_path = os.path.abspath(path)
doc = self._docs.get(abs_path)
sent_version = doc.version if doc else -1
try:
params: Dict[str, Any] = {
"textDocument": {"uri": file_uri(os.path.abspath(path))}
"textDocument": {"uri": file_uri(abs_path)}
}
result = await self._send_request_with_retry(
"textDocument/diagnostic",
@ -788,7 +828,9 @@ class LSPClient:
return
items = result.get("items")
if isinstance(items, list):
self._pull_diagnostics[os.path.abspath(path)] = items
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
doc.pull = items
doc.pull_version = sent_version
related = result.get("relatedDocuments")
if isinstance(related, dict):
for uri, sub in related.items():
@ -796,7 +838,11 @@ class LSPClient:
continue
sub_items = sub.get("items")
if isinstance(sub_items, list):
self._pull_diagnostics[uri_to_path(uri)] = sub_items
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
rel.pull = sub_items
# Same send-anchored tagging: fresh only if that
# doc hasn't changed since the request went out.
rel.pull_version = rel.version
async def wait_for_diagnostics(
self,
@ -804,22 +850,36 @@ class LSPClient:
version: int,
*,
mode: str = "document",
) -> None:
timeout: Optional[float] = None,
) -> bool:
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
``mode`` is ``"document"`` (5s budget, document pulls) or
``"full"`` (10s budget, also workspace pulls). Best-effort
returns silently on timeout. Does NOT throw if the server
doesn't support pull diagnostics; we still get the push side.
``"full"`` (10s budget, also workspace pulls). ``timeout``
overrides the mode's default budget when provided — this is
how the user's ``lsp.wait_timeout`` config reaches the wait
loop (slow servers like tsserver on big projects need more
than the 5s default).
Returns ``True`` when *fresh* diagnostics arrived (a push at
or after our didChange, or a pull answered after it) and
``False`` on timeout. Callers must treat ``False`` as "no
data", NOT as "no errors" — the diagnostic stores may still
hold stale entries from the previous edit at that point.
Best-effort never throws if the server doesn't support pull
diagnostics; we still get the push side.
"""
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
if timeout is not None and timeout > 0:
budget = timeout
else:
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
deadline = asyncio.get_event_loop().time() + budget
abs_path = os.path.abspath(path)
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return
return False
# Concurrent: document pull + push wait.
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
@ -838,26 +898,24 @@ class LSPClient:
pass
# If we got a fresh push for our version, we're done.
current_v = self._published_version.get(abs_path)
if abs_path in self._published and (
current_v is None or current_v >= version
):
return
doc = self._docs.get(abs_path)
if doc and doc.fresh_push(version):
return True
# Pull may have populated _pull_diagnostics — that's also
# success.
if abs_path in self._pull_diagnostics:
return
# Pull may have answered for the current version — that's
# also success.
if doc and doc.fresh_pull(version):
return True
# Loop until budget runs out.
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
deadline = asyncio.get_event_loop().time() + timeout
baseline = self._push_counter
while True:
current_v = self._published_version.get(path)
if path in self._published and (current_v is None or current_v >= version):
doc = self._docs.get(path)
if doc and doc.fresh_push(version):
# Debounce — wait a tick in case more diagnostics arrive
# immediately after. TS often emits in pairs. We
# snapshot the counter so we wake on a *new* push, not
@ -888,17 +946,28 @@ class LSPClient:
except asyncio.TimeoutError:
continue
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
"""Return current merged + deduped diagnostics for one file.
Diagnostics from push and pull stores are concatenated and
deduplicated by ``(severity, code, message, range)`` content
key. Empty list if the server hasn't published anything.
With ``fresh_only=True``, a store only contributes when its
version tag has caught up to the document's current version —
stale leftovers from the previous edit cycle are excluded.
This is what report paths should use: after an edit, "stale
errors" and "no errors" must not be conflated.
"""
abs_path = os.path.abspath(path)
push = self._push_diagnostics.get(abs_path) or []
pull = self._pull_diagnostics.get(abs_path) or []
return _dedupe(push, pull)
doc = self._docs.get(os.path.abspath(path))
if doc is None:
return []
if fresh_only:
return _dedupe(
doc.push if doc.fresh_push() else [],
doc.pull if doc.fresh_pull() else [],
)
return _dedupe(doc.push, doc.pull)
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:

View file

@ -292,7 +292,10 @@ class LSPService:
if not self.enabled_for(file_path):
return
try:
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
# Outer join budget must exceed the inner wait budget or a
# slow-but-alive server gets falsely marked broken.
t = max(8.0, self._wait_timeout + 3.0)
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
self._delta_baseline[os.path.abspath(file_path)] = diags or []
except Exception as e: # noqa: BLE001
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
@ -341,7 +344,7 @@ class LSPService:
try:
t = timeout if timeout is not None else self._wait_timeout + 2.0
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
except asyncio.TimeoutError as e:
eventlog.log_timeout(server_id, file_path)
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
@ -353,6 +356,17 @@ class LSPService:
self._mark_broken_for_file(file_path, e)
return []
if diags is None:
# The server is alive but never produced diagnostics for the
# post-edit content within the wait budget (common for
# tsserver on large projects). Report "no data" rather than
# whatever stale state is in the stores — surfacing the
# previous edit's errors as if they were current is the
# ghost-diagnostics bug. The server is NOT marked broken:
# slow is not dead, and the next edit may well succeed.
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
return []
abs_path = os.path.abspath(file_path)
if delta:
baseline = self._delta_baseline.get(abs_path) or []
@ -452,26 +466,43 @@ class LSPService:
return []
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
except Exception as e: # noqa: BLE001
logger.debug("snapshot open/wait failed: %s", e)
return []
self._last_used[(client.server_id, client.workspace_root)] = time.time()
return list(client.diagnostics_for(file_path))
if not fresh:
# No fresh data for the pre-edit content — an empty baseline
# is safe: worst case the delta filter removes less, never
# more. Never seed the baseline from stale stores.
return []
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
"""Open + wait for FRESH diagnostics.
Returns the fresh diagnostic list, or ``None`` when the server
never produced post-change data within the wait budget. The
distinction matters: ``[]`` means "server checked the new
content, it's clean", ``None`` means "no verdict" — the caller
must not substitute stale data for either.
"""
client = await self._get_or_spawn(file_path)
if client is None:
return []
return None
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.save_file(file_path)
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
fresh = await client.wait_for_diagnostics(
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
)
except Exception as e: # noqa: BLE001
logger.debug("open/wait failed for %s: %s", file_path, e)
return []
return None
self._last_used[(client.server_id, client.workspace_root)] = time.time()
return list(client.diagnostics_for(file_path))
if not fresh:
return None
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
ws, gated = resolve_workspace_for_file(file_path)
@ -482,7 +513,7 @@ class LSPService:
client = self._clients.get((srv.server_id, ws))
if client is None:
return []
return list(client.diagnostics_for(file_path))
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
srv = find_server_for_file(file_path)

View file

@ -905,7 +905,114 @@ class MoAChatCompletions:
except Exception as exc: # pragma: no cover - display must never break the turn
logger.debug("MoA reference_callback failed for %s: %s", event, exc)
def prepare(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Run the advisor fan-out and return the exact aggregator request.
The normal agent loop needs to measure this augmented prompt before its
compression gate. ``create()`` also uses this method for direct callers;
when the loop supplies the returned private object back to ``create()``,
the advisor fan-out is not repeated.
"""
return self.create(messages=messages, _moa_prepare_only=True)
def rebase_prepared_request(
self, prepared: dict[str, Any], messages: list[dict[str, Any]]
) -> dict[str, Any]:
"""Apply already-generated advisor guidance to a rebuilt API transcript.
Context compression changes the persisted transcript but not the
ephemeral advisor result. Reusing the guidance avoids a second costly
fan-out while keeping the aggregator request aligned with the compacted
history.
"""
guidance = prepared.get("guidance")
agg_messages = [dict(message) for message in messages]
if guidance:
_attach_reference_guidance(agg_messages, str(guidance))
return {**prepared, "messages": agg_messages}
def _call_prepared_aggregator(
self, prepared: dict[str, Any], api_kwargs: dict[str, Any]
) -> Any:
"""Send an already prepared MoA aggregator request exactly once."""
agg_messages = prepared["messages"]
aggregator = prepared["aggregator"]
aggregator_temperature = prepared["aggregator_temperature"]
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
max_tokens: Any = agg_kwargs.get("max_tokens")
tools: Any = agg_kwargs.get("tools")
extra_body: Any = agg_kwargs.get("extra_body")
# Record the exact aggregator INPUT (incl. the injected reference
# context) into the pending trace so a trace captures what the
# aggregator actually saw, not a reconstruction.
if self._pending_trace is not None:
self._pending_trace["aggregator_input_messages"] = agg_messages
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
# The aggregator is the acting model. Resolve its slot to the provider's
# real runtime (base_url/api_key/api_mode) and call it through the same
# request-building path any model uses — so per-model wire-format
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
# temperature) applies identically to it. MoA imposes no output cap:
# max_tokens is passed through from the caller (normally None → omitted
# → the model's real maximum). The preset's old hardcoded 4096 default
# is gone — it truncated long syntheses.
# When the agent's streaming consumer calls us with stream=True, run the
# references first (above) and then return the aggregator's RAW token
# stream so the acting model's output reaches the user live. The consumer
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
# back to a non-streaming retry on error. The non-streaming path
# (stream=False) is unchanged — no stream/stream_options/timeout are
# forwarded, so its behavior is byte-for-byte identical to before.
stream = bool(api_kwargs.get("stream"))
stream_kwargs: dict[str, Any] = {}
if stream:
stream_kwargs["stream"] = True
stream_kwargs["stream_options"] = (
api_kwargs.get("stream_options") or {"include_usage": True}
)
# Forward the consumer's per-request (stream read) timeout so it
# actually governs the aggregator stream, not just call_llm's default.
if api_kwargs.get("timeout") is not None:
stream_kwargs["timeout"] = api_kwargs["timeout"]
_agg_response = call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
tools=tools,
extra_body=extra_body,
# Prepared requests must retain the acting aggregator's reasoning
# policy exactly as the direct create() path does (#64187).
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)
# Non-streaming path (quiet mode / eval / subagents): the aggregator
# output is available inline, so capture it into the pending trace now.
# Streaming path: the aggregator's raw token stream is returned to the
# consumer live and its acting output lands as the turn's assistant
# message; the trace marks it streamed and points there.
if self._pending_trace is not None:
if stream:
self._pending_trace["aggregator_streamed"] = True
self._pending_trace["aggregator_output"] = None
else:
self._pending_trace["aggregator_streamed"] = False
try:
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
except Exception: # pragma: no cover - defensive
self._pending_trace["aggregator_output"] = None
return _agg_response
def create(self, **api_kwargs: Any) -> Any:
prepared_request = api_kwargs.pop("_moa_prepared_request", None)
if prepared_request is not None:
if not isinstance(prepared_request, dict):
raise TypeError("_moa_prepared_request must be a dict")
return self._call_prepared_aggregator(prepared_request, api_kwargs)
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
@ -1065,6 +1172,7 @@ class MoAChatCompletions:
ref_count=_ref_count,
)
guidance: str | None = None
agg_messages = [dict(m) for m in messages]
if reference_outputs:
joined = "\n\n".join(
@ -1082,69 +1190,15 @@ class MoAChatCompletions:
)
_attach_reference_guidance(agg_messages, guidance)
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
agg_kwargs["messages"] = agg_messages
# Record the exact aggregator INPUT (incl. the injected reference
# context) into the pending trace so a trace captures what the
# aggregator actually saw, not a reconstruction.
if self._pending_trace is not None:
self._pending_trace["aggregator_input_messages"] = agg_messages
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
# The aggregator is the acting model. Resolve its slot to the provider's
# real runtime (base_url/api_key/api_mode) and call it through the same
# request-building path any model uses — so per-model wire-format
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
# temperature) applies identically to it. MoA imposes no output cap:
# max_tokens is passed through from the caller (normally None → omitted
# → the model's real maximum). The preset's old hardcoded 4096 default
# is gone — it truncated long syntheses.
# When the agent's streaming consumer calls us with stream=True, run the
# references first (above) and then return the aggregator's RAW token
# stream so the acting model's output reaches the user live. The consumer
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
# back to a non-streaming retry on error. The non-streaming path
# (stream=False) is unchanged — no stream/stream_options/timeout are
# forwarded, so its behavior is byte-for-byte identical to before.
stream = bool(api_kwargs.get("stream"))
stream_kwargs: dict[str, Any] = {}
if stream:
stream_kwargs["stream"] = True
stream_kwargs["stream_options"] = (
api_kwargs.get("stream_options") or {"include_usage": True}
)
# Forward the consumer's per-request (stream read) timeout so it
# actually governs the aggregator stream, not just call_llm's default.
if api_kwargs.get("timeout") is not None:
stream_kwargs["timeout"] = api_kwargs["timeout"]
_agg_response = call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)
# Non-streaming path (quiet mode / eval / subagents): the aggregator
# output is available inline, so capture it into the pending trace now.
# Streaming path: the aggregator's raw token stream is returned to the
# consumer live and its acting output lands as the turn's assistant
# message; the trace marks it streamed and points there.
if self._pending_trace is not None:
if stream:
self._pending_trace["aggregator_streamed"] = True
self._pending_trace["aggregator_output"] = None
else:
self._pending_trace["aggregator_streamed"] = False
try:
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
except Exception: # pragma: no cover - defensive
self._pending_trace["aggregator_output"] = None
return _agg_response
prepared_request = {
"messages": agg_messages,
"guidance": guidance,
"aggregator": aggregator,
"aggregator_temperature": aggregator_temperature,
}
if api_kwargs.pop("_moa_prepare_only", False):
return prepared_request
return self._call_prepared_aggregator(prepared_request, api_kwargs)
class MoAClient:

View file

@ -4,6 +4,8 @@ Pure utility functions with no AIAgent dependency. Used by ContextCompressor
and run_agent.py for pre-flight context checks.
"""
import base64
import hashlib
import ipaddress
import json
import logging
@ -213,6 +215,7 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-sonnet-5": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
@ -275,8 +278,10 @@ DEFAULT_CONTEXT_LENGTHS = {
# Qwen — specific model families before the catch-all.
# Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
"qwen3.6-plus": 1048576, # 1M context (DashScope/Alibaba & OpenRouter)
"qwen3.7-plus": 1048576, # 1M context (DashScope/Alibaba)
"qwen3-coder-plus": 1000000, # 1M context
"qwen3-coder": 262144, # 256K context
"qwen3-max": 262144, # 256K context (qwen3-max-2026-01-23 snapshot, Coding Plan)
"qwen": 131072,
# MiniMax — M3 is 1M context (max output 512K); M2.x series is 204,800.
# Keys use substring matching (longest-first), so "minimax-m3" wins over
@ -316,7 +321,12 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
# Kimi — K3 ships with a 1 Mi context window (1,048,576; verified against
# models.dev and OpenRouter live metadata, matching the endpoint-scoped
# override in _endpoint_scoped_context_length). Longest-key-first substring
# matching ensures "kimi-k3" resolves to 1M while older/unknown Kimi models
# still hit the generic 256K fallback.
"kimi-k3": 1_048_576,
"kimi": 262144,
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
# so these fallbacks keep token budgeting / compression from probing down
@ -540,7 +550,13 @@ def _is_known_provider_base_url(base_url: str) -> bool:
def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
"""Return metadata confirmed only for one provider endpoint."""
"""Return metadata confirmed only for the Kimi Coding endpoint.
Kimi Coding serves K3 under the bare slug ``k3``, but users may also
configure or select the public-facing aliases ``kimi-k3`` and
``kimi-k3-cot``. Only canonical ``https://api.kimi.com/coding`` endpoints
(legacy Moonshot keys do not serve K3) get the 1 Mi context window.
"""
normalized = _normalize_base_url(base_url)
try:
parsed = urlparse(normalized)
@ -556,7 +572,7 @@ def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
and parsed.path.rstrip("/") in {"/coding", "/coding/v1"}
and not parsed.query
and not parsed.fragment
and model.strip().lower() == "k3"
and model.strip().lower() in {"k3", "kimi-k3", "kimi-k3-cot"}
):
return 1_048_576
return None
@ -567,8 +583,13 @@ def _skip_persistent_context_cache(base_url: str, provider: str) -> bool:
LM Studio excludes caching because loaded context is transient the user
can reload the model with a different context_length at any time.
"""
return provider == "lmstudio"
Codex OAuth excludes caching because its context window is account- and
entitlement-specific metadata supplied by the authenticated /models
endpoint. A fallback value written after a transient probe failure must
not prevent a later live probe from observing an updated allocation.
"""
return (provider or "").strip().lower() in {"lmstudio", "openai-codex"}
def _maybe_cache_local_context_length(
@ -1904,32 +1925,72 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
}
_codex_oauth_context_cache: Dict[str, int] = {}
_codex_oauth_context_cache_time: float = 0.0
_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {}
_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.
def _codex_oauth_token_fingerprint(access_token: str) -> str:
"""Return a non-secret cache key for a Codex OAuth access token."""
return hashlib.sha256(access_token.encode("utf-8")).hexdigest()[:16]
Codex OAuth imposes its own context limits that differ from the direct
OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
`context_window` field in each model entry is the authoritative source.
Returns a ``{slug: context_window}`` dict. Empty on failure.
def _extract_chatgpt_account_id(access_token: str) -> Optional[str]:
"""Extract ``chatgpt_account_id`` from the Codex OAuth JWT.
The Codex ``/backend-api/codex/models`` endpoint returns the per-account
catalog only when the ``ChatGPT-Account-Id`` header is present; without
it, the endpoint returns ``{"models":[]}`` (HTTP 200) and the context
probe falls back to the hardcoded defaults which can be stale or
wrong for the active account's plan. Mirrors the same extraction done
in ``auxiliary_client.py`` for the request path.
Returns ``None`` on any parse error rather than raising, so a bad
token still surfaces as a normal probe failure instead of crashing
the metadata resolver.
"""
global _codex_oauth_context_cache, _codex_oauth_context_cache_time
try:
parts = access_token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
if not isinstance(claims, dict):
return None
acct_id = claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
return acct_id if isinstance(acct_id, str) and acct_id else None
except Exception:
return None
def _fetch_codex_oauth_context_lengths_with_source(
access_token: str,
) -> Tuple[Dict[str, int], bool]:
"""Fetch Codex catalogue data and report whether it came from HTTP.
The in-process cache is scoped by token fingerprint because Codex model
availability and context windows can vary by account entitlement. The raw
token is never retained in the cache key. The boolean is false for a
same-token in-process hit, which must not be treated as a fresh provider
confirmation when deciding whether to update persistent state.
"""
global _codex_oauth_context_cache
now = time.time()
if (
_codex_oauth_context_cache
and now - _codex_oauth_context_cache_time < _CODEX_OAUTH_CONTEXT_CACHE_TTL
):
return _codex_oauth_context_cache
cache_key = _codex_oauth_token_fingerprint(access_token)
cached = _codex_oauth_context_cache.get(cache_key)
if cached is not None:
cached_models, cached_at = cached
if now - cached_at < _CODEX_OAUTH_CONTEXT_CACHE_TTL:
return cached_models, False
headers = {"Authorization": f"Bearer {access_token}"}
acct_id = _extract_chatgpt_account_id(access_token)
if acct_id:
headers["ChatGPT-Account-Id"] = acct_id
try:
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers={"Authorization": f"Bearer {access_token}"},
headers=headers,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
@ -1938,11 +1999,11 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"Codex /models probe returned HTTP %s; falling back to hardcoded defaults",
resp.status_code,
)
return {}
return {}, False
data = resp.json()
except Exception as exc:
logger.debug("Codex /models probe failed: %s", exc)
return {}
return {}, False
entries = data.get("models", []) if isinstance(data, dict) else []
result: Dict[str, int] = {}
@ -1955,32 +2016,50 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
result[slug.strip()] = ctx
if result:
_codex_oauth_context_cache = result
_codex_oauth_context_cache_time = now
_codex_oauth_context_cache[cache_key] = (result, now)
return result, True
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.
Codex OAuth imposes its own context limits that differ from the direct
OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
`context_window` field in each model entry is the authoritative source.
Returns a ``{slug: context_window}`` dict. Empty on failure.
"""
result, _fresh = _fetch_codex_oauth_context_lengths_with_source(access_token)
return result
def _resolve_codex_oauth_context_length(
def _resolve_codex_oauth_context_length_with_source(
model: str, access_token: str = ""
) -> Optional[int]:
) -> Tuple[Optional[int], str]:
"""Resolve a Codex OAuth model's real context window.
Prefers a live probe of chatgpt.com/backend-api/codex/models (when we
have a bearer token), then falls back to ``_CODEX_OAUTH_CONTEXT_FALLBACK``.
Returns ``(context_length, source)`` where source is ``"live"`` for a
value returned by a fresh authenticated endpoint probe, ``"memory"`` for
a same-token in-process catalogue hit, or ``"fallback"`` for the static
conservative table. Only ``"live"`` is eligible for persistent writes.
"""
model_bare = _strip_provider_prefix(model).strip()
if not model_bare:
return None
return None, ""
if access_token:
live = _fetch_codex_oauth_context_lengths(access_token)
live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token)
live_source = "live" if fresh_probe else "memory"
if model_bare in live:
return live[model_bare]
return live[model_bare], live_source
# Case-insensitive match in case casing drifts
model_lower = model_bare.lower()
for slug, ctx in live.items():
if slug.lower() == model_lower:
return ctx
return ctx, live_source
# Fallback: longest-key-first substring match over hardcoded defaults.
model_lower = model_bare.lower()
@ -1988,9 +2067,19 @@ def _resolve_codex_oauth_context_length(
_CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True
):
if slug in model_lower:
return ctx
return ctx, "fallback"
return None
return None, ""
def _resolve_codex_oauth_context_length(
model: str, access_token: str = ""
) -> Optional[int]:
"""Resolve a Codex OAuth model's context length (compatibility wrapper)."""
context_length, _source = _resolve_codex_oauth_context_length_with_source(
model, access_token=access_token,
)
return context_length
def _resolve_nous_context_length(
@ -2080,9 +2169,9 @@ def get_model_context_length(
Resolution order:
0. Explicit config override (model.context_length or custom_providers per-model)
0c. Endpoint-scoped metadata for models validated on one multiplexed endpoint
1. Persistent cache (previously discovered via probing). Nous URLs
bypass the cache here so step 5b can always reconcile against
the authoritative portal /v1/models response.
1. Persistent cache (previously discovered via probing). Nous URLs,
LM Studio, and Codex OAuth bypass the cache here so their provider
metadata can be reconciled against the authoritative live source.
1b. AWS Bedrock static table (must precede custom-endpoint probe)
2. Active endpoint metadata (/models for explicit custom endpoints)
3. Local server query (for local endpoints)
@ -2172,28 +2261,23 @@ def get_model_context_length(
if endpoint_context is not None:
return endpoint_context
is_bedrock_context = provider == "bedrock" or (
base_url
and base_url_hostname(base_url).startswith("bedrock-runtime.")
and base_url_host_matches(base_url, "amazonaws.com")
)
# 1. Check persistent cache (model+provider)
# LM Studio is excluded — its loaded context length is transient (the
# user can reload the model with a different context_length at any time
# via /api/v1/models/load), so a stale cached value would mask reloads.
# Codex OAuth is excluded because the authenticated /models catalogue is
# account-specific and a fallback must never suppress later revalidation.
if base_url and not _skip_persistent_context_cache(base_url, provider):
cached = get_cached_context_length(model, base_url)
if cached is not None:
# Invalidate stale Codex OAuth cache entries: pre-PR #14935 builds
# resolved gpt-5.x to the direct-API value (e.g. 1.05M) via
# models.dev and persisted it. Codex OAuth caps at 272K for every
# slug, so any cached Codex entry at or above 400K is a leftover
# from the old resolution path. Drop it and fall through to the
# live /models probe in step 5 below.
if provider == "openai-codex" and cached >= 400_000:
logger.info(
"Dropping stale Codex cache entry %s@%s -> %s (pre-fix value); "
"re-resolving via live /models probe",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale 32k cache entries for Kimi-family models.
elif cached <= 32768 and _model_name_suggests_kimi(model):
if cached <= 32768 and _model_name_suggests_kimi(model):
logger.info(
"Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); "
"re-resolving via hardcoded defaults",
@ -2240,6 +2324,30 @@ def get_model_context_length(
model, base_url,
)
# Fall through; step 5b reconciles and overwrites if portal responds.
# Invalidate stale Bedrock entries seeded before the Claude 4.6+
# long-context table was corrected to 1M. The static table is a
# FLOOR, not an override: probe-derived cache entries (step 1b)
# may legitimately exceed the table (real window read from
# Bedrock's length-validation error), so only under-reporting
# entries are dropped — never a cached value above the table.
elif is_bedrock_context:
try:
from agent.bedrock_adapter import get_bedrock_context_length
bedrock_ctx = get_bedrock_context_length(model)
if cached < bedrock_ctx:
logger.info(
"Dropping stale Bedrock cache entry %s@%s -> %s; "
"using static Bedrock table value %s",
model,
base_url,
f"{cached:,}",
f"{bedrock_ctx:,}",
)
_invalidate_cached_context_length(model, base_url)
return bedrock_ctx
except ImportError:
pass
return cached
else:
if is_local_endpoint(base_url):
return _reconcile_local_cached_context_length(
@ -2250,22 +2358,50 @@ def get_model_context_length(
# 1b. AWS Bedrock — use static context length table.
# Bedrock's ListFoundationModels API doesn't expose context window sizes,
# so we maintain a curated table in bedrock_adapter.py that reflects
# AWS-imposed limits (e.g. 200K for Claude models vs 1M on the native
# Anthropic API). This must run BEFORE the custom-endpoint probe at
# Bedrock-hosted model limits (e.g. older Claude 4 at 200K; Claude
# Opus/Sonnet 4.6+ at 1M). This must run BEFORE the custom-endpoint probe at
# step 2 — bedrock-runtime.<region>.amazonaws.com is not in
# _URL_TO_PROVIDER, so it would otherwise be treated as a custom endpoint,
# fail the /models probe (Bedrock doesn't expose that shape), and fall
# back to the 128K default before reaching the original step 4b branch.
if provider == "bedrock" or (
base_url
and base_url_hostname(base_url).startswith("bedrock-runtime.")
and base_url_host_matches(base_url, "amazonaws.com")
):
if is_bedrock_context:
try:
from agent.bedrock_adapter import get_bedrock_context_length
return get_bedrock_context_length(model)
from agent.bedrock_adapter import (
get_bedrock_context_length,
resolve_bedrock_region,
)
except ImportError:
pass # boto3 not installed — fall through to generic resolution
else:
# Bedrock does not expose the context window via any metadata API,
# so get_bedrock_context_length() probes the live endpoint (one
# fast, pre-inference length rejection) to read the real window.
# Cache the probe result per model so we pay that cost once, not
# every turn — keyed by base_url when present, else a synthetic
# bedrock:// key so display/offline paths share the entry.
cache_key_url = base_url or "bedrock://"
cached = get_cached_context_length(model, cache_key_url)
if cached is not None:
return cached
# Resolve region from the base_url host first, then the standard
# AWS region chain. An empty region disables probing (table only).
region = ""
if base_url:
_m = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url)
if _m:
region = _m.group(1)
if not region:
try:
region = resolve_bedrock_region()
except Exception:
region = ""
ctx = get_bedrock_context_length(model, region=region, probe=bool(region))
if ctx and region:
# Only persist probe-derived values (region present); a pure
# table fallback shouldn't poison the cache against a later
# successful probe.
save_context_length(model, cache_key_url, ctx)
return ctx
if provider == "novita" or (base_url and base_url_host_matches(base_url, "api.novita.ai")):
ctx = _resolve_endpoint_context_length(model, base_url or "https://api.novita.ai/openai/v1", api_key=api_key)
@ -2380,9 +2516,14 @@ def get_model_context_length(
# Codex OAuth enforces lower context limits than the direct OpenAI
# API for the same slug (e.g. gpt-5.5 is 1.05M on the API but 272K
# on Codex). Authoritative source is Codex's own /models endpoint.
codex_ctx = _resolve_codex_oauth_context_length(model, access_token=api_key or "")
codex_ctx, codex_source = _resolve_codex_oauth_context_length_with_source(
model, access_token=api_key or "",
)
if codex_ctx:
if base_url:
# Only a successful authenticated catalogue response is safe to
# persist. The static fallback is deliberately runtime-only so a
# transient OAuth/network failure cannot poison future probes.
if base_url and codex_source == "live":
save_context_length(model, base_url, codex_ctx)
return codex_ctx
if effective_provider == "gmi" and base_url:
@ -2525,16 +2666,61 @@ async def get_model_context_length_async(
)
def _is_cjk_token_dense_char(ch: str) -> bool:
code = ord(ch)
return (
0x1100 <= code <= 0x11FF # Hangul Jamo
or 0x2E80 <= code <= 0x9FFF # CJK radicals/ideographs
or 0xA960 <= code <= 0xA97F # Hangul Jamo Extended-A
or 0xAC00 <= code <= 0xD7AF # Hangul Syllables
or 0xF900 <= code <= 0xFAFF # CJK compatibility ideographs
or 0xFF00 <= code <= 0xFFEF # Fullwidth forms / halfwidth kana
)
# Same codepoint ranges as _is_cjk_token_dense_char, as a compiled character
# class so dense-char counting runs in C (``len(text) - len(re.sub(...))``)
# instead of a per-char Python loop. MUST stay in sync with
# _is_cjk_token_dense_char.
_CJK_DENSE_RE = re.compile(
"[\u1100-\u11ff" # Hangul Jamo
"\u2e80-\u9fff" # CJK radicals/ideographs
"\ua960-\ua97f" # Hangul Jamo Extended-A
"\uac00-\ud7af" # Hangul Syllables
"\uf900-\ufaff" # CJK compatibility ideographs
"\uff00-\uffef]" # Fullwidth forms / halfwidth kana
)
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate (~4 chars/token) for pre-flight checks.
"""Rough token estimate for pre-flight checks.
Uses ceiling division so short texts (1-3 chars) never estimate as
0 tokens, which would cause the compressor and pre-flight checks to
systematically undercount when many short tool results are present.
CJK/Hangul/Kana text is much denser than English under common LLM
tokenizers, so count those codepoints as roughly one token each instead
of applying the English-centric ~4 chars/token rule.
Perf: this runs on every message in every preflight/compaction walk,
including MB-scale tool outputs, so the common all-ASCII case must stay
O(1). ``str.isascii()`` is a flag check on CPython's compact unicode
representation (no scan), and the CJK counting itself is a single
C-level ``re.findall`` rather than a per-character Python loop.
"""
if not text:
return 0
return (len(text) + 3) // 4
text = str(text)
if text.isascii():
# O(1) fast path — ASCII text cannot contain token-dense CJK chars.
return (len(text) + 3) // 4
dense = len(text) - len(_CJK_DENSE_RE.sub("", text))
if not dense:
# Non-ASCII but no CJK (accents, Cyrillic, emoji, ...): keep the
# classic ~4 chars/token rule.
return (len(text) + 3) // 4
sparse = len(text) - dense
return dense + ((sparse + 3) // 4)
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
@ -2546,12 +2732,12 @@ def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
estimated at ~250K tokens and trigger premature context compression.
"""
_IMAGE_TOKEN_COST = 1500
total_chars = 0
text_tokens = 0
image_tokens = 0
for msg in messages:
total_chars += _estimate_message_chars(msg)
text_tokens += _estimate_message_tokens_without_images(msg)
image_tokens += _count_image_tokens(msg, _IMAGE_TOKEN_COST)
return ((total_chars + 3) // 4) + image_tokens
return text_tokens + image_tokens
def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
@ -2613,6 +2799,35 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int:
return len(str(shadow))
def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int:
"""Token estimate for a message shadow with image payloads stripped."""
if not isinstance(msg, dict):
return estimate_tokens_rough(str(msg))
shadow: Dict[str, Any] = {}
for k, v in msg.items():
if k == "_anthropic_content_blocks":
continue
if k == "content":
if isinstance(v, list):
cleaned = []
for part in v:
if isinstance(part, dict):
if part.get("type") in {"image", "image_url", "input_image"}:
cleaned.append({"type": part.get("type"), "image": "[stripped]"})
else:
cleaned.append(part)
else:
cleaned.append(part)
shadow[k] = cleaned
elif isinstance(v, dict) and v.get("_multimodal"):
shadow[k] = v.get("text_summary", "")
else:
shadow[k] = v
else:
shadow[k] = v
return estimate_tokens_rough(str(shadow))
def estimate_request_tokens_rough(
messages: List[Dict[str, Any]],
*,
@ -2629,7 +2844,7 @@ def estimate_request_tokens_rough(
"""
total = 0
if system_prompt:
total += (len(system_prompt) + 3) // 4
total += estimate_tokens_rough(system_prompt)
if messages:
total += estimate_messages_tokens_rough(messages)
if tools:

View file

@ -15,6 +15,9 @@ and MoonshotAI/kimi-cli#1595:
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
the parent. Presence of both causes "type should be defined in anyOf
items instead of the parent schema".
3. Every object schema must carry a ``required`` array, even an empty one.
Standard JSON Schema allows omitting it; Moonshot 400s with
"required must be an array".
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
@ -130,9 +133,32 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
else:
repaired.pop("enum")
# Rule 4: object schemas must carry a `required` array, even when empty.
if repaired.get("type") == "object":
repaired = _ensure_required_array(repaired)
return repaired
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
"""Guarantee an object schema carries a ``required`` array (Moonshot rule).
Standard JSON Schema lets you omit ``required`` when nothing is required;
Moonshot 400s on that ("required must be an array"). Ensure the key is a
list. When ``properties`` is known, prune ``required`` entries that don't
name a real property defensive against dangling names, which Moonshot
also rejects. Mutates and returns ``node``.
"""
props = node.get("properties")
req = node.get("required")
if isinstance(req, list):
if isinstance(props, dict):
node["required"] = [r for r in req if r in props]
else:
node["required"] = []
return node
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
node_type = node.get("type")
@ -174,17 +200,18 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
applied. Input is not mutated.
"""
if not isinstance(parameters, dict):
return {"type": "object", "properties": {}}
return {"type": "object", "properties": {}, "required": []}
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
if not isinstance(repaired, dict):
return {"type": "object", "properties": {}}
return {"type": "object", "properties": {}, "required": []}
# Top-level must be an object schema
if repaired.get("type") != "object":
repaired["type"] = "object"
if "properties" not in repaired:
repaired["properties"] = {}
_ensure_required_array(repaired)
return repaired
@ -232,6 +259,10 @@ def is_moonshot_model(model: str | None) -> bool:
tail = bare.rsplit("/", 1)[-1]
if tail.startswith("kimi-") or tail == "kimi":
return True
# Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus dated /
# suffixed variants like ``k3.1`` or ``k3-turbo``).
if tail == "k3" or tail.startswith(("k3.", "k3-")):
return True
# Vendor-prefixed forms commonly used on aggregators
if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"):
return True

View file

@ -52,6 +52,13 @@ def busy_input_hint_gateway(mode: str) -> str:
"Send `/busy interrupt` or `/busy queue` to change this, or "
"`/busy status` to check. This notice won't appear again."
)
if mode == "redirect":
return (
"💡 First-time tip — I redirected the current run using your message. "
"Completed work stays in context, and `/stop` still cancels the task. "
"Send `/busy queue` to wait for a separate turn, or `/busy status` "
"to check. This notice won't appear again."
)
return (
"💡 First-time tip — I just interrupted my current task to answer you. "
"Send `/busy queue` to queue follow-ups for after the current task instead, "
@ -74,6 +81,12 @@ def busy_input_hint_cli(mode: str) -> str:
"after the next tool call. Use /busy interrupt or /busy queue to "
"change this. This tip only shows once."
)
if mode == "redirect":
return (
"(tip) Your correction redirected the current run without discarding "
"completed work. Use /stop to cancel or /busy queue to wait for a "
"separate turn. This tip only shows once."
)
return (
"(tip) Your message interrupted the current run. "
"Use /busy queue to queue messages for the next turn instead, "

View file

@ -58,6 +58,14 @@ def _scan_context_content(content: str, filename: str) -> str:
BLOCKED at this layer because the file would otherwise enter the
system prompt verbatim and the user has no chance to intervene.
"""
# Editors (Windows Notepad, PowerShell Out-File without -Encoding
# utf8NoBOM, some VS Code profiles) prefix a UTF-8 BOM as an encoding
# artifact, not a prompt injection. Strip a leading U+FEFF silently so a
# context file (SOUL.md, AGENTS.md, ...) is not blocked wholesale; BOMs
# elsewhere in the content remain subject to the threat scan below.
if content.startswith("\ufeff"):
content = content[1:]
findings = _scan_for_threats(content, scope="context")
if findings:
logger.warning("Context file %s blocked: %s", filename, ", ".join(findings))
@ -549,6 +557,29 @@ def computer_use_guidance(platform_name: Optional[str] = None) -> str:
"4. After any state-changing action, re-capture to verify. You can "
"pass `capture_after=true` to get the follow-up screenshot in one "
"round-trip.\n\n"
"## Verify → escalate ladder (background-first, NOT background-only)\n"
"Background delivery is the DEFAULT and the co-work path, but it is "
"the first rung, not the only one. Read each action's structured "
"result and climb only when the driver tells you to:\n"
"- `effect: 'confirmed'` + `verified: true` — the driver read the "
"result back. Done.\n"
"- `effect: 'unverifiable'` — the input was delivered but the driver "
"can't confirm it. Re-capture and check the screenshot/tree yourself "
"before deciding it worked.\n"
"- `effect: 'suspected_noop'`, `code: 'background_unavailable'`, or an "
"`escalation.recommended` field — the action did NOT land. Follow "
"`escalation.recommended`:\n"
" - `'px'` → re-issue addressing the target by `coordinate=[x,y]` "
"read off the screenshot instead of `element`.\n"
" - `'foreground'` (or a pixel click still didn't land) → re-issue "
"the SAME action with `delivery_mode='foreground'`. This briefly "
"raises the window; it needs its own approval and is only appropriate "
"when the user isn't actively working. Common for Electron/Chromium "
"consent dialogs, DirectInput games, and raw-input canvases.\n"
"- Escalate to foreground as a REACTION to a returned signal, never "
"as a prediction from the app being Electron/Chromium/GTK. Do not "
"silently retry the same rung expecting a different result, and do "
"not conclude 'cua-driver can't drive this app' — climb the ladder.\n\n"
"## Background mode rules\n"
"- Do NOT use `raise_window=true` on `focus_app` unless the user "
"explicitly asked you to bring a window to front. Input routing to "
@ -774,8 +805,19 @@ PLATFORM_HINTS = {
),
"matrix": (
"You are in a Matrix room communicating with your user. "
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
"the adapter converts your Markdown to HTML for rich display. "
"The adapter converts your Markdown to HTML for rich display — bold, "
"italic, inline code, fenced code blocks, headings, bullet and "
"numbered lists, blockquotes, and links all render.\n\n"
"Do NOT use Markdown tables: many popular Matrix clients (Element X, "
"Beeper, most mobile apps) do not render HTML tables, so the cells "
"collapse into one continuous run of text. Present tabular data as "
"labeled '**Label:** value' lines or bullet lists instead.\n\n"
"Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes "
"(- [ ] / - [x]) — they are not converted and appear as literal "
"characters.\n\n"
"LINKS: prefer [descriptive link text](url) over bare URLs. When "
"referencing something with an associated URL (events, sources, "
"people), make the name a clickable link.\n\n"
"You can send media files natively: include MEDIA:/absolute/path/to/file "
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "

View file

@ -102,6 +102,7 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# ``claude-opus-4`` so non-thinking Claude 3.x or future
# non-reasoning Claude variants don't match.
("claude-opus-4", 240),
("claude-sonnet-5", 180),
("claude-sonnet-4.5", 180),
("claude-sonnet-4.6", 180),
# xAI Grok reasoning variants. Explicit reasoning-only keys
@ -111,6 +112,7 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# non-reasoning pairs.
("grok-4-fast-reasoning", 300),
("grok-4.20-reasoning", 300),
("grok-4.5", 300),
("grok-4-fast-non-reasoning", 180),
)

View file

@ -11,6 +11,7 @@ import logging
import os
import re
import shlex
from urllib.parse import unquote_plus
logger = logging.getLogger(__name__)
@ -285,6 +286,22 @@ _URL_USERINFO_RE = re.compile(
r"(https?|wss?|ftp)://([^/\s:@]+):([^/\s@]+)@",
)
# Strict provider-egress URL redaction accepts more URL-reference forms than
# the display/log helpers above. Parameter delimiters stay in capture groups so
# redaction preserves the original query/fragment layout byte-for-byte, while
# the key is decoded separately for classification. Values stop at query or
# fragment pair separators; both ``&`` and ``;`` are valid in deployed URLs.
_STRICT_URL_PARAM_RE = re.compile(
r"([?#&;])([A-Za-z0-9_.~+%\-]+)=([^#&;\s\"'<>]*)"
)
# Match userinfo in both absolute (``scheme://user:pass@host``) and
# network-path (``//user:pass@host``) references. The authority boundary stops
# at path/query/fragment delimiters so an ``@`` elsewhere in a URL is ignored.
_STRICT_URL_USERINFO_RE = re.compile(
r"((?:[A-Za-z][A-Za-z0-9+.-]*:)?//)([^/\s?#@]+)@"
)
# HTTP access logs often use a relative request target rather than a full URL:
# `"POST /webhook?password=... HTTP/1.1"`. The full-URL redactor above only
# sees strings containing `://`, so handle request-target query strings too.
@ -411,6 +428,41 @@ def _redact_url_userinfo(text: str) -> str:
)
def _canonical_url_param_name(name: str) -> str:
"""Decode a URL parameter name for bounded, case-insensitive matching."""
decoded = name
for _ in range(3):
next_value = unquote_plus(decoded)
if next_value == decoded:
break
decoded = next_value
return decoded.casefold().replace("-", "_")
def _redact_strict_url_credentials(text: str) -> str:
"""Redact credentials from absolute, relative, and network URL references.
This is intentionally stricter than display/log redaction and is used only
at explicit secret-egress boundaries. It preserves original keys,
separators, public parameters, hosts, and paths while masking sensitive
values and URL userinfo.
"""
def _redact_param(match: re.Match) -> str:
if _canonical_url_param_name(match.group(2)) not in _SENSITIVE_QUERY_PARAMS:
return match.group(0)
return f"{match.group(1)}{match.group(2)}=***"
def _redact_userinfo(match: re.Match) -> str:
userinfo = match.group(2)
if ":" in userinfo:
username, _, _password = userinfo.partition(":")
return f"{match.group(1)}{username}:***@"
return f"{match.group(1)}***@"
text = _STRICT_URL_PARAM_RE.sub(_redact_param, text)
return _STRICT_URL_USERINFO_RE.sub(_redact_userinfo, text)
def redact_cdp_url(value: object) -> str:
"""Mask secrets in a CDP/browser endpoint URL before it is logged.
@ -494,6 +546,7 @@ def redact_sensitive_text(
force: bool = False,
code_file: bool = False,
file_read: bool = False,
redact_url_credentials: bool = False,
) -> str:
"""Apply all redaction patterns to a block of text.
@ -502,6 +555,11 @@ def redact_sensitive_text(
Set force=True for safety boundaries that must never return raw secrets
regardless of the user's global logging redaction preference.
Set redact_url_credentials=True at non-navigation egress boundaries to
additionally redact credential-named query parameters and ``user:pass@``
URL userinfo. The default remains False because actionable OAuth callback,
magic-link, and pre-signed URLs must survive ordinary tool flows unchanged.
Set code_file=True to skip the ENV-assignment and JSON-field regex
patterns when the text is known to be source code (e.g. MAX_TOKENS=***
constants, "apiKey": "test" fixtures). Prefix patterns, auth headers,
@ -666,6 +724,9 @@ def redact_sensitive_text(
# string), so masking it can't break a skill. The ``user:pass@`` form is
# left to pass through per #34029.
if redact_url_credentials:
text = _redact_strict_url_credentials(text)
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
if "&" in text and "=" in text:
text = _redact_form_body(text)

View file

@ -22,6 +22,7 @@ from typing import Any, Dict, List
from agent.tool_dispatch_helpers import make_tool_result_message
from agent.tool_result_classification import tool_may_have_side_effect
from agent.turn_context import drop_stale_api_content
logger = logging.getLogger(__name__)
@ -311,6 +312,11 @@ def strip_stale_dangerous_confirmations(
)
redacted = dict(msg)
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
# Drop the api_content sidecar: it carries the exact bytes
# previously sent — i.e. the dangerous confirmation this
# redaction exists to expire. Replaying it verbatim would
# undo the redaction on the wire.
drop_stale_api_content(redacted)
cleaned.append(redacted)
continue
cleaned.append(msg)

View file

@ -127,10 +127,16 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
1. Genuinely-global vars (``_is_global_env``) always read ``os.environ``
they are deployment settings, not profile secrets.
2. When a secret scope is installed (multiplexed turn), read from it; an
absent key returns ``default``. The scope is authoritative we do NOT
fall through to ``os.environ``, because in a multiplexer ``os.environ``
may hold another profile's value.
2. When a secret scope is installed (multiplexed turn), read from it. Under
multiplexing the scope is authoritative an absent key returns
``default`` and we do NOT fall through to ``os.environ``, because in a
multiplexer ``os.environ`` may hold another profile's value. When
multiplexing is OFF, a scope miss falls through to ``os.environ``:
single-profile deployments legitimately provide credentials via the
process environment (systemd ``Environment=``, secret-manager wrappers
like ``pass-cli run`` / ``op run``, plain shell exports) rather than
``<home>/.env``, and the scope installed unconditionally around e.g.
every cron job must stay a ``.env`` overlay, not a blindfold.
3. No scope installed:
- multiplex INACTIVE (default deployment): read ``os.environ``
identical to the legacy ``os.getenv`` behavior every caller had before.
@ -144,6 +150,17 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
scope = _SECRET_SCOPE.get()
if scope is not None:
val = scope.get(name)
if val is not None:
return val
if _MULTIPLEX_ACTIVE:
return default
# Multiplex off: the scope is an overlay over the process environment,
# not an isolation boundary — there is no other profile to leak from.
# Without this fallthrough, credentials injected only into the process
# environment vanish inside any set_secret_scope(...) block (the cron
# scheduler installs one around every job), so cron jobs send a
# placeholder API key and 401 while interactive turns keep working.
val = os.environ.get(name)
return val if val is not None else default
if _MULTIPLEX_ACTIVE:
@ -201,5 +218,18 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
global vars are intentionally NOT copied in ``get_secret`` reads those
from ``os.environ`` directly, so the scope holds only profile secrets.
"""
return load_env_file(Path(hermes_home) / ".env")
home = Path(hermes_home)
secrets = load_env_file(home / ".env")
try:
from hermes_cli.env_loader import get_secret_source_values
external_secrets = get_secret_source_values(home)
except Exception:
external_secrets = {}
for key, value in external_secrets.items():
if _is_global_env(key):
continue
secrets[key] = value
return secrets

View file

@ -190,6 +190,45 @@ class SecretSource(ABC):
"""
return {}
def remediation(self, kind: Optional["ErrorKind"], cfg: dict) -> str:
"""One-line, actionable next step for a failed fetch.
Called by the startup status printer (and ``hermes secrets ...
status``) right after a fetch error is surfaced, so the user sees
*what to run* next to fix it not just what broke. Sources
should override this to point at their own CLI verbs (e.g.
``hermes secrets bitwarden token`` for AUTH_FAILED). Return an
empty string to suppress the hint.
Must never raise and must not perform I/O it's a pure
kindstring mapping on the startup path.
"""
generic = {
ErrorKind.NOT_CONFIGURED: (
f"Run `hermes secrets {self.name} setup` to finish configuration."
),
ErrorKind.BINARY_MISSING: (
f"Run `hermes secrets {self.name} setup` to install the helper CLI."
),
ErrorKind.AUTH_FAILED: (
f"Credentials rejected — run `hermes secrets {self.name} setup` "
"to re-authenticate."
),
ErrorKind.AUTH_EXPIRED: (
f"Credentials expired — run `hermes secrets {self.name} setup` "
"to re-authenticate."
),
ErrorKind.NETWORK: (
"Network problem reaching the secrets backend — check "
"connectivity and retry."
),
ErrorKind.TIMEOUT: (
f"Backend was slow — raise secrets.{self.name}.timeout_seconds "
"if this recurs."
),
}
return generic.get(kind, "") if kind is not None else ""
# ---------------------------------------------------------------------------
# Shared helpers — use these instead of hand-rolling per backend

View file

@ -29,11 +29,13 @@ is easier to lazy-install than a wheels-with-Rust-extension dependency.
from __future__ import annotations
import base64
import hashlib
import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
@ -45,6 +47,10 @@ import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from agent.secret_sources._cache import (
CachedFetch as _CachedFetch,
DiskCache,
@ -91,6 +97,9 @@ _CACHE: Dict[_CacheKey, _CachedFetch] = {}
# accidentally commit BSM-sourced secrets. The atomic-write/0600/TTL mechanics
# live in agent.secret_sources._cache.DiskCache, shared with the other backends.
_DISK_CACHE_BASENAME = "bws_cache.json"
_ENCRYPTED_CACHE_BASENAME = "bws_cache.enc.json"
_ENCRYPTED_CACHE_VERSION = 1
_ENCRYPTED_CACHE_INFO = b"hermes-bws-encrypted-cache-v1"
def _cache_key_str(cache_key: _CacheKey) -> str:
@ -113,6 +122,13 @@ def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
return _DISK_CACHE.path(home_path)
def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Return the encrypted disk cache path under hermes_home/cache/."""
from agent.secret_sources._cache import resolve_cache_home
return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME
# ---------------------------------------------------------------------------
# Binary discovery + lazy install
# ---------------------------------------------------------------------------
@ -348,6 +364,134 @@ def _token_fingerprint(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
def _b64e(raw: bytes) -> str:
return base64.b64encode(raw).decode("ascii")
def _b64d(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"), validate=True)
def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes:
"""Derive the local cache encryption key from the bootstrap BWS token."""
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=_ENCRYPTED_CACHE_INFO,
).derive(access_token.encode("utf-8"))
def _write_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
entry: _CachedFetch,
home_path: Optional[Path] = None,
) -> None:
"""Persist an encrypted last-good cache entry atomically.
Best-effort by design: cache write failure must never block a fresh BWS
fetch. The raw BWS access token is not stored; it only derives the AES key.
"""
path = _encrypted_disk_cache_path(home_path)
try:
cache_dir = path.parent
cache_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(cache_dir, 0o700)
except OSError:
pass
salt = os.urandom(16)
nonce = os.urandom(12)
serialized_key = _cache_key_str(cache_key)
key = _derive_encrypted_cache_key(access_token, salt)
plaintext = json.dumps(
{"secrets": entry.secrets, "fetched_at": entry.fetched_at},
separators=(",", ":"),
).encode("utf-8")
ciphertext = AESGCM(key).encrypt(
nonce, plaintext, serialized_key.encode("utf-8")
)
payload = {
"version": _ENCRYPTED_CACHE_VERSION,
"key": serialized_key,
"salt": _b64e(salt),
"nonce": _b64e(nonce),
"ciphertext": _b64e(ciphertext),
}
fd, tmp = tempfile.mkstemp(
prefix=".bws_cache_enc_", suffix=".tmp", dir=str(cache_dir)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
# A successful encrypted write completes migration; remove the
# legacy plaintext cache so stale secrets cannot remain on disk.
try:
_disk_cache_path(home_path).unlink()
except FileNotFoundError:
pass
except OSError:
pass
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception: # noqa: BLE001 — best-effort cache only
return
def _read_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
max_age_seconds: float,
home_path: Optional[Path] = None,
) -> Optional[_CachedFetch]:
"""Return a decrypted encrypted-cache entry if it matches and is in-window."""
if max_age_seconds <= 0:
return None
path = _encrypted_disk_cache_path(home_path)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return None
serialized_key = _cache_key_str(cache_key)
if payload.get("version") != _ENCRYPTED_CACHE_VERSION:
return None
if payload.get("key") != serialized_key:
return None
salt = _b64d(str(payload.get("salt", "")))
nonce = _b64d(str(payload.get("nonce", "")))
ciphertext = _b64d(str(payload.get("ciphertext", "")))
key = _derive_encrypted_cache_key(access_token, salt)
raw = AESGCM(key).decrypt(
nonce, ciphertext, serialized_key.encode("utf-8")
)
inner = json.loads(raw.decode("utf-8"))
if not isinstance(inner, dict):
return None
secrets = inner.get("secrets")
inner_fetched_at = inner.get("fetched_at")
if not isinstance(secrets, dict) or not isinstance(inner_fetched_at, (int, float)):
return None
entry_age = time.time() - float(inner_fetched_at)
if entry_age < 0 or entry_age > max_age_seconds:
return None
typed = {
k: v for k, v in secrets.items()
if isinstance(k, str) and isinstance(v, str)
}
return _CachedFetch(secrets=typed, fetched_at=float(inner_fetched_at))
except Exception: # noqa: BLE001 — cache miss on parse/decrypt/I/O errors
return None
def fetch_bitwarden_secrets(
*,
access_token: str,
@ -357,6 +501,8 @@ def fetch_bitwarden_secrets(
use_cache: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> Tuple[Dict[str, str], List[str]]:
"""Pull the secrets for ``project_id`` from Bitwarden Secrets Manager.
@ -368,12 +514,13 @@ def fetch_bitwarden_secrets(
(``https://vault.bitwarden.com``, US Cloud). This is plumbed into
the subprocess as ``BWS_SERVER_URL``.
Caching is a two-layer LRU: an in-process dict (for hot-reload paths
inside one process) and a disk-persisted JSON file under
``<hermes_home>/cache/bws_cache.json`` (for back-to-back CLI invocations).
Both share the same TTL. Pass ``home_path`` so disk cache lookups find
the right directory in tests / non-standard installs; otherwise we fall
back to ``$HERMES_HOME`` / ``~/.hermes``.
``cache_ttl_seconds`` controls the normal fresh cache. When
``encrypted_cache_enabled`` is true, fresh cache entries are written as
AES-GCM encrypted JSON instead of plaintext, and a last-good encrypted
entry may be used after NETWORK/TIMEOUT failures for up to
``encrypted_cache_max_stale_seconds``. This stale fallback is separate
from the fresh-cache TTL so operators can set ``cache_ttl_seconds: 0``
while still keeping an encrypted break-glass cache for offline startup.
Raises :class:`RuntimeError` for fatal conditions (missing binary,
auth failure, unparseable output). Callers in the env_loader path
@ -386,12 +533,20 @@ def fetch_bitwarden_secrets(
raise RuntimeError("Bitwarden project_id is empty")
cache_key = (_token_fingerprint(access_token), project_id, server_url or "")
if use_cache:
if use_cache and cache_ttl_seconds > 0:
cached = _CACHE.get(cache_key)
if cached and cached.is_fresh(cache_ttl_seconds):
return cached.secrets, []
# L2: disk cache. ~5ms on cache hit vs ~380ms for `bws secret list`.
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if encrypted_cache_enabled:
disk_cached = _read_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
max_age_seconds=cache_ttl_seconds,
home_path=home_path,
)
else:
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into in-process cache so subsequent fetches in the
# same process skip the disk read too.
@ -407,14 +562,107 @@ def fetch_bitwarden_secrets(
"`hermes secrets bitwarden setup`."
)
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
try:
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
except RuntimeError as exc:
# Live fetch failed. Fall back to a stale disk cache ONLY for
# transport-level failures (network down, DNS error, transient BWS
# outage / timeout) — never for AUTH_FAILED or a malformed-output
# INTERNAL error, where serving old secrets would mask a real
# config/credential problem the caller needs to see. Without this
# fallback a fleet of bots sharing one BWS project all stop working
# on a single network blip.
#
# Two fallback tiers share the transport-only gate:
# * encrypted cache (opt-in) — AES-GCM payload keyed off the
# bootstrap token, with its own max_stale_seconds window. When
# enabled it is the ONLY fallback consulted: the whole point is
# that the at-rest payload is never plaintext, so we don't
# quietly serve the plaintext file alongside it.
# * plaintext disk cache (default) — the ordinary DiskCache file.
# `cache_ttl_seconds <= 0` means the caller opted out of caching
# entirely (DiskCache.read/write both short-circuit on it) —
# honor that on the fallback path too. `ttl_seconds=inf` on the
# read bypasses freshness (we explicitly want a stale hit); the
# caller's real TTL gates whether we even attempt the read.
kind = _classify_bws_error(str(exc))
if use_cache and kind in (ErrorKind.NETWORK, ErrorKind.TIMEOUT):
if encrypted_cache_enabled:
stale = _read_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
max_age_seconds=encrypted_cache_max_stale_seconds,
home_path=home_path,
)
if stale is not None:
age = max(0.0, time.time() - stale.fetched_at)
_CACHE[cache_key] = stale
return stale.secrets, [
f"bws live fetch failed ({exc}); falling back to "
f"stale ENCRYPTED disk cache ({int(age)}s old)"
]
elif cache_ttl_seconds > 0:
stale = _DISK_CACHE.read(cache_key, float("inf"), home_path)
if stale is not None:
age = max(0.0, time.time() - stale.fetched_at)
_CACHE[cache_key] = stale
return stale.secrets, [
f"bws live fetch failed ({exc}); "
f"falling back to stale disk cache ({int(age)}s old)"
]
raise
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
_CACHE[cache_key] = entry
if use_cache:
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
if cache_ttl_seconds > 0:
_CACHE[cache_key] = entry
if encrypted_cache_enabled:
# Encryption is the storage policy; max_stale_seconds only controls
# whether an outage may consume the last-good entry. Never fall
# back to the plaintext cache just because stale fallback is off.
_write_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
entry=entry,
home_path=home_path,
)
elif cache_ttl_seconds > 0:
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
def _summarize_bws_stderr(raw: str) -> str:
"""Reduce a bws (Rust color-eyre) error dump to its cause line(s).
bws failures look like::
Error:
0: Received error message from server: [400 Bad Request] {"error":"invalid_client"}
Location:
crates/bws/src/main.rs:108
...
Everything from ``Location:`` on is diagnostic noise for a Hermes
user. Keep the numbered cause lines (joined), drop the rest, and
fall back to the stripped raw text when the shape is unrecognized.
"""
text = raw.replace("\x1b", "").strip()
if not text:
return text
causes: List[str] = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith(("Location:", "Backtrace omitted", "Run with ")):
break
if stripped in ("", "Error:"):
continue
# Cause lines are numbered "0: ...", "1: ..." — strip the index.
stripped = re.sub(r"^\d+:\s*", "", stripped)
if stripped:
causes.append(stripped)
return "; ".join(causes) if causes else text
def _run_bws_list(
bws: Path, access_token: str, project_id: str, server_url: str = ""
) -> Tuple[Dict[str, str], List[str]]:
@ -448,9 +696,11 @@ def _run_bws_list(
raise RuntimeError(f"failed to invoke bws: {exc}") from exc
if proc.returncode != 0:
# bws writes auth/network errors to stderr in plain English.
# Strip ANSI just in case and surface the first 200 chars.
err = (proc.stderr or proc.stdout or "").strip().replace("\x1b", "")
# bws writes auth/network errors to stderr as a Rust error-report
# dump (color-eyre): an "Error:" header, indented cause lines, then
# "Location:" / "Backtrace omitted" noise. Strip ANSI and boil it
# down to the meaningful cause line(s) before surfacing.
err = _summarize_bws_stderr(proc.stderr or proc.stdout or "")
raise RuntimeError(
f"bws exited {proc.returncode}: {err[:200]}"
)
@ -502,6 +752,8 @@ def apply_bitwarden_secrets(
auto_install: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> FetchResult:
"""Pull secrets from BSM and set them on ``os.environ``.
@ -553,6 +805,8 @@ def apply_bitwarden_secrets(
cache_ttl_seconds=cache_ttl_seconds,
server_url=server_url,
home_path=home_path,
encrypted_cache_enabled=encrypted_cache_enabled,
encrypted_cache_max_stale_seconds=encrypted_cache_max_stale_seconds,
)
except RuntimeError as exc:
result.error = str(exc)
@ -622,9 +876,16 @@ class BitwardenSource(SecretSource):
},
"project_id": {"description": "BSM project UUID", "default": ""},
"cache_ttl_seconds": {
"description": "Disk+memory cache TTL; 0 disables",
"description": "Fresh disk+memory cache TTL; 0 disables fresh-cache reuse",
"default": 300,
},
"encrypted_cache": {
"description": "Encrypted last-good cache for network/timeout fallback",
"default": {
"enabled": False,
"max_stale_seconds": 0,
},
},
"override_existing": {
"description": "BSM values overwrite .env/shell values",
"default": True,
@ -678,6 +939,14 @@ class BitwardenSource(SecretSource):
except (TypeError, ValueError):
ttl = 300.0
encrypted_cfg = cfg.get("encrypted_cache")
encrypted_cfg = encrypted_cfg if isinstance(encrypted_cfg, dict) else {}
encrypted_enabled = bool(encrypted_cfg.get("enabled", False))
try:
encrypted_max_stale = float(encrypted_cfg.get("max_stale_seconds", 0))
except (TypeError, ValueError):
encrypted_max_stale = 0.0
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
@ -686,16 +955,36 @@ class BitwardenSource(SecretSource):
cache_ttl_seconds=ttl,
server_url=str(cfg.get("server_url", "") or "").strip(),
home_path=home_path,
encrypted_cache_enabled=encrypted_enabled,
encrypted_cache_max_stale_seconds=encrypted_max_stale,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = _classify_bws_error(str(exc))
if result.error_kind == ErrorKind.AUTH_FAILED:
# Translate the raw OAuth reject into what it actually means
# for the user before the mechanics.
result.error = (
"Bitwarden rejected the machine-account access token "
f"({access_token_env}) — it was likely revoked, expired, "
f"or belongs to another region. ({result.error})"
)
return result
result.secrets = secrets
result.warnings.extend(warnings)
return result
def remediation(self, kind, cfg: dict) -> str:
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
return (
"Run `hermes secrets bitwarden token` to paste a fresh access "
"token (create one in the Bitwarden web app: Secrets Manager → "
"Machine accounts → Access tokens). Wrong region? Re-run "
"`hermes secrets bitwarden setup` and pick EU/self-hosted."
)
return super().remediation(kind, cfg)
def _classify_bws_error(message: str) -> ErrorKind:
"""Best-effort mapping of bws failure text onto the shared taxonomy."""
@ -705,7 +994,13 @@ def _classify_bws_error(message: str) -> ErrorKind:
if "binary not available" in lowered or "failed to invoke" in lowered:
return ErrorKind.BINARY_MISSING
if any(tok in lowered for tok in ("unauthorized", "invalid token",
"access token", "401", "403")):
"access token", "401", "403",
# The BSM identity endpoint rejects a
# revoked/expired/deleted machine-account
# token with an OAuth-style
# `[400 Bad Request] {"error":"invalid_client"}`.
"invalid_client", "invalid_grant",
"400 bad request")):
return ErrorKind.AUTH_FAILED
if any(tok in lowered for tok in ("network", "connection", "resolve",
"download", "dns")):
@ -718,6 +1013,22 @@ def _classify_bws_error(message: str) -> ErrorKind:
# ---------------------------------------------------------------------------
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches (plaintext and encrypted).
Used after a token rotation (`hermes secrets bitwarden token`) so the
next startup fetches fresh with the new credential instead of serving
a pull cached under the old token's fingerprint. The encrypted cache
is keyed off the old token too, so it must go as well.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
try:
_encrypted_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
"""Clear in-process AND disk caches.
@ -725,5 +1036,4 @@ def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
Without it we fall back to the same default resolution as the cache
writer itself.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
clear_caches(home_path)

View file

@ -0,0 +1,488 @@
"""``command`` secret source — resolve secrets via a user-configured helper.
Ports the security semantics of the desktop app's TypeScript
``CommandSecretsProvider`` (hermes-desktop ``src/main/secrets/commandProvider.ts``)
to the Python agent. The helper command (e.g. ``keepassxc-cli``,
``secret-tool``, or a script that cats a tmpfs env file) comes from
``secrets.command`` in ``config.yaml`` NEVER from ``.env``, which holds
only secret values.
Security model (mirrors the TS provider line-for-line where it matters):
* The command string is the USER'S OWN configuration (same trust level as
the ``.env`` file they control), so it is run via ``/bin/sh -c <command>``.
* The requested key is passed to the child ONLY via the ``HERMES_SECRET_KEY``
environment variable it is NEVER interpolated into the shell string, so
a hostile key name (e.g. ``"; rm -rf ~``) is inert data, not code.
* Hard timeout (default 3s) + output cap (default 1 MiB); any failure
(non-zero exit, timeout, spawn failure, oversized output) degrades to
"no value" rather than raising.
* Failures log ONLY structured fields (exit code / signal / errno) to
stderr never the command string, the helper's stderr, or any secret
value. The helper's stderr is captured via a pipe and DISCARDED so its
diagnostics (which can carry secret material) never reach our stderr.
* The startup/apply path runs the helper exactly ONCE (with an empty
``HERMES_SECRET_KEY``) it is never called per-key in a loop, so a
helper that blocks (e.g. on a vault unlock prompt) can't be spawned
dozens of times.
* PLATFORM: the provider is POSIX-only (needs ``/bin/sh``). On Windows it
degrades to an empty result with a warning; Windows users stay on the
default ``env`` provider.
"""
from __future__ import annotations
import os
import platform
import re
import signal as _signal
import subprocess
import sys
from pathlib import Path
from typing import Dict, Optional
# Reuse the exact result shape the bitwarden source returns so
# hermes_cli.env_loader can consume both providers identically.
from agent.secret_sources.base import ErrorKind, SecretSource
from agent.secret_sources.bitwarden import FetchResult
__all__ = [
"FetchResult",
"apply_command_secrets",
"get_command_secret",
"list_command_secrets",
"parse_secret_output",
"unquote_dotenv_value",
]
# Hard cap so a hung helper can never wedge startup. Kept deliberately
# TIGHT (3s) — a configured helper MUST be fast and NON-INTERACTIVE
# (e.g. `keepassxc-cli` against an already-unlocked DB, `secret-tool
# lookup`, or `cat`-ing a tmpfs env file), NOT something that prompts
# for a touch/PIN.
_COMMAND_TIMEOUT_SECONDS = 3.0
# Defensive cap on helper output (1 MiB) — a misbehaving command can't OOM us.
_MAX_OUTPUT_BYTES = 1024 * 1024
# A line is treated as a KEY=VALUE pair only when it matches an env-key
# shape before the '='. Anchored; `.` does not cross newlines, so a
# multi-line blob never matches as a single "env-shaped" value.
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
def _is_windows() -> bool:
return os.name == "nt" or platform.system() == "Windows"
def unquote_dotenv_value(raw: str) -> str:
"""Strip a single layer of matching surrounding quotes from a dotenv value.
Requires length >= 2 so a lone quote (``"``) is left intact rather than
collapsing to empty, and ``""``/``''`` correctly yield an empty string.
Shared by the single-key parser and the list path so both unquote
identically.
"""
t = raw.strip()
if len(t) >= 2 and (
(t.startswith('"') and t.endswith('"'))
or (t.startswith("'") and t.endswith("'"))
):
return t[1:-1]
return t
def parse_secret_output(stdout: str, wanted_key: str) -> Optional[str]:
"""Parse a secret-fetch helper's stdout. Supports BOTH shapes:
* a bare value (single secret): the whole trimmed stdout is the value.
* a dotenv blob (KEY=VALUE lines): parse them and return the entry for
``wanted_key``.
Mirrors the TS ``parseSecretOutput`` exactly, including the cross-key
misroute guard and the base64-padding disambiguation.
"""
text = stdout.replace("\r\n", "\n")
lines = text.split("\n")
# 1. Exact dotenv match wins: scan for a `wanted_key=...` line. This
# is deterministic and never returns another key's value.
dotenv_lines = [
line
for line in (raw.strip() for raw in lines)
if line and not line.startswith("#") and _ENV_LINE.match(line)
]
for line in dotenv_lines:
m = _ENV_LINE.match(line)
assert m is not None # filtered above
if m.group(1) == wanted_key:
value = unquote_dotenv_value(m.group(2))
# Whitespace-only (e.g. a quoted `K=" "` placeholder) is "no
# value": it would otherwise flow into an Authorization header
# → guaranteed 401.
return value if value.strip() != "" else None
# 2. The output is a multi-key dotenv dump that does NOT contain the
# wanted key → None, rather than mis-returning an unrelated line as
# a bare value. Only >=2 env-shaped lines count as a dump: a SINGLE
# non-matching env-shaped line falls through to the bare-value
# branch, because a bare secret can itself match the KEY=VALUE shape
# (e.g. base64 with '=' padding, "dGVzdA==") and must not be
# misclassified as a dump.
if len(dotenv_lines) > 1:
return None
# 3. Otherwise treat the whole output as a single bare value (a per-key
# helper that printed just the secret). Trim first so whitespace-only
# output (a ' '/'\t' placeholder entry) resolves to None, never a "key".
value = text.strip()
if value == "":
return None
# SECURITY (S2): a single env-shaped line for a DIFFERENT key must not
# be returned as the wanted secret. A sloppy helper (e.g. `head -1
# env-file`, or a grep that matched the wrong line) emitting
# `OTHER_KEY=realvalue` would otherwise flow — key name, '=' and the
# OTHER key's value — into an Authorization header sent to the WANTED
# key's endpoint: cross-provider credential leakage, not just a 401.
# Disambiguation from a bare base64 secret: base64 padding only ever
# produces an env-shaped line whose "value" part is empty or all '='
# (`dGVzdA==` → key `dGVzdA`, value `=`), so a non-trivial value part
# after a non-matching key means a misrouted dotenv entry → None.
env_shaped = _ENV_LINE.match(value)
if (
env_shaped
and env_shaped.group(1) != wanted_key
and re.fullmatch(r"=*", env_shaped.group(2).strip()) is None
):
return None
return value
def _run_helper(
command: str,
secret_key: str,
timeout_seconds: float,
max_output_bytes: int,
) -> Optional[str]:
"""Run the helper via ``/bin/sh -c`` and return its stdout, or None.
The key is passed as DATA via ``HERMES_SECRET_KEY`` never interpolated
into the command string. Both stdout and stderr are captured via pipes
(never inherited); stderr is discarded. Any failure logs structured
fields only and returns None never raises.
"""
if _is_windows():
print(
"[secrets:command] the 'command' provider is POSIX-only "
"(needs /bin/sh); resolving no value on Windows",
file=sys.stderr,
)
return None
env = os.environ.copy()
env["HERMES_SECRET_KEY"] = secret_key
try:
proc = subprocess.Popen( # noqa: S602 — command is the user's own config
["/bin/sh", "-c", command],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, # captured and DISCARDED — never inherited
start_new_session=True, # so the hard timeout can kill the whole group
)
except OSError as exc:
print(
f"[secrets:command] helper failed to spawn; resolving no value: "
f"errno={exc.errno}",
file=sys.stderr,
)
return None
try:
stdout_bytes, _stderr_discarded = proc.communicate(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
# Hard timeout: kill the whole process group (a helper script may
# have forked children that would otherwise keep the pipe open).
# POSIX-only by construction: _run_helper early-returns on Windows
# before ever spawning, so this line can't execute there.
try:
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
try:
proc.communicate(timeout=1.0)
except (subprocess.TimeoutExpired, ValueError, OSError):
pass
print(
f"[secrets:command] helper timed out after {timeout_seconds:g}s; "
f"resolving no value",
file=sys.stderr,
)
return None
if proc.returncode != 0:
# Structured fields ONLY — never the command string or the helper's
# stderr (either can carry secret material).
if proc.returncode < 0:
try:
sig = _signal.Signals(-proc.returncode).name
except ValueError:
sig = str(-proc.returncode)
code, signame = "?", sig
else:
code, signame = str(proc.returncode), "none"
print(
f"[secrets:command] helper failed; resolving no value: "
f"code={code} signal={signame}",
file=sys.stderr,
)
return None
if len(stdout_bytes) > max_output_bytes:
print(
f"[secrets:command] helper output exceeded the "
f"{max_output_bytes}-byte cap; resolving no value",
file=sys.stderr,
)
return None
return stdout_bytes.decode("utf-8", errors="replace")
def _parse_dotenv_map(stdout: str) -> Dict[str, str]:
"""Parse a KEY=VALUE blob into a map (the list/enumerate path).
Mirrors the TS ``list()``: only env-shaped lines contribute; comments
and non-matching lines are skipped. A bare-value helper yields ``{}``
per-key resolution via :func:`get_command_secret` still works.
"""
out: Dict[str, str] = {}
for raw in stdout.replace("\r\n", "\n").split("\n"):
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _ENV_LINE.match(line)
if not m:
continue
out[m.group(1)] = unquote_dotenv_value(m.group(2))
return out
def get_command_secret(
*,
command: str,
key: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Optional[str]:
"""Resolve a single secret by running the helper with the key in
``HERMES_SECRET_KEY``. Returns None on any failure never raises."""
command = (command or "").strip()
if not command:
return None
stdout = _run_helper(command, key, timeout_seconds, max_output_bytes)
if stdout is None:
return None
return parse_secret_output(stdout, key)
def list_command_secrets(
*,
command: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Dict[str, str]:
"""Enumerate secrets by running the helper ONCE with an empty key.
Returns the dotenv map ONLY when the helper emits a KEY=VALUE blob;
a bare-value helper returns ``{}``. Never raises.
"""
command = (command or "").strip()
if not command:
return {}
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
return {}
return _parse_dotenv_map(stdout)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
def apply_command_secrets(
*,
command: str,
override_existing: bool = False,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
home_path: Optional[Path] = None,
) -> FetchResult:
"""Run the helper once at startup and set its KEY=VALUE output on
``os.environ``.
LEGACY shim retained for API symmetry with ``apply_bitwarden_secrets``;
the startup path goes through :class:`CommandSource` + the registry
orchestrator instead (which owns precedence and the environ writes).
"""
result = FetchResult()
command = (command or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command is "
"empty. Set the helper command in config.yaml."
)
return result
if _is_windows():
result.warnings.append(
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
return result
# The list/enumerate path: run the helper exactly ONCE with an empty
# HERMES_SECRET_KEY and parse its stdout as a dotenv blob.
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.warnings.append(
"helper command failed at startup; no secrets applied "
"(process env / .env values remain in effect)"
)
return result
secrets = _parse_dotenv_map(stdout)
result.secrets = secrets
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing applied at "
"startup (a bare-value helper still resolves single keys on demand)"
)
return result
for key, value in secrets.items():
if value.strip() == "":
# Whitespace-only placeholder entries are "no value" — applying
# them would flow into an Authorization header → guaranteed 401.
result.skipped.append(key)
continue
if not override_existing and os.environ.get(key):
# Process env / .env win — same precedence as bitwarden.
result.skipped.append(key)
continue
os.environ[key] = value
result.applied.append(key)
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class CommandSource(SecretSource):
"""User-configured helper command as a registered secret source.
Composes with the other sources (Bitwarden, 1Password, plugins) through
the ``apply_all()`` orchestrator enable any combination simultaneously;
there is deliberately NO single-provider selector. ``fetch()`` only
fetches: precedence, ``override_existing`` semantics, conflict warnings,
and the ``os.environ`` writes are the orchestrator's job.
Bulk shape: the helper enumerates a KEY=VALUE blob in one run. Config::
secrets:
command:
enabled: true
command: "cat /run/user/1000/hermes-secrets.env"
# or per-vault CLIs: keepassxc-cli / secret-tool / pass / gpg —
# anything fast and NON-interactive.
"""
name = "command"
label = "Command helper"
shape = "bulk"
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"command": {
"description": "Helper run via /bin/sh -c; must print a "
"KEY=VALUE blob on stdout",
"default": "",
},
"helper_timeout_seconds": {
"description": "Hard timeout for one helper run",
"default": _COMMAND_TIMEOUT_SECONDS,
},
"override_existing": {
"description": "Helper values overwrite .env/shell values",
"default": False,
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
command = str(cfg.get("command") or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command "
"is empty. Set the helper command in config.yaml."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
if _is_windows():
result.error = (
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
try:
timeout = float(cfg.get("helper_timeout_seconds",
_COMMAND_TIMEOUT_SECONDS))
except (TypeError, ValueError):
timeout = _COMMAND_TIMEOUT_SECONDS
stdout = _run_helper(command, "", timeout, _MAX_OUTPUT_BYTES)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.error = (
"helper command failed (see structured fields above); "
"no secrets applied"
)
result.error_kind = ErrorKind.INTERNAL
return result
secrets = _parse_dotenv_map(stdout)
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing to apply"
)
return result
result.secrets = secrets
return result
def remediation(self, kind, cfg: dict) -> str:
if kind == ErrorKind.NOT_CONFIGURED:
return (
"Set secrets.command.command in config.yaml to a fast, "
"non-interactive helper that prints KEY=VALUE lines."
)
if kind == ErrorKind.INTERNAL:
return (
"Run the helper manually in a shell to see its real error — "
"Hermes discards helper stderr so diagnostics can't leak "
"secret material."
)
return super().remediation(kind, cfg)

View file

@ -98,6 +98,9 @@ _OP_ENV_ALLOWLIST = (
"OP_ACCOUNT",
"OP_CONNECT_HOST",
"OP_CONNECT_TOKEN",
# Lets a user skip op's desktop-app integration probe (which can hang with
# no timeout on a wedged desktop container) and go straight to token auth.
"OP_LOAD_DESKTOP_APP_SETTINGS",
)
@ -172,16 +175,19 @@ def _validate_references(
def _auth_fingerprint(token_env: str) -> str:
"""SHA-256 prefix over the auth material `op` would use.
Folds in the service-account token, ``OP_ACCOUNT``, and *all*
``OP_SESSION_*`` vars (the names `op` actually exports for interactive
sessions ``OP_SESSION_<account_shorthand>``). Signing out and into a
different identity therefore changes the cache key, so a value cached under
a previous identity is never served under a new one. Never logged or
Folds in the service-account token, ``OP_ACCOUNT``, the 1Password Connect
``OP_CONNECT_HOST``/``OP_CONNECT_TOKEN``, and *all* ``OP_SESSION_*`` vars
(the names `op` actually exports for interactive sessions
``OP_SESSION_<account_shorthand>``). Signing out and into a different
identity therefore changes the cache key, so a value cached under a
previous identity is never served under a new one. Never logged or
displayed; the raw token never leaves this hash.
"""
parts: List[str] = [
f"token={os.environ.get(token_env, '')}",
f"account={os.environ.get('OP_ACCOUNT', '')}",
f"connect_host={os.environ.get('OP_CONNECT_HOST', '')}",
f"connect_token={os.environ.get('OP_CONNECT_TOKEN', '')}",
]
for key in sorted(os.environ):
if key.startswith("OP_SESSION_"):
@ -607,6 +613,24 @@ class OnePasswordSource(SecretSource):
result.warnings.extend(fetch_warnings)
return result
def remediation(self, kind, cfg: dict) -> str:
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
token_env = _DEFAULT_TOKEN_ENV
if isinstance(cfg, dict):
token_env = str(cfg.get("service_account_token_env") or token_env)
return (
"Run `hermes secrets onepassword token` to paste a fresh "
f"service-account token ({token_env}), or `op signin` for an "
"interactive session."
)
if kind == ErrorKind.BINARY_MISSING:
return (
"Install the 1Password CLI "
"(https://developer.1password.com/docs/cli/get-started/) or "
"set secrets.onepassword.binary_path."
)
return super().remediation(kind, cfg)
def _classify_op_error(message: str) -> ErrorKind:
"""Best-effort mapping of op failure text onto the shared taxonomy."""
@ -633,11 +657,21 @@ def _classify_op_error(message: str) -> ErrorKind:
# ---------------------------------------------------------------------------
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches.
Used after a token rotation (`hermes secrets onepassword token`) so
the next startup resolves fresh with the new credential instead of
serving values cached under the old token's fingerprint.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
"""Clear in-process AND disk caches.
Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.
Without it we fall back to the same default resolution as the writer.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
clear_caches(home_path)

View file

@ -29,6 +29,7 @@ from __future__ import annotations
import concurrent.futures
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
@ -174,6 +175,13 @@ def _ensure_builtin_sources() -> None:
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled 1Password secret source",
exc_info=True)
try:
from agent.secret_sources.command import CommandSource
register_source(CommandSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled command secret source",
exc_info=True)
def _reset_registry_for_tests() -> None:
@ -275,6 +283,43 @@ def _ordered_enabled_sources(secrets_cfg: dict) -> List[SecretSource]:
return enabled
def _active_profile_name(home_path: Optional[Path]) -> str:
"""Best-effort active profile name for profile-scoped secret aliases.
A named profile's HERMES_HOME is ``~/.hermes/profiles/<name>``; the
default profile (``~/.hermes``) returns "".
"""
if home_path is not None:
resolved = Path(home_path)
if resolved.parent.name == "profiles" and resolved.name:
return resolved.name
for env_name in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"):
value = os.environ.get(env_name, "").strip()
if value and value != "default":
return value
return ""
# Only credential-shaped names get auto-aliased — a random profile-suffixed
# var should not silently hydrate an unsuffixed name.
_ALIAS_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY", "_PASSWORD")
def _profile_alias_target(var: str, profile: str) -> Optional[str]:
"""Map ``FOO_<PROFILE>`` to ``FOO`` for the active profile when safe."""
if not profile:
return None
suffix = "_" + profile.replace("-", "_").upper()
if not var.endswith(suffix):
return None
alias = var[: -len(suffix)]
if not alias or not is_valid_env_name(alias):
return None
if not any(alias.endswith(s) for s in _ALIAS_SUFFIXES):
return None
return alias
def apply_all(secrets_cfg: dict, home_path: Path,
environ: Optional[Dict[str, str]] = None) -> ApplyReport:
"""Fetch from every enabled source and apply the merged result to env.
@ -283,14 +328,24 @@ def apply_all(secrets_cfg: dict, home_path: Path,
Precedence per env var (most-specific intent wins):
1. Pre-existing env (.env / shell) unless the winning source has
1. ``secrets.preserve_existing`` names a pre-existing env value always
wins for these, even against a source with ``override_existing: true``
(escape hatch for profile-local platform secrets, #58073).
2. Pre-existing env (.env / shell) unless the winning source has
``override_existing: true``.
2. Mapped sources, in configured order.
3. Bulk sources, in configured order.
3. Mapped sources, in configured order.
4. Bulk sources, in configured order.
First claim wins. A later source that also carries the var gets a
``skipped_claimed`` entry and a conflict warning never a silent
clobber, and ``override_existing`` never applies across sources.
Profile aliasing (#51447): when running under a named profile, an applied
var ``FOO_<PROFILE>`` (credential-shaped suffixes only) also hydrates the
canonical ``FOO`` so platform adapters and plugins that read fixed env
names see the profile's value. The alias obeys the same protected /
preserve / claimed / override guards and is disabled with
``secrets.profile_alias: false``.
"""
import os as _os
@ -302,6 +357,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
if not enabled:
return report
preserve_raw = secrets_cfg.get("preserve_existing")
preserve: frozenset = frozenset(
n.strip() for n in preserve_raw if isinstance(n, str) and n.strip()
) if isinstance(preserve_raw, list) else frozenset()
alias_enabled = bool(secrets_cfg.get("profile_alias", True))
profile = _active_profile_name(home_path) if alias_enabled else ""
# Mapped sources outrank bulk sources regardless of list order:
# an explicit VAR→ref binding is stronger intent than a project dump.
ordered = ([s for s in enabled if s.shape == "mapped"]
@ -321,6 +384,15 @@ def apply_all(secrets_cfg: dict, home_path: Path,
except Exception: # noqa: BLE001
pass
# Every var any source supplies directly — an alias never shadows a
# var that some source will (or tried to) claim by its real name.
supplied_directly: set = set()
for _, _, result in fetches:
if result.ok:
supplied_directly.update(
v for v in result.secrets if isinstance(v, str)
)
# Apply phase — sequential, first-wins, fully attributed.
claimed: Dict[str, str] = {} # var → source name that won it
for source, cfg, result in fetches:
@ -336,15 +408,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
except Exception: # noqa: BLE001
override = False
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
def _try_apply(var: str, value: str, *, is_alias: bool = False) -> bool:
"""Apply one var through the shared guard chain. True = applied."""
if not is_valid_env_name(var):
sr.skipped_invalid.append(var)
continue
return False
if var in protected:
sr.skipped_protected.append(var)
continue
return False
if var in claimed:
sr.skipped_claimed.append(var)
report.conflicts.append(
@ -352,11 +423,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
f"{source.name} also supplies it (first source wins — "
"remove one binding or reorder secrets.sources)"
)
continue
return False
existed = bool(env.get(var))
if existed and var in preserve:
sr.skipped_existing.append(var)
return False
if existed and not override:
sr.skipped_existing.append(var)
continue
return False
env[var] = value
claimed[var] = source.name
sr.applied.append(var)
@ -366,5 +440,21 @@ def apply_all(secrets_cfg: dict, home_path: Path,
shape=source.shape,
overrode_env=existed,
)
return True
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
applied = _try_apply(var, value)
if not applied or not profile:
continue
alias = _profile_alias_target(var, profile)
if alias and alias not in supplied_directly and alias not in claimed:
if _try_apply(alias, value, is_alias=True):
result.warnings.append(
f"applied profile-scoped {var} as {alias} "
f"(active profile {profile!r})"
)
return report

507
agent/subscription_view.py Normal file
View file

@ -0,0 +1,507 @@
"""Surface-agnostic core for the ``/subscription`` TUI screen.
Companion to :mod:`agent.billing_view` same fail-open philosophy: 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 is decimal end-to-end
(server emits decimal strings); we only format for display.
The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it
previews the effect, then schedules a downgrade / cancellation / resume
(chargeless) or applies an upgrade (charges the card on the subscription). The
portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the
fallback for an upgrade that needs 3DS / was declined.
WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
Until it ships, the fail-open contract handles 404s the builder returns
``logged_in=False`` and the surface degrades gracefully.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Optional
from agent.billing_view import parse_money
logger = logging.getLogger(__name__)
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CurrentSubscription:
"""The user's active subscription. ``None`` (not this object) = no plan.
When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` /
``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a
fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade
fields are optional.
"""
tier_id: Optional[str] = None
tier_name: Optional[str] = None
monthly_credits: Optional[Decimal] = None
credits_remaining: Optional[Decimal] = None
cycle_ends_at: Optional[str] = None # ISO
pending_downgrade_tier_name: Optional[str] = None
pending_downgrade_at: Optional[str] = None # ISO
cancel_at_period_end: bool = False
cancellation_effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionTier:
"""A selectable plan in the catalog — one row of the in-terminal tier picker.
Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan
(shown but not selectable); ``is_enabled=False`` is a grandfathered tier the
user is on but that can no longer be selected. ``tier_order`` sorts the picker
and drives the upgrade-vs-downgrade direction hint.
"""
tier_id: str
name: str
tier_order: int = 0
dollars_per_month: Optional[Decimal] = None
monthly_credits: Optional[Decimal] = None
is_current: bool = False
is_enabled: bool = True
@dataclass(frozen=True)
class SubscriptionChangePreview:
"""Parsed ``POST /api/billing/subscription/preview`` — what a change would do.
``effect`` is the disposition the commit would take:
- ``charge_now`` an upgrade; ``amount_due_now_cents`` is the prorated charge.
- ``scheduled`` a downgrade / same-price change at ``effective_at`` (period end).
- ``no_op`` already on the target tier.
- ``blocked`` the commit would be refused; ``reason`` says why.
"""
effect: str
reason: Optional[str] = None
current_tier_id: Optional[str] = None
current_tier_name: Optional[str] = None
target_tier_id: Optional[str] = None
target_tier_name: Optional[str] = None
monthly_credits_delta: Optional[Decimal] = None
amount_due_now_cents: Optional[int] = None
effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionState:
"""Parsed ``GET /api/billing/subscription`` — 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_name: Optional[str] = None
org_id: Optional[str] = None # org.id from the NAS response
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
context: str = "personal" # "personal" | "team"
current: Optional[CurrentSubscription] = None
tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker)
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:
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
# =============================================================================
# Payload parsing
# =============================================================================
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
# "No plan" is wire-represented as current:null (free personal OR team) —
# the old all-null-object shape is gone. A present current is a real plan,
# so guard on a real tier id and return None otherwise.
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return CurrentSubscription(
tier_id=tier_id,
tier_name=raw.get("tierName") or raw.get("name"),
monthly_credits=parse_money(raw.get("monthlyCredits")),
credits_remaining=parse_money(raw.get("creditsRemaining")),
cycle_ends_at=raw.get("cycleEndsAt"),
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
pending_downgrade_at=raw.get("pendingDowngradeAt"),
cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")),
cancellation_effective_at=raw.get("cancellationEffectiveAt") or None,
)
def _coalesce(*vals: Any) -> Any:
"""First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``).
NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain
``x or default`` would drop those, so coalesce on ``None`` specifically.
"""
for v in vals:
if v is not None:
return v
return None
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
"""Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`."""
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return SubscriptionTier(
tier_id=tier_id,
name=raw.get("name") or "",
tier_order=int(_coalesce(raw.get("tierOrder"), 0)),
dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")),
monthly_credits=parse_money(raw.get("monthlyCredits")),
is_current=bool(raw.get("isCurrent")),
is_enabled=bool(_coalesce(raw.get("isEnabled"), True)),
)
def subscription_change_preview_from_payload(
payload: dict[str, Any],
) -> SubscriptionChangePreview:
"""Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`."""
effect = payload.get("effect")
cents = payload.get("amountDueNowCents")
return SubscriptionChangePreview(
# An unrecognized/missing effect is treated as ``blocked`` — fail safe, never
# charge on a malformed quote.
effect=effect if isinstance(effect, str) else "blocked",
reason=payload.get("reason") or None,
current_tier_id=payload.get("currentTierId"),
current_tier_name=payload.get("currentTierName"),
target_tier_id=payload.get("targetTierId"),
target_tier_name=payload.get("targetTierName"),
monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")),
amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None,
effective_at=payload.get("effectiveAt") or None,
)
def subscription_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> SubscriptionState:
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_context = payload.get("context")
context = raw_context if raw_context in ("personal", "team") else "personal"
raw_tiers = payload.get("tiers")
tiers = (
tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None)
if isinstance(raw_tiers, list)
else ()
)
return SubscriptionState(
logged_in=True,
org_name=org.get("name"),
org_id=org.get("id") or None,
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
context=context,
current=_parse_current(payload.get("current")),
tiers=tiers,
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open.
Returns ``SubscriptionState(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.
Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
``/subscription`` renders from that fixture instead of the real portal so
every plan/cancel/downgrade/team/not-admin state is testable on both
the CLI and TUI without a live account. Throwaway scaffolding; see
:func:`dev_fixture_subscription_state`.
"""
fixture = dev_fixture_subscription_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_subscription_state,
resolve_portal_base_url,
)
except Exception:
return SubscriptionState(logged_in=False, error="billing client unavailable")
try:
payload = get_subscription_state(timeout=timeout)
except BillingAuthError:
return SubscriptionState(logged_in=False)
except BillingError as exc:
logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error=str(exc))
except Exception:
logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error="could not load subscription state")
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 = resolve_portal_base_url()
except Exception:
portal_url = None
return subscription_state_from_payload(payload, portal_url=portal_url)
def subscription_manage_url(
state: SubscriptionState, tier_id: Optional[str] = None
) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>[&plan=<tier_id>]``.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal decided Jun 23), which routes upgradeCheckout / downgradescheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
``tier_id`` (the stable ``tiers[]`` id, never a name/slug) is appended as
``plan=`` so the portal preselects the picked plan only for a NEW
subscription / upgrade the user chose. The portal validates it and simply
ignores an unknown tier, so the CLI appends unconditionally when a tier was
picked (parity with the TUI's ``?plan=``).
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
if not state.portal_url:
return None
try:
parts = urlsplit(state.portal_url)
except Exception:
return None
if parts.scheme not in ("http", "https") or not parts.netloc:
return None
from urllib.parse import parse_qsl
# Preserve unrelated portal query params; org_id / plan are contract-owned
# (org_id before plan — insertion order is the emitted query order).
params = dict(parse_qsl(parts.query, keep_blank_values=True))
params.pop("org_id", None)
params.pop("plan", None)
if state.org_id:
params["org_id"] = state.org_id
if tier_id:
params["plan"] = tier_id
query = urlencode(params)
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Shared plan-catalog helpers (consumed by the CLI Free catalog + paid picker)
# =============================================================================
def _format_dollars_grouped(value: Optional[Decimal]) -> str:
"""``$1,000`` / ``$1,234.50`` — the whole-vs-fractional rule of
``billing_view.format_money`` but thousands-grouped, matching the TUI's
``toLocaleString('en-US')``.
The shared ``format_money`` is intentionally ungrouped (and asserted so across
other surfaces), so plan-catalog rows group locally to mirror the TUI.
"""
if value is None:
return ""
if value == value.to_integral_value():
return f"${format(value.to_integral_value(), ',f')}"
return f"${format(value.quantize(Decimal('0.01')), ',f')}"
def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]:
"""Enabled paid tiers other than the current plan, cheapest first.
One derivation shared by the CLI Free catalog and the paid change picker:
``is_enabled and not is_current and tier_order > 0`` (free / no-sub excluded
dropping to free is a cancellation), sorted by ``tier_order``.
"""
return sorted(
(
t
for t in (state.tiers or ())
if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0
),
key=lambda t: t.tier_order or 0,
)
def format_tier_row(tier: SubscriptionTier) -> str:
"""``name · $X/mo[ · $Y credits/mo]`` — the shared plan-catalog row.
Mirrors the TUI Free rows (``subscriptionOverlay.tsx``): thousands-grouped
money, and the ``$Y credits/mo`` suffix ONLY when monthly credits are present
and > 0 (a ``None`` / zero-credits tier hides it never ``· credits/mo`` or
``· $0 credits/mo``).
"""
row = f"{tier.name} · {_format_dollars_grouped(tier.dollars_per_month)}/mo"
mc = tier.monthly_credits
if mc is not None and mc > 0:
row += f" · {_format_dollars_grouped(mc)} credits/mo"
return row
def is_upgrade(state: SubscriptionState, tier_id: str) -> bool:
"""True when ``tier_id`` ranks above the current plan by ``tier_order``.
Prefers the active subscription's tier; falls back to the ``tiers[]``
``is_current`` marker (what the picker derives from), else 0 (free).
"""
orders = {t.tier_id: (t.tier_order or 0) for t in (state.tiers or ())}
cur_id = state.current.tier_id if state.current else None
if cur_id is not None and cur_id in orders:
cur_order = orders[cur_id]
else:
cur_order = next((t.tier_order or 0 for t in (state.tiers or ()) if t.is_current), 0)
return orders.get(tier_id, 0) > cur_order
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
def _dev_current(**over: Any) -> CurrentSubscription:
base: dict[str, Any] = dict(
tier_id="plus",
tier_name="Plus",
monthly_credits=Decimal("1000"),
credits_remaining=Decimal("420"),
cycle_ends_at="2026-07-01",
)
base.update(over)
return CurrentSubscription(**base)
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
"""A sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
specs = (
("free", "Free", 0, "0", "0"),
("plus", "Plus", 1, "20", "1000"),
("super", "Super", 2, "40", "3000"),
("ultra", "Ultra", 3, "80", "7000"),
)
return tuple(
SubscriptionTier(
tier_id=tid,
name=name,
tier_order=order,
dollars_per_month=parse_money(dpm),
monthly_credits=parse_money(mc),
is_current=(tid == current_id),
is_enabled=True,
)
for tid, name, order, dpm, mc in specs
)
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
"""Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.
Lets every CLI/TUI subscription state be exercised without a live portal:
free | mid | top | not-admin | downgrade | cancel | team |
logged-out
Returns ``None`` when the env var is unset/empty (the real portal path runs).
Throwaway scaffolding mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
"""
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
if not name:
return None
common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
if name in ("logged-out", "logged_out", "loggedout"):
return SubscriptionState(logged_in=False)
if name == "free":
return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common)
if name in ("mid", "mid-tier"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common)
if name in ("top", "top-tier"):
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
tiers=_dev_tiers("ultra"),
**common,
)
if name in ("not-admin", "member"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"})
if name == "downgrade":
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
tiers=_dev_tiers("super"),
**common,
)
if name == "cancel":
return SubscriptionState(
logged_in=True,
current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
tiers=_dev_tiers("plus"),
**common,
)
if name == "team":
return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
# Unknown name → behave as logged-out so the misconfiguration is visible.
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")

View file

@ -46,6 +46,7 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from hermes_constants import get_hermes_home
from utils import is_truthy_value
@ -395,7 +396,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if active_profile == "default":
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under ~/.hermes/profiles/<name>/. Each profile has its own "
"under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
"session than this one. Do not modify another profile's "
"skills/plugins/cron/memories unless the user explicitly directs "
@ -404,9 +405,9 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
else:
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
f"and writes ~/.hermes/profiles/{active_profile}/. The default "
f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default "
f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, "
f"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a "
f"different session run from a different shell. Do NOT modify "
f"another profile's skills/plugins/cron/memories unless the user "
f"explicitly directs you to. The cross-profile write guard will "

View file

@ -53,6 +53,28 @@ from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context
logger = logging.getLogger(__name__)
def _ensure_file_checkpoint(
agent,
function_name: str,
function_args: dict,
effective_task_id: str,
) -> None:
"""Checkpoint the same workspace path that the file tool will mutate."""
file_path = function_args.get("path", "")
if not file_path:
return
# File tools resolve relative paths against the task's live/session cwd,
# which can differ from the Hermes process cwd (notably in Docker). Resolve
# through that same path pipeline before asking the checkpoint manager to
# discover the project root.
from tools.file_tools import _resolve_path_for_task
resolved_path = _resolve_path_for_task(file_path, effective_task_id or "default")
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(str(resolved_path))
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
def _budget_for_agent(agent) -> BudgetConfig:
"""Resolve a tool-result BudgetConfig scaled to the agent's context window.
@ -502,10 +524,12 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# Checkpoint for file-mutating tools
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
_ensure_file_checkpoint(
agent,
function_name,
function_args,
effective_task_id,
)
except Exception:
pass
@ -1188,12 +1212,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# Checkpoint: snapshot working dir before file-mutating tools
if not _execution_blocked and function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(
work_dir, f"before {function_name}"
)
_ensure_file_checkpoint(
agent,
function_name,
function_args,
effective_task_id,
)
except Exception:
pass # never block tool execution

View file

@ -472,4 +472,8 @@ def _positive_int(value: Any, default: int) -> int:
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
# surrogatepass: tool results scraped from the web can carry unpaired
# UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict
# encode raises and takes down the whole conversation loop. The hash only
# needs deterministic bytes, not valid UTF-8.
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()

View file

@ -187,6 +187,7 @@ class ChatCompletionsTransport(ProviderTransport):
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — strict providers reject this
or "api_content" in msg # persist-what-you-send sidecar
):
needs_sanitize = True
break
@ -229,6 +230,7 @@ class ChatCompletionsTransport(ProviderTransport):
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — leak into strict providers
or "api_content" in msg # persist-what-you-send sidecar
):
out_msg = mutable_msg()
out_msg.pop("codex_reasoning_items", None)
@ -236,6 +238,7 @@ class ChatCompletionsTransport(ProviderTransport):
out_msg.pop("tool_name", None)
out_msg.pop("effect_disposition", None)
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
out_msg.pop("api_content", None) # persist-what-you-send sidecar
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).

View file

@ -92,6 +92,79 @@ class TurnResult:
_TURN_ABORTED_MARKERS = ("<turn_aborted>", "<turn_aborted/>")
def _notification_scope_ids(
note: dict,
) -> tuple[Optional[str], Optional[str]]:
"""Extract the thread/turn identity carried by a notification."""
if not isinstance(note, dict):
return None, None
params = note.get("params") or {}
if not isinstance(params, dict):
return None, None
nested_turn = params.get("turn") or {}
nested_item = params.get("item") or {}
observed_thread_id = params.get("threadId") or params.get("thread_id")
if observed_thread_id is None and isinstance(nested_turn, dict):
observed_thread_id = (
nested_turn.get("threadId")
or nested_turn.get("thread_id")
)
if observed_thread_id is None and isinstance(nested_item, dict):
observed_thread_id = (
nested_item.get("threadId")
or nested_item.get("thread_id")
)
observed_turn_id = params.get("turnId") or params.get("turn_id")
if observed_turn_id is None and isinstance(nested_turn, dict):
observed_turn_id = nested_turn.get("id") or nested_turn.get("turnId")
if observed_turn_id is None and isinstance(nested_item, dict):
observed_turn_id = (
nested_item.get("turnId")
or nested_item.get("turn_id")
)
return observed_thread_id, observed_turn_id
def _notification_belongs_to_turn(
note: dict,
*,
thread_id: Optional[str],
turn_id: Optional[str],
) -> bool:
"""Return whether a multiplexed notification belongs to this turn.
Codex app-server can carry parent and hosted subagent threads over one
JSON-RPC connection. An explicitly foreign child or
stale-turn event must not mutate the active parent's transcript or mark
its turn complete. Unscoped notifications remain accepted for protocol
compatibility.
"""
if not isinstance(note, dict):
return False
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if (
thread_id is not None
and observed_thread_id is not None
and str(observed_thread_id) != str(thread_id)
):
return False
if (
turn_id is not None
and observed_turn_id is not None
and str(observed_turn_id) != str(turn_id)
):
return False
return True
def _coerce_turn_input_text(user_input: Any) -> str:
"""Collapse Hermes/OpenAI rich content into app-server text input.
@ -227,6 +300,8 @@ class CodexAppServerSession:
self._client: Optional[CodexAppServerClient] = None
self._thread_id: Optional[str] = None
self._interrupt_event = threading.Event()
self._active_turn_id: Optional[str] = None
self._active_turn_lock = threading.Lock()
# Pending file-change items, keyed by item id. Populated on
# item/started for fileChange items; consumed by the approval
# bridge when codex sends item/fileChange/requestApproval. The
@ -301,6 +376,8 @@ class CodexAppServerSession:
if self._closed:
return
self._closed = True
with self._active_turn_lock:
self._active_turn_id = None
if self._client is not None:
try:
self._client.close()
@ -322,6 +399,33 @@ class CodexAppServerSession:
and unwind. Called by AIAgent's _interrupt_requested path."""
self._interrupt_event.set()
def request_steer(self, text: str) -> bool:
"""Append user guidance to the active Codex turn via ``turn/steer``."""
cleaned = str(text or "").strip()
if not cleaned:
return False
with self._active_turn_lock:
turn_id = self._active_turn_id
thread_id = self._thread_id
client = self._client
if not turn_id or not thread_id or client is None:
return False
try:
response = client.request(
"turn/steer",
{
"threadId": thread_id,
"input": [{"type": "text", "text": cleaned}],
"expectedTurnId": turn_id,
},
timeout=10,
)
except (CodexAppServerError, TimeoutError):
logger.debug("turn/steer rejected for active Codex turn", exc_info=True)
return False
accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None
return accepted_turn_id in {None, turn_id}
# ---------- diagnostics ----------
def _format_error_with_stderr(
@ -396,11 +500,18 @@ class CodexAppServerSession:
# Subprocess almost certainly unhealthy — retire so the next
# turn re-spawns cleanly.
result.should_retire = True
self._interrupt_event.clear()
return result
assert self._client is not None and self._thread_id is not None
result.thread_id = self._thread_id
self._interrupt_event.clear()
# Do not clear here: a hard stop can arrive while ensure_started() is
# spawning/initializing the subprocess. Honor it before launching a
# Codex turn instead of erasing the signal.
if self._interrupt_event.is_set():
result.interrupted = True
self._interrupt_event.clear()
return result
projector = CodexEventProjector()
user_input_text = _coerce_turn_input_text(user_input)
@ -432,6 +543,7 @@ class CodexAppServerSession:
result.error = self._format_error_with_stderr(
"turn/start failed", exc
)
self._interrupt_event.clear()
return result
except TimeoutError as exc:
# turn/start hanging is a strong signal the subprocess is wedged.
@ -441,9 +553,12 @@ class CodexAppServerSession:
"turn/start timed out", exc
)
result.should_retire = True
self._interrupt_event.clear()
return result
result.turn_id = (ts.get("turn") or {}).get("id")
with self._active_turn_lock:
self._active_turn_id = result.turn_id
deadline = time.monotonic() + turn_timeout
turn_complete = False
# Post-tool watchdog state. last_tool_completion_at is set whenever
@ -505,6 +620,17 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
if not _notification_belongs_to_turn(
pending,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification while draining "
"server request: method=%s",
pending.get("method"),
)
continue
# Mirror the main notification-handling block below so
# display events surface and stay in step with projector
# state. Without this, item/started / item/completed
@ -550,6 +676,16 @@ class CodexAppServerSession:
continue
method = note.get("method", "")
if not _notification_belongs_to_turn(
note,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification: method=%s", method
)
continue
if self._on_event is not None:
try:
self._on_event(note)
@ -647,6 +783,9 @@ class CodexAppServerSession:
)
result.should_retire = True
with self._active_turn_lock:
self._active_turn_id = None
self._interrupt_event.clear()
return result
def compact_thread(
@ -737,6 +876,48 @@ class CodexAppServerSession:
continue
method = note.get("method", "")
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if result.turn_id is None:
if method == "turn/started":
if (
observed_thread_id is not None
and str(observed_thread_id) != str(self._thread_id)
):
logger.debug(
"ignoring foreign compact turn/started: thread=%s",
observed_thread_id,
)
continue
if observed_turn_id is None:
logger.debug(
"ignoring compact turn/started without a turn id"
)
continue
result.turn_id = str(observed_turn_id)
elif observed_turn_id is not None or method in {
"item/completed",
"turn/completed",
}:
# thread/compact/start does not return a turn id. Until the
# new turn/started arrives, any terminal/projectable event
# is stale or cannot be safely attributed to this compaction.
logger.debug(
"ignoring codex notification before compact turn start: "
"method=%s",
method,
)
continue
if not _notification_belongs_to_turn(
note,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification: method=%s", method
)
continue
if self._on_event is not None:
try:
self._on_event(note)

View file

@ -3,8 +3,10 @@
``run_conversation`` opened with ~470 lines of straight-line setup before the
tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter
resets, user-message sanitization, todo/nudge-counter hydration, system-prompt
restore-or-build, crash-resilience persistence, preflight context compression, the
``pre_llm_call`` plugin hook, and external-memory prefetch.
restore-or-build, session-row creation (before compression, whose DB writes
reference the row), preflight context compression, the ``pre_llm_call`` plugin
hook, external-memory prefetch, and crash-resilience persistence (last, so the
user row is written once with its final ``api_content`` sidecar).
All of that is *prologue* it runs once per turn, has no back-references into the
loop, and produces a fixed set of values the loop then consumes. ``TurnContext``
@ -24,12 +26,18 @@ from __future__ import annotations
import logging
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Mapping, Optional
from agent.conversation_compression import conversation_history_after_compression
from agent.conversation_compression import (
IDLE_COMPACTION_STATUS_TEMPLATE,
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
conversation_history_after_compression,
)
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.model_metadata import (
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
@ -38,6 +46,152 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
def compose_user_api_content(
content: Any,
ext_prefetch_cache: str,
plugin_user_context: str,
) -> Optional[str]:
"""Compose the API-bound content of the current turn's user message.
Sources: memory-manager prefetch + ``pre_llm_call`` plugin context with
target="user_message" (the default). Both are appended to the *API copy*
of the user message only the stored content stays clean.
This is the single source of that composition. The prologue stamps the
result onto the live message as ``api_content`` (persisted alongside the
clean content) and the ``api_messages`` build in ``conversation_loop``
sends the same helper's output, so the persisted sidecar can never drift
from the bytes on the wire which is the whole prompt-cache invariant:
what turn N sends must be what turn N+1 replays.
Returns ``None`` when nothing is injected (multimodal/non-string content,
or no ephemeral context), meaning the message is sent as-is.
"""
if not isinstance(content, str):
return None
injections = []
if ext_prefetch_cache:
fenced = build_memory_context_block(ext_prefetch_cache)
if fenced:
injections.append(fenced)
if plugin_user_context:
injections.append(plugin_user_context)
if not injections:
return None
return content + "\n\n" + "\n\n".join(injections)
def substitute_api_content(api_msg: Dict[str, Any]) -> Optional[str]:
"""Pop the ``api_content`` sidecar and substitute it into ``content``.
Used at every API-bound message-build site (the ``api_messages`` build in
``conversation_loop``, the max-iterations summary in
``chat_completion_helpers``, the chat-completions transport). The sidecar
carries the exact bytes previously sent to the API for this message when
they differ from the clean stored content; substituting it here keeps the
provider prompt-cache prefix byte-stable across turns.
Returns the popped sidecar string (for callers that need the value for
current-turn composition logic) or ``None`` when absent.
"""
sidecar = api_msg.pop("api_content", None)
if (
isinstance(sidecar, str)
and sidecar
and api_msg.get("role") in ("user", "assistant")
):
api_msg["content"] = sidecar
return sidecar
def drop_stale_api_content(msg: Dict[str, Any]) -> None:
"""Drop the ``api_content`` sidecar from a message whose content was rewritten.
Called from every content-rewrite path (historical image strip,
merge-summary-into-tail, consecutive-user repair merge, stale-confirmation
redaction). Replaying the pre-rewrite sidecar would resend exactly what
the rewrite removed, so it must be dropped the cost is one cache
boundary miss, never wrong content.
"""
msg.pop("api_content", None)
def extract_api_content_sidecar(msg: Mapping[str, Any]) -> Optional[str]:
"""Extract the ``api_content`` sidecar from a message dict for persistence.
Shared by the gateway/branch forwarding sites that copy the sidecar into a
new row. Returns the string sidecar or ``None`` when absent/non-string.
"""
v = msg.get("api_content")
return v if isinstance(v, str) else None
def consume_gateway_turn_context_notes(agent: Any) -> str:
"""Pop the gateway's per-turn must-deliver notes off the agent (one-shot).
The gateway relocates volatile per-turn facts OUT of the ephemeral system
prompt (auto-reset notes, the first-contact intro, voice-channel changes)
and delivers them on the current user message via the api_content sidecar
instead, so the composed system prompt stays byte-stable turn-over-turn.
It stages the rendered notes on ``agent._gateway_turn_context_notes``
right before ``run_conversation``; this consumes them so a cached agent
can never replay a stale note on a later turn.
"""
notes = getattr(agent, "_gateway_turn_context_notes", "") or ""
if hasattr(agent, "_gateway_turn_context_notes"):
try:
agent._gateway_turn_context_notes = ""
except Exception:
pass
return notes if isinstance(notes, str) else ""
def append_notes_to_multimodal_content(content: Any, notes: str) -> bool:
"""Deliver must-deliver notes on a multimodal (list) user message.
``compose_user_api_content`` returns ``None`` for non-string content, so
sidecar-borne facts would silently drop on image/attachment turns. For
gateway must-deliver notes we instead append a text part to the content
list in place the part becomes durable message content (persisted and
replayed as-is), which keeps the wire and the transcript byte-identical.
Returns ``True`` when a part was appended.
"""
if not notes or not isinstance(content, list):
return False
try:
content.append({"type": "text", "text": notes})
return True
except Exception:
return False
def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> int:
"""Locate this turn's user message after compaction rebuilt ``messages``.
Compression replaces list entries with fresh copies (and may append a
todo-snapshot user message or a restored user turn AFTER the surviving
copy of the current turn's message), so a pre-compression index is
meaningless. Prefer the LAST user message whose content exactly matches
this turn's text — the surviving copy in the common case — so the
injection stamp and the #48677 persist override can't land on a
todo-snapshot or historical row. Fall back to the last user message when
no exact match survives (merge-summary-into-tail rewrites the content but
the trackers still need a live anchor). Returns -1 when the list has no
user message at all.
"""
fallback = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if not (isinstance(msg, dict) and msg.get("role") == "user"):
continue
if fallback < 0:
fallback = i
if msg.get("content") == user_message:
return i
return fallback
def _compression_made_progress(
orig_len: int, new_len: int, orig_tokens: int, new_tokens: int
) -> bool:
@ -61,6 +215,23 @@ def _compression_made_progress(
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
def _compression_warrants_another_preflight_pass(
orig_tokens: int, new_tokens: int, threshold_tokens: int
) -> bool:
"""Whether an over-threshold request merits another immediate summary.
Row-count progress is enough to prove that a compression boundary was real,
but not enough to justify another expensive pass before trying the provider.
Continue only when the request remains over threshold *and* the previous pass
materially reduced its estimated token pressure (>5%).
"""
return (
new_tokens >= threshold_tokens
and orig_tokens > 0
and new_tokens < orig_tokens * 0.95
)
def _should_run_preflight_estimate(
messages: List[Dict[str, Any]],
protect_first_n: int,
@ -89,6 +260,40 @@ def _should_run_preflight_estimate(
return estimate_messages_tokens_rough(messages) >= threshold_tokens
def _should_idle_compact(
*,
enabled: bool,
idle_after_seconds: int,
idle_gap_seconds: float,
tokens: int,
floor_tokens: int,
cooldown_active: bool,
) -> bool:
"""Decide whether an idle-triggered compaction should run this turn.
Idle compaction is opt-in (``idle_after_seconds <= 0`` disables it). It
fires when a session resumes after a wall-clock gap of at least
``idle_after_seconds`` since its last activity, so a long-lived thread
that is paused and later resumed compacts its accumulated history up
front instead of re-reading it on every subsequent turn.
It is orthogonal to the token-threshold trigger: it does NOT require the
context to exceed ``threshold_tokens``. It still skips work when the
context is at or below ``floor_tokens`` (the size compaction would reduce
*to*), so a small idle thread never pays for a summarisation that saves
nothing, and it defers to an active compression-failure cooldown.
Pure predicate so the policy is unit-testable without a live agent.
"""
if not enabled or idle_after_seconds <= 0:
return False
if idle_gap_seconds < idle_after_seconds:
return False
if cooldown_active:
return False
return tokens > floor_tokens
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
@ -114,6 +319,8 @@ class TurnContext:
plugin_user_context: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
# Turn-start preflight already proved an immediate retry ineffective.
preflight_compression_blocked: bool = False
def build_turn_context(
@ -133,6 +340,7 @@ def build_turn_context(
set_session_context,
set_current_write_origin,
ra,
moa_active: bool = False,
) -> TurnContext:
"""Run the once-per-turn setup and return the loop's input context.
@ -379,38 +587,115 @@ def build_turn_context(
# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Keep row creation and the marker-based append in the same
# per-agent critical section as CLI close persistence.
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
# Must run BEFORE preflight compression: in-place compaction inserts
# message rows referencing this session (archive_and_compact), and
# rotation creates a child with parent_session_id pointing at it — with
# PRAGMA foreign_keys=ON, a missing parent row fails both INSERTs on a
# fresh oversized first turn. The user-turn crash persist itself runs
# LATER (after memory prefetch / pre_llm_call), so the row is written
# once with its final api_content — both steps take the same per-agent
# persist lock as CLI close persistence.
persist_lock = getattr(agent, "_session_persist_lock", None)
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
if persist_lock is None:
_ensure_and_persist()
agent._ensure_db_session()
else:
with persist_lock:
_ensure_and_persist()
agent._ensure_db_session()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
"Turn-start session row creation failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
# Clear the staged CLI input eagerly (as the pre-refactor code did)
# so a crash in preflight compression — which runs between this row
# create and the late crash-persist below — doesn't leave a stale
# _pending_cli_user_message that the next turn would mistake for a
# fresh staged input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
# ── Idle-triggered compaction (opt-in; ``idle_compact_after_seconds``) ──
# When a session resumes after a long idle gap, compact the accumulated
# history up front so the rest of the conversation does not keep re-reading
# a large stale context on every turn. This fires on elapsed wall-clock time
# rather than size, so it complements (does not replace) the token-threshold
# preflight below. ``_last_activity_ts`` is the last time this turn loop did
# work; nothing has touched it yet this turn, so it measures the gap since
# the previous turn finished. The cheap gap pre-check gates the (more
# expensive) token estimate, mirroring ``_should_run_preflight_estimate``.
_idle_after = getattr(agent, "compression_idle_compact_after_seconds", 0)
if agent.compression_enabled and _idle_after > 0 and messages:
_idle_gap = time.time() - getattr(agent, "_last_activity_ts", time.time())
if _idle_gap >= _idle_after:
_compressor = agent.context_compressor
_idle_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
# Post-compression target size: don't summarise a thread already
# below what compaction would reduce it to.
_idle_floor = int(
_compressor.threshold_tokens * _compressor.summary_target_ratio
)
_idle_cooldown = getattr(
_compressor, "get_active_compression_failure_cooldown", lambda: None
)()
if _should_idle_compact(
enabled=agent.compression_enabled,
idle_after_seconds=_idle_after,
idle_gap_seconds=_idle_gap,
tokens=_idle_tokens,
floor_tokens=_idle_floor,
cooldown_active=bool(_idle_cooldown),
):
logger.info(
"Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor "
"(session %s)",
int(_idle_gap),
_idle_after,
f"{_idle_tokens:,}",
f"{_idle_floor:,}",
agent.session_id or "none",
)
agent._emit_status(
IDLE_COMPACTION_STATUS_TEMPLATE.format(
idle_seconds=int(_idle_gap), tokens=_idle_tokens
)
)
_idle_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_idle_tokens,
task_id=effective_task_id,
)
# ``_compress_context`` returns the INPUT list object when it
# skips (per-session lock held by another path, failure
# cooldown, anti-thrash breaker, codex-native routing). Only
# re-baseline + re-anchor after a real compaction — a skip
# must leave the turn's flush baseline and user-message index
# untouched.
if messages is not _idle_input:
conversation_history = conversation_history_after_compression(
agent, messages
)
# Compaction rebuilt the list, so the index of this turn's
# just-appended user message is stale — re-anchor it the
# same way the preflight path does below.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
# issue #27405 (a few very large messages slipping past the count gate).
_preflight_compressed = False
_preflight_compression_blocked = False
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
@ -478,6 +763,7 @@ def build_turn_context(
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
elif _compressor.should_compress(_preflight_tokens):
_preflight_compressed = True
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
@ -486,11 +772,18 @@ def build_turn_context(
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
tokens=_preflight_tokens,
threshold=_compressor.threshold_tokens,
)
)
for _pass in range(3):
# Preflight passes honor the same configured per-turn cap
# (compression.max_attempts) as the loop's compression sites;
# default 3 preserves the prior hardcoded behavior.
_max_preflight_passes = max(
1, int(getattr(agent, "max_compression_attempts", 3) or 3)
)
for _pass in range(_max_preflight_passes):
_orig_len = len(messages)
_orig_tokens = _preflight_tokens
messages, active_system_prompt = agent._compress_context(
@ -509,6 +802,7 @@ def build_turn_context(
if not _compression_made_progress(
_orig_len, len(messages), _orig_tokens, _preflight_tokens
):
_preflight_compression_blocked = True
break # Cannot compress further: neither rows nor tokens moved
conversation_history = conversation_history_after_compression(
agent, messages
@ -520,6 +814,32 @@ def build_turn_context(
agent._mute_post_response = False
if not _compressor.should_compress(_preflight_tokens):
break
if not _compression_warrants_another_preflight_pass(
_orig_tokens,
_preflight_tokens,
_compressor.threshold_tokens,
):
_preflight_compression_blocked = True
logger.warning(
"Preflight compression made insufficient progress: "
"~%s -> ~%s request tokens; skipping additional passes",
f"{_orig_tokens:,}",
f"{_preflight_tokens:,}",
)
break
if _preflight_compressed:
# Compression rebuilt the list (tail messages are fresh compaction
# copies), so the pre-compression index of this turn's user message
# is stale. Re-anchor both index trackers: the api_content stamp
# below, the loop's injection site, and the flush's persist-override
# row (#48677) must all target the surviving dict, not a stale
# position. Exact-content match first so a todo-snapshot user message
# appended after the tail can't steal the anchor.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
@ -574,6 +894,29 @@ def build_turn_context(
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
# Gateway must-deliver notes (auto-reset note, first-contact intro,
# voice-channel change) ride the same user-message injection channel as
# plugin context so the ephemeral system prompt can stay byte-stable.
# One-shot: staged by the gateway right before this turn, consumed here.
# Multimodal (list) content can't take the string sidecar — append a
# durable text part instead of dropping the fact.
_gateway_notes = consume_gateway_turn_context_notes(agent)
if _gateway_notes:
_gw_turn_content = (
messages[current_turn_user_idx].get("content")
if 0 <= current_turn_user_idx < len(messages)
and isinstance(messages[current_turn_user_idx], dict)
else None
)
if isinstance(_gw_turn_content, list):
append_notes_to_multimodal_content(_gw_turn_content, _gateway_notes)
else:
plugin_user_context = (
plugin_user_context + "\n\n" + _gateway_notes
if plugin_user_context
else _gateway_notes
)
# Per-turn file-mutation verifier state.
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
@ -610,6 +953,92 @@ def build_turn_context(
except Exception:
pass
# ── api_content sidecar: persist what you send ──
# The prefetch/plugin context above is injected into the API copy of this
# turn's user message, never into the stored content — so on the next
# turn the message would replay WITHOUT the injection, diverging the
# request prefix at this point and re-prefilling everything after it
# (the whole previous turn's assistant/tool chain). Stamp the exact
# API-bound bytes on the live dict, only when they differ from the clean
# content, so the crash persist below writes both in the same row and
# replay can reproduce the sent prefix byte-for-byte. Guarded by the
# same predicate the api_messages build uses, so the stamped bytes are
# exactly the bytes the loop sends. codex_app_server turns bypass the
# api_messages build entirely (the codex thread gets the plain user
# message), so stamping there would persist bytes that were never sent.
# MoA turns append per-call aggregated reference context to the same API
# copy AFTER this composition, so the stamped bytes would never match the
# wire either — skip the stamp rather than persist provably wrong "exact
# sent bytes" (MoA keeps its pre-sidecar cache behavior).
if (
not moa_active
and getattr(agent, "api_mode", None) != "codex_app_server"
and 0 <= current_turn_user_idx < len(messages)
and messages[current_turn_user_idx].get("role") == "user"
):
_turn_user_msg = messages[current_turn_user_idx]
_api_content = compose_user_api_content(
_turn_user_msg.get("content", ""), ext_prefetch_cache, plugin_user_context
)
if _api_content is not None and _api_content != _turn_user_msg.get("content"):
_turn_user_msg["api_content"] = _api_content
# In-place preflight compaction has ALREADY inserted this turn's
# user row (archive_and_compact runs before prefetch/pre_llm_call
# can compose the sidecar), and the crash persist below identity-
# skips every compacted dict (they are all in the rebound
# conversation_history) — so the stamp would never reach the DB.
# Backfill it onto the freshly-inserted row directly. Rotation
# mode needs nothing here: its compacted copies flush to the
# child session after this stamp.
if _preflight_compressed and bool(
getattr(agent, "_last_compaction_in_place", False)
):
_db = getattr(agent, "_session_db", None)
if _db is not None:
try:
_db.set_latest_user_api_content(
agent.session_id,
_turn_user_msg.get("content"),
_api_content,
)
except Exception:
logger.warning(
"in-place compaction api_content backfill failed "
"for session=%s",
agent.session_id or "none",
exc_info=True,
)
# Crash-resilience: persist the inbound user turn before the first LLM
# call. Runs after preflight compression (which rewrites history anyway)
# and after prefetch/pre_llm_call, so the user row is written once with
# its final api_content instead of being re-written mid-turn.
# Keep row creation and the marker-based append in the same per-agent
# critical section as CLI close persistence, and retry the row create if
# the pre-compression attempt above failed transiently.
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
try:
if persist_lock is None:
_ensure_and_persist()
else:
with persist_lock:
_ensure_and_persist()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
return TurnContext(
user_message=user_message,
original_user_message=original_user_message,
@ -622,4 +1051,5 @@ def build_turn_context(
should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context,
ext_prefetch_cache=ext_prefetch_cache,
preflight_compression_blocked=_preflight_compression_blocked,
)

View file

@ -25,6 +25,45 @@ from __future__ import annotations
import os
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.message_content import flatten_message_text
def _is_pure_tool_call_tail(msg: dict) -> bool:
"""An assistant row with ``tool_calls`` but no visible text content of its own.
Such a row satisfies the role check (``tail role == "assistant"``) while
carrying none of the delivered answer see the #43849/#44100 invariant
block in :func:`finalize_turn`. Uses :func:`flatten_message_text` so that
multimodal (list-type) content is evaluated by its text parts, not just
its type.
"""
if not msg.get("tool_calls"):
return False
return not flatten_message_text(msg.get("content")).strip()
# Verification continuation scaffolding flags: verify-on-stop / pre_verify
# inject a synthetic user nudge to keep the agent going one more turn.
# These nudges must be stripped from returned/live history to avoid
# role-alternation breaks and poisoning the resumed transcript. The
# assistant response is real content and is not flagged. (#65919 §7)
_VERIFICATION_CONTINUATION_FLAGS = (
"_verification_stop_synthetic",
"_pre_verify_synthetic",
)
def _drop_verification_continuation_scaffolding(messages) -> None:
"""Remove verification-continuation nudge messages from *messages* in place.
Only the synthetic nudges carry these flags, so this strips just the
nudges while preserving the real attempted-final-answer that was
persisted to state.db.
"""
messages[:] = [
m for m in messages
if not (isinstance(m, dict) and any(m.get(f) for f in _VERIFICATION_CONTINUATION_FLAGS))
]
def finalize_turn(
@ -43,6 +82,7 @@ def finalize_turn(
_should_review_memory,
_turn_exit_reason,
_pending_verification_response=None,
_pending_verification_response_previewed=False,
):
"""Run the post-loop finalization and return the turn ``result`` dict.
@ -76,6 +116,11 @@ def finalize_turn(
# fallible model call. The explicit pending value is the provenance
# guard: unrelated error/recovery exits can never enter this branch.
final_response = _pending_verification_response
# Mark the turn as previewed only when the reused candidate was
# actually streamed to the user as interim content. (#65919 review:
# response-loss blocker)
if _pending_verification_response_previewed:
agent._response_was_previewed = True
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
iteration_limit_fallback = True
preserved_verification_fallback = True
@ -191,6 +236,12 @@ def finalize_turn(
try:
agent._drop_trailing_empty_response_scaffolding(messages)
# Drop verification-continuation nudges (synthetic user messages)
# from the live history before the tail-assistant check — only the
# nudges need stripping; the assistant candidate persists in
# state.db. (#65919 §7)
_drop_verification_continuation_scaffolding(messages)
# When the turn was interrupted and the last message is a tool
# result, append a synthetic assistant message to close the
# tool-call sequence. Without this, the session persists a
@ -220,13 +271,44 @@ def finalize_turn(
# single chokepoint every recovery ``break`` flows through, so the
# invariant "delivered final_response ⇒ assistant row in transcript"
# holds regardless of which path produced it. (#43849 / #44100)
#
# Compare content (not just role) so a verification candidate that
# matches the final response is not duplicated at budget
# exhaustion. (#65919 §7)
if final_response and not interrupted:
try:
_tail_role = messages[-1].get("role") if messages else None
_tail = messages[-1] if messages else None
except Exception:
_tail_role = None
_tail = None
_tail_role = _tail.get("role") if isinstance(_tail, dict) else None
if _tail_role != "assistant":
# Tail is not an assistant row — append the final response
# so the durable turn closes with the answer (#43849/#44100).
messages.append({"role": "assistant", "content": final_response})
elif isinstance(_tail, dict) and _tail.get("content") != final_response and _is_pure_tool_call_tail(_tail):
# The tail IS an assistant row, but a *pure tool-call turn*:
# tool_calls with no text of its own. The role check alone
# leaves the #43849/#44100 invariant unmet — the user saw a
# response that never reached the transcript, and the next turn
# replays the user backlog and re-answers it (the very symptom
# this block was added for). Fill that row's empty content
# instead of appending, so the durable turn ends with the answer
# without disturbing the tool-call structure or creating an
# assistant→assistant pair.
#
# The ``content != final_response`` guard prevents filling when
# the tail already carries the final response text (verification
# candidate collapse — the provisional answer was persisted and
# reused as the terminal response, #65919 §7).
_tail["content"] = final_response
# The row may have already been flushed to SQLite by the
# incremental tool-call persist (conversation_loop.py:4990),
# which stamps ``_DB_PERSISTED_MARKER`` so subsequent flushes
# skip it. Pop the marker so the next ``_persist_session``
# re-writes the filled content to the durable store —
# otherwise ``/resume`` reloads ``content=""`` and the bug
# resurfaces cross-session.
_tail.pop("_db_persisted", None)
# The model has completed its request, so replace API-local
# voice/model/skill guidance with the clean user input before writing the

View file

@ -73,6 +73,10 @@ class TurnRetryState:
# was rolled back off ``messages`` and the loop should re-issue the API
# call against the newly-activated provider (#32421).
restart_with_rebuilt_messages: bool = False
# A user correction cancelled the in-flight provider request. The outer
# loop must append a role-safe checkpoint + user message, rebuild the API
# payload, and retry the same logical iteration.
restart_with_redirected_messages: bool = False
def __iter__(self):
# Convenience for debugging / tests: iterate (name, value) pairs.

View file

@ -179,6 +179,23 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://openrouter.ai/anthropic/claude-opus-4.8-fast",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude Sonnet 5 ────────────────────────────────────────
# Launched 2026-06-30. Introductory pricing ($2/$10 per MTok) runs
# through 2026-08-31, after which it reverts to $3/$15 (matching
# Sonnet 4.6). Update this entry when the intro window closes.
# Source: https://platform.claude.com/docs/en/about-claude/pricing
(
"anthropic",
"claude-sonnet-5",
): PricingEntry(
input_cost_per_million=Decimal("2.00"),
output_cost_per_million=Decimal("10.00"),
cache_read_cost_per_million=Decimal("0.20"),
cache_write_cost_per_million=Decimal("2.50"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-06-intro",
),
# ── Anthropic Claude 4.7 ─────────────────────────────────────────────
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
# tokens for the same text).
@ -528,17 +545,59 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
# Bedrock charges the same per-token rates as the model provider but
# through AWS billing. These are the on-demand prices (no commitment).
# Source: https://aws.amazon.com/bedrock/pricing/
# Current-gen Claude Opus on Bedrock. Commercial Bedrock on-demand
# mirrors Anthropic's published list price for the Claude line
# ($5/$25 for Opus 4.6/4.7/4.8; cache write = 1.25x input at the
# 5-minute TTL, cache read = 0.1x input). NOTE: the AWS Price List API
# had not published these SKUs machine-readably as of 2026-07 — these
# are commercial-list snapshots pending an authoritative machine source.
(
"bedrock",
"anthropic.claude-opus-4-8",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="anthropic-list-2026-07",
),
(
"bedrock",
"anthropic.claude-opus-4-7",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="anthropic-list-2026-07",
),
(
"bedrock",
"anthropic.claude-opus-4-6",
): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
pricing_version="anthropic-list-2026-07",
),
(
"bedrock",
"anthropic.claude-sonnet-5",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-06",
),
(
"bedrock",
@ -884,19 +943,40 @@ def _normalize_bedrock_model_name(model: str) -> str:
"""Normalize a Bedrock model id to its bare foundation-model form.
Bedrock cross-region inference profiles prefix the foundation model id
with a region scope (``us.`` / ``global.`` / ``eu.`` / ``ap.`` / ``jp.``),
e.g. ``us.anthropic.claude-opus-4-7``. The pricing table is keyed on the
bare ``anthropic.claude-*`` id, so the prefix must be stripped before the
lookup or every cross-region session prices as unknown. Mirrors the
prefix list in ``bedrock_adapter.is_anthropic_bedrock_model``. Also
normalizes dot-notation version numbers (``4.7`` ``4-7``).
with a region scope (``us.`` / ``global.`` / ``eu.`` / ``apac.`` / ``au.``
/ ...), e.g. ``us.anthropic.claude-opus-4-7`` or
``au.anthropic.claude-sonnet-4-5-20250929-v1:0``. The pricing table is
keyed on the bare ``anthropic.claude-*`` id, so the prefix must be
stripped before the lookup or every cross-region session prices as
unknown. Note Asia-Pacific uses ``apac.`` (a bare ``ap.`` never matches
an ``apac.*`` id) and Australia/New Zealand use ``au.``. Also normalizes
dot-notation version numbers (``4.7`` ``4-7``) and the documented
trailing date, revision, and profile components (``-20250514-v1:0``).
"""
name = model.lower().strip()
for prefix in ("us.", "global.", "eu.", "ap.", "jp."):
for prefix in (
"global.",
"us.",
"eu.",
"apac.",
"ap.",
"au.",
"jp.",
"ca.",
"sa.",
"me.",
"af.",
):
if name.startswith(prefix):
name = name[len(prefix):]
break
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
# Bedrock inference profile IDs append these documented components to the
# foundation model ID. Strip only the trailing forms, not arbitrary model
# name continuations that could be a distinct SKU.
name = re.sub(r":\d+$", "", name)
name = re.sub(r"-v\d+$", "", name)
name = re.sub(r"-\d{8}$", "", name)
return name

View file

@ -122,13 +122,13 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.commit()
def _split_segment_tokens(command: str) -> list[list[str]]:
def _split_segment_tokens(command: str, *, posix: bool = True) -> list[list[str]]:
segments: list[list[str]] = []
for segment in _SHELL_SPLIT_RE.split(command.strip()):
if not segment:
continue
try:
tokens = shlex.split(segment)
tokens = shlex.split(segment, posix=posix)
except ValueError:
continue
if tokens:
@ -298,10 +298,13 @@ def _ad_hoc_script_args(tokens: list[str], root: str | Path | None) -> Optional[
def _find_ad_hoc_match(command: str, root: str | Path | None) -> Optional[list[str]]:
for tokens in _split_segment_tokens(command):
trailing_args = _ad_hoc_script_args(tokens, root)
if trailing_args is not None:
return trailing_args
# Try both posix=True (default) and posix=False (Windows backslash paths)
# so ad-hoc verification scripts with backslash paths are matched on Windows.
for posix in (True, False):
for tokens in _split_segment_tokens(command, posix=posix):
trailing_args = _ad_hoc_script_args(tokens, root)
if trailing_args is not None:
return trailing_args
return None

View file

@ -70,6 +70,29 @@ fn is_valid_commit(s: &str) -> bool {
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
}
/// Resolver cache plan for a pin that already has a local path computed.
///
/// Immutable commit pins reuse cache forever. Mutable branch/tag pins always
/// refresh, and only fall back to a stale cache when the refresh fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CachePlan {
/// On-disk hit for an immutable pin — skip the network.
Reuse,
/// Download (or re-download). `stale_ok` means a failed refresh may return
/// the existing cache file (mutable pins with a prior download).
Fetch { stale_ok: bool },
}
pub(crate) fn cache_plan(immutable: bool, cached_exists: bool) -> CachePlan {
if immutable && cached_exists {
CachePlan::Reuse
} else {
CachePlan::Fetch {
stale_ok: !immutable && cached_exists,
}
}
}
/// Resolves the install script to use for this run.
///
/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
@ -100,9 +123,13 @@ pub async fn resolve(
// 2. (Not implemented) bundled fallback.
// 3. Network. Pin must be a real commit or a branch ref.
let commit_or_ref = match (&pin.commit, &pin.branch) {
(Some(c), _) if is_valid_commit(c) => c.clone(),
(_, Some(b)) if !b.trim().is_empty() => b.clone(),
//
// Commit SHAs are immutable — permanent cache reuse is safe.
// Branch/tag pins are moving refs: always try to refresh so "Retry install"
// cannot keep reusing a poisoned install-main.ps1 forever (#67193).
let (commit_or_ref, immutable) = match (&pin.commit, &pin.branch) {
(Some(c), _) if is_valid_commit(c) => (c.clone(), true),
(_, Some(b)) if !b.trim().is_empty() => (b.clone(), false),
(Some(other), _) => {
return Err(anyhow!(
"install script pin commit `{other}` is not a valid git SHA"
@ -116,36 +143,66 @@ pub async fn resolve(
};
let cached = cached_path(kind, &commit_or_ref);
if cached.exists() {
emit_log(&format!(
"[bootstrap] using cached {} for {}",
kind.filename(),
truncate_ref(&commit_or_ref)
));
return Ok(ResolvedScript {
path: cached,
source: ScriptSource::Cached,
commit: pin.commit.clone(),
branch: pin.branch.clone(),
});
match cache_plan(immutable, cached.exists()) {
CachePlan::Reuse => {
emit_log(&format!(
"[bootstrap] using cached {} for {}",
kind.filename(),
truncate_ref(&commit_or_ref)
));
// Immutable pins are cached forever, so a .ps1 cached by a
// pre-BOM-fix installer would keep the #67193 encoding bug on
// every retry. Upgrade it in place before handing it out.
upgrade_cached_script(kind, &cached, emit_log);
return Ok(ResolvedScript {
path: cached,
source: ScriptSource::Cached,
commit: pin.commit.clone(),
branch: pin.branch.clone(),
});
}
CachePlan::Fetch { stale_ok } => {
emit_log(&format!(
"[bootstrap] downloading {} for {} {} from GitHub",
kind.filename(),
if immutable {
"commit"
} else {
"mutable ref"
},
truncate_ref(&commit_or_ref)
));
match download(kind, &commit_or_ref, &cached).await {
Ok(()) => {
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
Ok(ResolvedScript {
path: cached,
source: ScriptSource::Downloaded,
commit: pin.commit.clone(),
branch: pin.branch.clone(),
})
}
Err(err) if stale_ok => {
emit_log(&format!(
"[bootstrap] WARNING: refresh failed for mutable ref {}; using stale cached {} at {}: {err:#}",
truncate_ref(&commit_or_ref),
kind.filename(),
cached.display()
));
// Stale cache can predate the BOM fix too — upgrade it.
upgrade_cached_script(kind, &cached, emit_log);
Ok(ResolvedScript {
path: cached,
source: ScriptSource::Cached,
commit: pin.commit.clone(),
branch: pin.branch.clone(),
})
}
Err(err) => Err(err),
}
}
}
emit_log(&format!(
"[bootstrap] downloading {} for {} from GitHub",
kind.filename(),
truncate_ref(&commit_or_ref)
));
download(kind, &commit_or_ref, &cached).await?;
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
Ok(ResolvedScript {
path: cached,
source: ScriptSource::Downloaded,
commit: pin.commit.clone(),
branch: pin.branch.clone(),
})
}
#[derive(Debug, Clone, Default)]
@ -185,8 +242,86 @@ fn truncate_ref(s: &str) -> &str {
}
}
/// UTF-8 BOM. Windows PowerShell 5.1 reads a BOM-less `.ps1` using the system
/// ANSI code page; a leading BOM is what tells it the file is UTF-8. The
/// `irm | iex` / `[scriptblock]::Create` path strips BOMs on purpose, but the
/// GUI bootstrap runs the *cached file* via `-File`, so we write the opposite
/// (#67193).
const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
/// Prepare bytes for the on-disk bootstrap cache.
///
/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files
/// are left unchanged — a BOM would break `#!/bin/bash`.
pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> {
match kind {
ScriptKind::Ps1 => {
if bytes.starts_with(UTF8_BOM) {
bytes.to_vec()
} else {
let mut out = Vec::with_capacity(UTF8_BOM.len() + bytes.len());
out.extend_from_slice(UTF8_BOM);
out.extend_from_slice(bytes);
out
}
}
ScriptKind::Sh => bytes.to_vec(),
}
}
/// Upgrade a cached script written by a pre-BOM-fix installer in place.
///
/// `prepare_cached_script_bytes` only runs inside `download()`, but immutable
/// commit pins (and the stale-fallback path) reuse the on-disk file without
/// re-downloading — so a BOM-less `.ps1` cached before the #67193 fix would
/// keep reproducing the ANSI-codepage parse failure on every retry. Rewrites
/// through the same atomic tmp+rename shape as `download()`. Best-effort: a
/// failed upgrade logs a warning and keeps the original file (which is no
/// worse than the pre-existing behavior).
fn upgrade_cached_script(kind: ScriptKind, cached: &Path, emit_log: &impl Fn(&str)) {
if !matches!(kind, ScriptKind::Ps1) {
return;
}
let bytes = match std::fs::read(cached) {
Ok(b) => b,
Err(err) => {
emit_log(&format!(
"[bootstrap] WARNING: could not read cached script {} for BOM check: {err}",
cached.display()
));
return;
}
};
if bytes.starts_with(UTF8_BOM) {
return;
}
let upgraded = prepare_cached_script_bytes(kind, &bytes);
let tmp = cached.with_extension("ps1.tmp");
let result = std::fs::write(&tmp, &upgraded).and_then(|()| std::fs::rename(&tmp, cached));
match result {
Ok(()) => emit_log(&format!(
"[bootstrap] upgraded cached {} with UTF-8 BOM (#67193)",
cached.display()
)),
Err(err) => {
let _ = std::fs::remove_file(&tmp);
emit_log(&format!(
"[bootstrap] WARNING: could not upgrade cached {} with UTF-8 BOM: {err}",
cached.display()
));
}
}
}
/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
///
/// The client carries explicit timeouts: mutable branch pins call this on
/// EVERY run (#67193 cache-refresh fix), and the stale-cache fallback in
/// `resolve()` only fires when this returns `Err`. Without a timeout, a
/// black-holed connection (captive portal, hung proxy, silently dropped
/// packets) never errors — the whole bootstrap would hang here instead of
/// falling back to the cached script.
async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
let url = format!(
"https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}",
@ -208,7 +343,11 @@ async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Re
format!("{ext}.tmp")
});
let response = reqwest::Client::new()
let response = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(60))
.build()
.context("building download client")?
.get(&url)
.header("User-Agent", "hermes-setup/0.0.1")
.send()
@ -228,6 +367,7 @@ async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Re
.bytes()
.await
.with_context(|| format!("reading body of {url}"))?;
let bytes = prepare_cached_script_bytes(kind, &bytes);
let mut file = tokio::fs::File::create(&tmp_path)
.await
@ -270,4 +410,93 @@ mod tests {
assert_eq!(sanitize_ref("main"), "main");
assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
}
#[test]
fn prepare_cached_ps1_prefixes_utf8_bom() {
let out = prepare_cached_script_bytes(ScriptKind::Ps1, b"Write-Host hi\n");
assert!(out.starts_with(UTF8_BOM), "cached .ps1 must start with UTF-8 BOM");
assert_eq!(&out[UTF8_BOM.len()..], b"Write-Host hi\n");
}
#[test]
fn prepare_cached_ps1_does_not_double_bom() {
let mut already = UTF8_BOM.to_vec();
already.extend_from_slice(b"x");
let out = prepare_cached_script_bytes(ScriptKind::Ps1, &already);
assert_eq!(out, already);
assert_eq!(out.windows(3).filter(|w| *w == UTF8_BOM).count(), 1);
}
#[test]
fn prepare_cached_sh_stays_bomless() {
let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/bin/bash\n");
assert!(!out.starts_with(UTF8_BOM));
assert_eq!(out, b"#!/bin/bash\n");
}
#[test]
fn commit_pins_are_immutable_branch_pins_are_not() {
// Mirrors the resolve() immutable decision: SHA pins may reuse cache
// forever; branch pins must refresh so Retry cannot keep a bad script.
assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
assert!(!is_valid_commit("main"));
assert!(!is_valid_commit("release/1.2.3"));
}
#[test]
fn existing_branch_cache_plans_refresh_with_stale_fallback() {
// Resolver-level: a prior install-main.ps1 must not short-circuit
// Retry — mutable pins refresh, and only fall back if download fails.
assert_eq!(
cache_plan(/*immutable=*/ false, /*cached_exists=*/ true),
CachePlan::Fetch { stale_ok: true }
);
assert_eq!(
cache_plan(/*immutable=*/ true, /*cached_exists=*/ true),
CachePlan::Reuse
);
assert_eq!(
cache_plan(/*immutable=*/ false, /*cached_exists=*/ false),
CachePlan::Fetch { stale_ok: false }
);
assert_eq!(
cache_plan(/*immutable=*/ true, /*cached_exists=*/ false),
CachePlan::Fetch { stale_ok: false }
);
}
#[test]
fn upgrade_cached_script_adds_bom_to_legacy_ps1() {
// A .ps1 cached by a pre-#67193 installer has no BOM; the Reuse path
// must upgrade it in place instead of serving the broken bytes forever.
let dir = std::env::temp_dir().join(format!("hermes-bom-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cached = dir.join("install-abc1234.ps1");
std::fs::write(&cached, b"Write-Host legacy\n").unwrap();
upgrade_cached_script(ScriptKind::Ps1, &cached, &|_| {});
let bytes = std::fs::read(&cached).unwrap();
assert!(bytes.starts_with(UTF8_BOM), "legacy cache must gain a BOM");
assert_eq!(&bytes[UTF8_BOM.len()..], b"Write-Host legacy\n");
// Idempotent: a second pass must not double the BOM.
upgrade_cached_script(ScriptKind::Ps1, &cached, &|_| {});
let again = std::fs::read(&cached).unwrap();
assert_eq!(again, bytes);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn upgrade_cached_script_leaves_sh_untouched() {
let dir = std::env::temp_dir().join(format!("hermes-bom-sh-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cached = dir.join("install-main.sh");
std::fs::write(&cached, b"#!/bin/bash\n").unwrap();
upgrade_cached_script(ScriptKind::Sh, &cached, &|_| {});
assert_eq!(std::fs::read(&cached).unwrap(), b"#!/bin/bash\n");
std::fs::remove_dir_all(&dir).unwrap();
}
}

View file

@ -13,6 +13,103 @@ use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
/// CP1252 mapping for bytes `0x80..=0x9F` (the range that differs from Latin-1).
/// Undefined slots keep the C1 control code points, matching Windows-1252
/// best-fit behavior used by `encoding_rs::WINDOWS_1252`.
const CP1252_80_9F: [char; 32] = [
'\u{20AC}', // 0x80 €
'\u{0081}', // 0x81
'\u{201A}', // 0x82
'\u{0192}', // 0x83 ƒ
'\u{201E}', // 0x84 „
'\u{2026}', // 0x85 …
'\u{2020}', // 0x86 †
'\u{2021}', // 0x87 ‡
'\u{02C6}', // 0x88 ˆ
'\u{2030}', // 0x89 ‰
'\u{0160}', // 0x8A Š
'\u{2039}', // 0x8B
'\u{0152}', // 0x8C Œ
'\u{008D}', // 0x8D
'\u{017D}', // 0x8E Ž
'\u{008F}', // 0x8F
'\u{0090}', // 0x90
'\u{2018}', // 0x91
'\u{2019}', // 0x92
'\u{201C}', // 0x93 “
'\u{201D}', // 0x94 ”
'\u{2022}', // 0x95 •
'\u{2013}', // 0x96
'\u{2014}', // 0x97 —
'\u{02DC}', // 0x98 ˜
'\u{2122}', // 0x99 ™
'\u{0161}', // 0x9A š
'\u{203A}', // 0x9B
'\u{0153}', // 0x9C œ
'\u{009D}', // 0x9D
'\u{017E}', // 0x9E ž
'\u{0178}', // 0x9F Ÿ
];
fn decode_cp1252_byte(b: u8) -> char {
match b {
0x00..=0x7F => b as char,
0x80..=0x9F => CP1252_80_9F[(b - 0x80) as usize],
// 0xA0..=0xFF match Unicode Latin-1 / Windows-1252.
_ => b as char,
}
}
/// Decode one stdout/stderr line from a child process.
///
/// Tokio's `BufReader::lines()` requires valid UTF-8 and aborts the line (with
/// `stream did not contain valid UTF-8`) at the first accented byte. Windows
/// PowerShell 5.1 emits localized ParserError text in the console ANSI code
/// page (often CP1252), so Portuguese/Spanish/etc. users only saw a truncated
/// `No` instead of `Não foi fornecido o terminador...` (#67193).
///
/// Prefer UTF-8 when the bytes are valid; otherwise decode as Windows-1252 so
/// both Western-European letters and CP1252-only punctuation (e.g. `0x91` →
/// U+2018) survive rather than disappearing into a read-error warning.
pub(crate) fn decode_console_bytes(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => bytes.iter().copied().map(decode_cp1252_byte).collect(),
}
}
/// Read one line (LF or CRLF) and decode it with [`decode_console_bytes`].
/// Returns `Ok(None)` on EOF with no bytes pending.
pub(crate) async fn read_decoded_line<R>(
reader: &mut R,
buf: &mut Vec<u8>,
) -> std::io::Result<Option<String>>
where
R: AsyncBufReadExt + Unpin,
{
// Cancel-safety: `buf` is NOT cleared on entry. When this future is
// dropped mid-read inside `tokio::select!` (the other stream produced a
// line first), `read_until` has already appended any consumed bytes to
// `buf`; the next call resumes and appends the rest of the line. Clearing
// on entry would silently drop those bytes. We clear only after a full
// line has been decoded.
let n = reader.read_until(b'\n', buf).await?;
if n == 0 && buf.is_empty() {
return Ok(None);
}
// n == 0 with a non-empty buf means EOF cut off an unterminated line
// (possibly accumulated across cancelled reads) -- emit it.
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
let line = decode_console_bytes(buf);
buf.clear();
Ok(Some(line))
}
/// Hooks the caller installs to receive output.
pub struct StreamSink {
pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
@ -77,8 +174,13 @@ pub async fn run_script(
let stdout = child.stdout.take().expect("stdout was piped");
let stderr = child.stderr.take().expect("stderr was piped");
let mut stdout_reader = BufReader::new(stdout).lines();
let mut stderr_reader = BufReader::new(stderr).lines();
// Byte-oriented readers + [`decode_console_bytes`]: do NOT use
// `BufReader::lines()`, which requires valid UTF-8 and hides localized
// PowerShell errors on non-English Windows (#67193).
let mut stdout_reader = BufReader::new(stdout);
let mut stderr_reader = BufReader::new(stderr);
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let mut combined_stdout = String::new();
let mut combined_stderr = String::new();
@ -87,7 +189,7 @@ pub async fn run_script(
// Loop: poll stdout, stderr, cancel, and child exit concurrently.
loop {
tokio::select! {
line = stdout_reader.next_line() => {
line = read_decoded_line(&mut stdout_reader, &mut stdout_buf) => {
match line {
Ok(Some(l)) => {
(sink.on_stdout_line)(&l);
@ -104,7 +206,7 @@ pub async fn run_script(
}
}
}
line = stderr_reader.next_line() => {
line = read_decoded_line(&mut stderr_reader, &mut stderr_buf) => {
match line {
Ok(Some(l)) => {
(sink.on_stderr_line)(&l);
@ -130,12 +232,12 @@ pub async fn run_script(
}
// Drain remaining lines after the loop exited.
while let Ok(Some(l)) = stdout_reader.next_line().await {
while let Ok(Some(l)) = read_decoded_line(&mut stdout_reader, &mut stdout_buf).await {
(sink.on_stdout_line)(&l);
combined_stdout.push_str(&l);
combined_stdout.push('\n');
}
while let Ok(Some(l)) = stderr_reader.next_line().await {
while let Ok(Some(l)) = read_decoded_line(&mut stderr_reader, &mut stderr_buf).await {
(sink.on_stderr_line)(&l);
combined_stderr.push_str(&l);
combined_stderr.push('\n');
@ -354,4 +456,98 @@ info line
"unexpected powershell path: {normalized}"
);
}
#[test]
fn decode_console_bytes_keeps_valid_utf8() {
assert_eq!(decode_console_bytes("café — ok".as_bytes()), "café — ok");
}
#[test]
fn decode_console_bytes_preserves_cp1252_portuguese_error() {
// "Não foi fornecido o terminador..." as Windows PowerShell 5.1 emits
// under CP1252 (0xE3 = ã). BufReader::lines() previously failed here
// with "stream did not contain valid UTF-8" and the UI only showed "No".
let bytes: &[u8] = b"N\xE3o foi fornecido o terminador";
assert_eq!(decode_console_bytes(bytes), "Não foi fornecido o terminador");
}
#[test]
fn decode_console_bytes_maps_cp1252_only_punctuation() {
// 0x91/0x92 are curly quotes in Windows-1252, but C1 controls under
// Latin-1 (`b as char`). This locks the real CP1252 fallback.
let bytes: &[u8] = b"say \x91hi\x92";
assert_eq!(decode_console_bytes(bytes), "say \u{2018}hi\u{2019}");
assert_ne!(
decode_console_bytes(bytes),
bytes.iter().map(|&b| b as char).collect::<String>(),
"Latin-1 byte mapping must not be used for the 0x80..=0x9F range"
);
}
#[tokio::test]
async fn read_decoded_line_survives_non_utf8_and_crlf() {
let data: &[u8] = b"N\xE3o erro\r\nnext\n";
let mut reader = BufReader::new(data);
let mut buf = Vec::new();
assert_eq!(
read_decoded_line(&mut reader, &mut buf)
.await
.unwrap()
.as_deref(),
Some("Não erro")
);
assert_eq!(
read_decoded_line(&mut reader, &mut buf)
.await
.unwrap()
.as_deref(),
Some("next")
);
assert!(read_decoded_line(&mut reader, &mut buf)
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn read_decoded_line_preserves_partial_line_across_cancellation() {
use std::time::Duration;
use tokio::io::AsyncWriteExt;
let (mut tx, rx) = tokio::io::duplex(64);
let mut reader = BufReader::new(rx);
let mut buf = Vec::new();
tx.write_all(b"partial").await.unwrap();
// Poll once, then cancel (drop) the future -- exactly what
// tokio::select! does in run_script when the other stream produces
// a line first. The consumed bytes must survive in `buf`.
let _ = tokio::time::timeout(
Duration::from_millis(0),
read_decoded_line(&mut reader, &mut buf),
)
.await;
tx.write_all(b" line\n").await.unwrap();
let line = read_decoded_line(&mut reader, &mut buf).await.unwrap();
assert_eq!(line.as_deref(), Some("partial line"));
}
#[tokio::test]
async fn read_decoded_line_emits_unterminated_final_line_at_eof() {
let data: &[u8] = b"no trailing newline";
let mut reader = BufReader::new(data);
let mut buf = Vec::new();
assert_eq!(
read_decoded_line(&mut reader, &mut buf)
.await
.unwrap()
.as_deref(),
Some("no trailing newline")
);
assert!(read_decoded_line(&mut reader, &mut buf)
.await
.unwrap()
.is_none());
}
}

View file

@ -31,10 +31,11 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::io::BufReader;
use tokio::process::Command;
use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
use crate::powershell::read_decoded_line;
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
@ -662,28 +663,31 @@ async fn run_streamed(
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let mut out = BufReader::new(stdout).lines();
let mut err = BufReader::new(stderr).lines();
// Same non-UTF-8-safe decode path as powershell::run_script (#67193).
let mut out = BufReader::new(stdout);
let mut err = BufReader::new(stderr);
let mut out_buf = Vec::new();
let mut err_buf = Vec::new();
let stage_owned = stage.map(|s| s.to_string());
loop {
tokio::select! {
line = out.next_line() => match line {
line = read_decoded_line(&mut out, &mut out_buf) => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
Ok(None) => break,
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
},
line = err.next_line() => match line {
line = read_decoded_line(&mut err, &mut err_buf) => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),
Ok(None) => {}
Err(e) => { tracing::warn!("stderr read error: {e}"); }
},
}
}
while let Ok(Some(l)) = out.next_line().await {
while let Ok(Some(l)) = read_decoded_line(&mut out, &mut out_buf).await {
emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);
}
while let Ok(Some(l)) = err.next_line().await {
while let Ok(Some(l)) = read_decoded_line(&mut err, &mut err_buf).await {
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
}

View file

@ -125,9 +125,11 @@ normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **One-time credentials are never reused.** An OAuth gateway connection mints a
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
not "fall back to the cached URL." Only long-lived token/local auth may reuse
a cached URL as a lower rung.
fresh WebSocket ticket on every dial and never falls back to the cached URL.
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
reauthentication; timeout, network, malformed-response, and server failures
remain connectivity errors. Only long-lived token/local auth may reuse a
cached URL as a lower rung.
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."

View file

@ -0,0 +1,53 @@
/**
* E2E boot-failure tests verify the app shows an error overlay when the
* backend can't start.
*
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
* resolution fails with a controlled error message. The app should show the
* BootFailureOverlay with retry/repair actions.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { allowErrorBanners, test } from './test'
import {
type DeadBackendFixture,
setupDeadBackend,
waitForBootFailure,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: DeadBackendFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('boot failure with dead backend', () => {
test.beforeEach(() => {
// These tests deliberately trigger boot errors — error banners
// (notifyError → [role="alert"]) are expected, not failures.
allowErrorBanners()
})
test('app shows error state', async () => {
// Inject a fake boot error so the backend resolution "fails" with a
// controlled error message. This is the only reliable way to trigger
// BootFailureOverlay in dev mode.
fixture = await setupDeadBackend({ fakeError: true })
await waitForBootFailure(fixture.page, 90_000)
})
test('screenshot of error state', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
})
})

View file

@ -0,0 +1,63 @@
/**
* E2E smoke tests for the dev-mode desktop app.
*
* These tests launch the Electron app from the built dist/ (not the
* packaged binary) with a real `hermes serve` backend pointed at a mock
* inference server. The full chain is exercised:
*
* electron hermes serve (python) mock provider renderer
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Run from the nix devshell:
* npm exec playwright test e2e/boot.spec.ts --reporter=list
*/
import { expect, test } from './test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('dev-mode boot with mock backend', () => {
test('window opens with Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer mounts and shows DOM content', async () => {
const page = fixture!.page
// Wait for the React root to mount. The app renders into #root
// (see src/main.tsx), but content may arrive through portals — so
// check the body for any interactive content instead.
await page.waitForSelector('body', { state: 'attached' })
// Wait for the main app shell — the composer is always present.
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: 30_000,
})
})
test('backend boots and app becomes ready', async () => {
// This is the big one — wait for the full boot chain to complete:
// electron starts → hermes serve is spawned → WS connects → config
// loaded → sessions loaded → boot overlay dismissed → composer visible.
await waitForAppReady(fixture!, 120_000)
})
test('screenshot after boot', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
})
})

View file

@ -0,0 +1,138 @@
/**
* E2E chat tests send a message and verify a response appears.
*
* Requires the full boot chain to complete (hermes serve + mock inference
* provider). The mock server returns a canned reply, so we verify the
* response text shows up in the chat transcript.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('chat interaction with mock backend', () => {
test('send a message and receive a response', async () => {
const page = fixture!.page
// Find the composer — it's a contenteditable textbox.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
// Click to focus, then type the message character by character.
// Using `type` instead of `fill` because the composer is a
// contenteditable div with custom keydown handling that tracks
// IME composition state — `fill` bypasses the event chain.
await composer.click()
await composer.type('Hello, can you hear me?', { delay: 20 })
// Submit with Enter — the composer's keydown handler intercepts
// plain Enter (without Shift) and calls submitDraft().
await page.keyboard.press('Enter')
// Wait for the user's message to appear in the transcript.
// The message renders as an assistant-ui message in the chat view.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
return (body.textContent ?? '').includes('Hello, can you hear me?')
},
undefined,
{ timeout: 15_000 }
)
// Wait for the mock response to appear. The canned reply is:
// "Hello from the mock inference server! The full boot chain is working."
// Give it a generous timeout — the inference request goes through the
// gateway → hermes serve → mock server → streaming SSE back.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
const text = body.textContent ?? ''
return text.includes('mock inference server') || text.includes('boot chain is working')
},
undefined,
{ timeout: 60_000 }
)
})
test('screenshot of chat with messages', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
})
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
const page = fixture!.page
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
const speakReplies = page.locator(
'[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
)
await composer.click()
await composer.type(BLOCKING_CLARIFY_TRIGGER)
await page.keyboard.press('Enter')
await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
await expect(primary).toHaveAttribute('aria-label', 'Stop')
await expect(primary.locator('span')).toHaveClass(/bg-current/)
await composer.click()
await composer.type('please answer tersely')
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await expect(dictation).toBeVisible()
await expect(speakReplies).toBeVisible()
await expect(queue).toBeVisible()
await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
const controlLabels = await page
.locator('[data-slot="composer-root"] button')
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
const speakRepliesIndex = controlLabels.findIndex(
label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
)
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
expect(controlLabels.indexOf('Queue message')).toBeLessThan(
controlLabels.findIndex(label => label?.startsWith('Steer'))
)
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible()
await queue.click()
await expect(primary).toHaveAttribute('aria-label', 'Stop')
await expect(queue).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
await expect(page.getByText('1 Queued')).toBeVisible()
await primary.click()
await expect(page.getByText('1 Queued — paused')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
})
})

View file

@ -0,0 +1,210 @@
/**
* Regression coverage for a correction sent during a live response, then a
* warm session switch away and back. The correction is an accepted user turn,
* not an optimistic duplicate of the original prompt, and its relative place
* in the transcript must survive the resume reconciliation.
*/
import { type TestInfo } from '@playwright/test'
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from './mock-server'
const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.'
const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.`
const CORRECTION = 'E2E correction must stay after the original prompt.'
const TOOL_STARTED = 'Checking the long-running task before I continue.'
const CORRECTED_REPLY = 'The corrected task finished.'
const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
}
async function waitForTranscriptText(page: Page, text: string): Promise<void> {
await page.waitForFunction(
(expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
text,
{ timeout: 30_000 },
)
}
async function textNodeOccurrences(page: Page, text: string): Promise<number> {
return page.evaluate((expected: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return 0
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
let count = 0
while (walker.nextNode()) {
if (walker.currentNode.textContent?.includes(expected)) {
count += 1
}
}
return count
}, text)
}
async function transcriptTextOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="message"], [data-message-id]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
await page.waitForFunction(
(priorText: string) => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(priorText),
priorSessionText,
{ timeout: 15_000 },
)
}
async function openSidebarSession(page: Page, sidebarText: string, expectedTranscriptText: string): Promise<void> {
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: sidebarText }).first()
await row.waitFor({ state: 'visible', timeout: 30_000 })
await row.click()
await waitForTranscriptText(page, expectedTranscriptText)
}
async function reopenOriginalSession(page: Page): Promise<void> {
// A still-running tool has not generated a final title yet, so the sidebar
// retains the source prompt as its provisional session title.
await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT)
}
async function reopenInferenceSession(page: Page): Promise<void> {
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first()
await row.waitFor({ state: 'visible', timeout: 30_000 })
await row.click()
await waitForTranscriptText(page, INFERENCE_PROMPT)
}
function relevantOrder(messages: string[]): string[] {
return messages.filter(message => message.includes(ORIGINAL_PROMPT) || message.includes(CORRECTION))
}
function steerTurnOrder(messages: string[]): string[] {
return messages.flatMap(message => {
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
if (message.includes(CORRECTION)) return [CORRECTION]
if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY]
return []
})
}
test.describe('correction session switch', () => {
let fixture: MockBackendFixture | null = null
test.beforeEach(async () => {
fixture = await setupMockBackend({
mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER },
})
await waitForAppReady(fixture, 120_000)
})
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('keeps a live correction in place and does not duplicate its original prompt after switching sessions', async ({}, testInfo: TestInfo) => {
const { page } = fixture!
// A blank draft does not exercise session hydration. Seed a real second
// session first, matching the observed switch between two saved chats.
await send(page, OTHER_SESSION_PROMPT)
await waitForTranscriptText(page, MOCK_REPLY)
await openFreshDraft(page, OTHER_SESSION_PROMPT)
await send(page, ORIGINAL_PROMPT)
await waitForTranscriptText(page, TOOL_STARTED)
await waitForTranscriptText(page, ORIGINAL_PROMPT)
// The historical session redirects while a foreground terminal task is
// running. Use the visible Steer action to cover the real composer path.
await steer(page, CORRECTION)
await waitForTranscriptText(page, CORRECTION)
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
expect(orderBeforeSwitch).toEqual([ORIGINAL_PROMPT, CORRECTION])
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
await page.screenshot({ path: testInfo.outputPath('correction-before-session-switch.png') })
// Reproduce the observed race: switch to another persisted session while
// the foreground tool is live, then return before its redirect settles.
await openSidebarSession(page, MOCK_REPLY, OTHER_SESSION_PROMPT)
await reopenOriginalSession(page)
await page.waitForTimeout(500)
await page.screenshot({ path: testInfo.outputPath('correction-after-warm-resume.png') })
expect(relevantOrder(await transcriptTextOrder(page))).toEqual(orderBeforeSwitch)
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
await waitForTranscriptText(page, CORRECTED_REPLY)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY])
})
test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => {
const { mock, page } = fixture!
await send(page, OTHER_SESSION_PROMPT)
await waitForTranscriptText(page, MOCK_REPLY)
await openFreshDraft(page, OTHER_SESSION_PROMPT)
await send(page, INFERENCE_PROMPT)
await mock.waitForHeldStream()
await waitForTranscriptText(page, INFERENCE_PROMPT)
await send(page, INFERENCE_CORRECTION)
await waitForTranscriptText(page, INFERENCE_CORRECTION)
await openSidebarSession(page, MOCK_REPLY, OTHER_SESSION_PROMPT)
await reopenInferenceSession(page)
expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1)
expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1)
await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') })
mock.releaseHeldStream()
await waitForTranscriptText(page, MOCK_REPLY)
})
})

View file

@ -0,0 +1,72 @@
/**
* Monkey-patch: playwright's test runner never calls tracing.start() on
* Electron's internal BrowserContext because:
* 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
* contexts Electron's context is excluded.
* 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
* beforeAll launches the electron app.
* 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
* class (only on BrowserType).
*
* As a result, trace screenshots (screencast) and DOM snapshots are never
* captured for electron tests.
*
* This patch:
* 1. Patches _allContexts() to include electron contexts, so the test
* runner's didFinishTest() cleanup calls _stopTracing() stopChunk()
* on the electron context (saving the trace chunk + merging it into
* the final trace.zip).
* 2. Manually calls tracing.start() + startChunk() after launch.
* 3. Wraps tracing.start to become startChunk after the first call,
* so the test runner's willStartTest doesn't throw "already started".
*
* Imported from playwright.config.ts so it runs before any test.
*
* Pinned dependency: this file reaches into Playwright internals (_playwright,
* _allContexts, _context) that have no public contract. @playwright/test is
* pinned exact (=1.58.2 in package.json) so a bump can't silently break the
* monkeypatch. When bumping, re-verify these private symbols still exist on
* the Electron / PlaywrightInternal classes and that tracing still merges.
*/
import { _electron as electron, type BrowserContext } from '@playwright/test'
import * as crypto from 'node:crypto'
const electronContexts = new Set<BrowserContext>()
const originalLaunch = electron.launch.bind(electron)
electron.launch = async (options: any) => {
const app = await originalLaunch(options)
const ctx = (app as any)._context as BrowserContext
electronContexts.add(ctx)
ctx.once('close', () => electronContexts.delete(ctx))
// Patch _allContexts so the test runner sees the electron context
// (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
const pw = (electron as any)._playwright as any
if (pw && !pw.__electronTracingPatched) {
pw.__electronTracingPatched = true
const original = pw._allContexts.bind(pw)
pw._allContexts = () => [...original(), ...electronContexts]
}
// Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
const traceName = crypto.randomUUID()
await ctx.tracing.start({
screenshots: true,
snapshots: true,
sources: true,
}).catch(() => {})
await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
// Wrap tracing.start to redirect to startChunk after the first call.
// The test runner's willStartTest calls tracing.start() on all contexts
// in _allContexts(). Since we already started, redirect to startChunk
// to avoid "Tracing has been already started" errors.
const tracing = ctx.tracing as any
tracing.start = async (opts: any) => {
return tracing.startChunk(opts)
}
return app
}

View file

@ -0,0 +1,703 @@
/**
* Shared E2E fixtures for the Hermes desktop Playwright suite.
*
* Two fixture modes:
*
* 1. `mockBackend` starts a mock inference server, writes a config.yaml
* that points at it, and launches the desktop app so the full chain
* (electron hermes serve provider inference renderer) is
* exercised with a real backend but a fake LLM.
*
* 2. `noProvider` launches the app with an empty config (no provider
* configured). The onboarding overlay should appear. Used to test the
* first-run flow without real credentials.
*
* Both modes launch the *dev* Electron app (`electron .` against the built
* `dist/`), not the packaged binary. This avoids the multi-minute
* `electron-builder --dir` step and matches `hermes desktop --source`. The
* packaged-binary path is already covered by `launch.spec.ts`.
*
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer, type MockServerOptions } from './mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
const CREDENTIAL_SUFFIXES: string[] = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_CREDENTIALS',
'_ACCESS_KEY',
'_PRIVATE_KEY',
'_OAUTH_TOKEN',
]
const CREDENTIAL_NAMES = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'CUSTOM_API_KEY',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
'OPENROUTER_BASE_URL',
'OLLAMA_BASE_URL',
'GROQ_BASE_URL',
'XAI_BASE_URL',
])
function isCredentialEnvVar(name: string): boolean {
if (CREDENTIAL_NAMES.has(name)) {
return true
}
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
}
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
const clean: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (!value) {
continue
}
if (isCredentialEnvVar(key)) {
continue
}
clean[key] = value
}
return clean
}
// ─── Sandbox creation ──────────────────────────────────────────────────
export interface Sandbox {
root: string
hermesHome: string
userDataDir: string
cleanup: () => void
}
export function createSandbox(prefix: string): Sandbox {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
// Write a fixed window-state.json so the Electron window opens at a
// consistent size — helps with visual regression screenshots. The
// exact size is also enforced right before each screenshot (see
// expectVisualSnapshot in visual-snapshot.ts) because window managers
// may resize after launch.
fs.writeFileSync(
path.join(userDataDir, 'window-state.json'),
JSON.stringify(
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
null,
2,
),
'utf8',
)
return {
root,
hermesHome,
userDataDir,
cleanup: () => {
try {
fs.rmSync(root, { recursive: true, force: true })
} catch {
// best-effort
}
},
}
}
// ─── Config writing ─────────────────────────────────────────────────────
/**
* Write a config.yaml that pre-configures a mock provider pointing at the
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*
* @param extraConfig optional YAML lines appended to the `display:` section,
* used by the interim-message e2e test to toggle
* `display.interim_assistant_messages`.
*/
export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const displaySection = extraConfig
? `\ndisplay:\n${extraConfig}\n`
: ''
const config = `# Auto-generated by E2E test fixtures
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
${displaySection}`
fs.writeFileSync(configPath, config, 'utf8')
}
/**
* Write a minimal .env with the mock API key. The key_env in config.yaml
* references MOCK_API_KEY, so the backend resolves credentials from here.
*/
export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
const envPath = path.join(hermesHome, '.env')
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
}
/**
* Write an empty config (no providers). The desktop app should show the
* onboarding overlay because no inference provider is configured.
*/
function writeEmptyConfig(hermesHome: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
}
// ─── Env building ──────────────────────────────────────────────────────
/**
* Build the environment for the Electron app process.
*
* Key env vars:
* - HERMES_HOME sandbox hermes-home (isolated config/sessions)
* - HERMES_DESKTOP_USER_DATA_DIR sandbox electron-user-data
* - HERMES_DESKTOP_IGNORE_EXISTING=1 don't pick up `hermes` from PATH
* (we want the dev checkout at REPO_ROOT)
* - HERMES_DESKTOP_HERMES_ROOT REPO_ROOT (dev checkout resolution)
* - HERMES_DESKTOP_APP_NAME unique-ish per test (avoids single-instance lock)
* - XDG_RUNTIME_DIR ensure Electron has a writable runtime dir on Linux
*/
export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
const clean = stripCredentials(process.env)
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
// headless/CI context — without it the zygote may fail to initialize.
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
}
// DISPLAY — needed for Electron to open a window.
if (!clean.DISPLAY && process.env.DISPLAY) {
clean.DISPLAY = process.env.DISPLAY
}
return {
...clean,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
// Clear dev-server override — we want the built dist/, not a vite server.
// The dev-server check in main.ts looks for this env var; if it's set,
// it loads from the vite URL instead of the local file.
...extra,
}
}
// ─── Electron launch ────────────────────────────────────────────────────
/**
* Verify that the desktop app has been built (dist/ exists). Playwright
* tests can't run without it the Electron main process loads
* dist/electron-main.mjs and the renderer loads dist/index.html.
*/
function assertDistBuilt(): void {
const distDir = path.join(DESKTOP_ROOT, 'dist')
const electronMain = path.join(distDir, 'electron-main.mjs')
const indexHtml = path.join(distDir, 'index.html')
if (!fs.existsSync(electronMain)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
if (!fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${indexHtml}`,
)
}
}
/**
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
* As a fallback, use the node_modules/.bin/electron from the desktop package.
*/
export function findElectron(): string {
// In dev mode, we use the `electron` binary directly (not the packaged app).
// The dev:electron script in package.json does exactly this: `electron .`
// after building. We replicate that here.
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(localElectron)) {
return localElectron
}
// Fall back to PATH
const result = spawnSync('which', ['electron'], {
encoding: 'utf8',
})
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim()
}
throw new Error(
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
)
}
/**
* Launch the desktop app in dev mode.
*
* @param sandbox - isolated HERMES_HOME + userData
* @param env - the process environment (already has HERMES_HOME etc.)
* @returns the ElectronApplication + first Page
*/
export async function launchDesktop(
env: Record<string, string>,
): Promise<{ app: ElectronApplication; page: Page }> {
assertDistBuilt()
const electronBin = findElectron()
// `electron .` loads from the package.json `main` field
// (dist/electron-main.mjs after build).
const app = await _electron.launch({
executablePath: electronBin,
args: [
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
'--disable-gpu',
'--no-sandbox',
],
env,
cwd: DESKTOP_ROOT,
})
const page = await app.firstWindow()
// Install the error-banner guard so any [role="alert"] that appears
// during a test is collected and surfaced in afterEach.
installErrorBannerGuard(page)
return { app, page }
}
// ─── Public fixtures ────────────────────────────────────────────────────
export interface MockBackendFixture {
app: ElectronApplication
page: Page
mock: Awaited<ReturnType<typeof startMockServer>>
mockUrl: string
sandbox: Sandbox
cleanup: () => Promise<void>
}
export interface MockBackendOptions {
/**
* Optional YAML lines to inject under the `display:` section of the
* generated config.yaml. Used by the interim-message e2e test to toggle
* `display.interim_assistant_messages`.
*/
extraDisplayConfig?: string
}
/**
* Set up a full mock-backend E2E environment:
* 1. Start the mock inference server
* 2. Create a sandbox with config.yaml pointing at it
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export interface MockBackendOptions {
mockServer?: MockServerOptions
}
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer(options.mockServer)
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
export interface NoProviderFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the app with no provider configured. The onboarding overlay should
* appear because there's no inference provider in config.yaml.
*/
export async function setupNoProvider(): Promise<NoProviderFixture> {
const sandbox = createSandbox('noprovider')
writeEmptyConfig(sandbox.hermesHome)
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
export interface DeadBackendFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
export interface DeadBackendOptions {
/**
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
* so the backend resolution itself "fails" with a controlled error message.
* This is the only reliable way to trigger BootFailureOverlay in dev mode
* (the real backend always resolves via SOURCE_REPO_ROOT).
*/
fakeError?: boolean
}
/**
* Launch the app with a provider pointing at a dead endpoint (port 1, which
* nothing listens on). By default the backend still boots (`hermes serve`
* starts fine the dead endpoint only matters at chat time). Pass
* `{ fakeError: true }` to inject a fake boot failure, triggering the
* BootFailureOverlay.
*/
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
const sandbox = createSandbox('dead')
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
fs.writeFileSync(
configPath,
`# Auto-generated by E2E test fixtures — dead provider
model:
default: mock-model
provider: mock
providers:
mock:
api: http://127.0.0.1:1/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
writeEnvFile(sandbox.hermesHome)
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Packaged-binary fixture ───────────────────────────────────────────
/**
* Resolve the packaged Electron binary path, per-platform, matching
* electron-builder's output layout under release/.
*/
function resolvePackagedBinaryPath(): string {
if (process.platform === 'win32') {
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
}
if (process.platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
}
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
}
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
export function packagedBinaryExists(): boolean {
return fs.existsSync(PACKAGED_BINARY_PATH)
}
export interface PackagedAppFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the *packaged* Electron binary (from `npm run pack`
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
* progress without spawning a real Hermes backend.
*
* Uses the same sandbox isolation (credential stripping, isolated
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
*
* Skips if the packaged binary doesn't exist run `npm run pack` first.
*/
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
if (!packagedBinaryExists()) {
throw new Error(
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
}
const sandbox = createSandbox('packaged')
// Build the sandbox env using the shared helpers, then add the
// packaged-binary-specific overrides.
const env = buildAppEnv(sandbox, {
// Fake boot: simulates progress steps without spawning the real backend.
HERMES_DESKTOP_BOOT_FAKE: '1',
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
})
// Clear dev-server + hermes-root overrides — the packaged binary
// should use its own bundled renderer, not the dev checkout.
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
const app = await _electron.launch({
executablePath: PACKAGED_BINARY_PATH,
args: ['--disable-gpu', '--no-sandbox'],
env,
})
const page = await app.firstWindow()
installErrorBannerGuard(page)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Wait helpers ──────────────────────────────────────────────────────
/**
* Wait for the desktop app to finish booting and show the main chat UI.
*
* The boot overlay disappears when `completeDesktopBoot()` fires in the
* renderer at that point the gateway is open, config is loaded, and
* sessions are loaded. We detect this by waiting for the boot/connecting
* overlay to become invisible and the main app shell to be present.
*
* Two things must both be true before we return:
* 1. The composer (chat input) is visible it's disabled until the
* gateway is open.
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
* boot-failure) covers the viewport center. The composer can be
* "visible" in Playwright's eyes (non-zero bounding box, not
* display:none) even when a z-1300+ overlay is painted on top of it,
* so checking the composer alone catches the app mid-boot at ~92%
* with the loading bar still showing.
*/
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
const { page, app } = fixture
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: timeoutMs,
})
// Now poll until no full-screen overlay covers the viewport center.
// elementFromPoint returns the topmost element at a point — if it's part
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
// app isn't ready yet.
await page.waitForFunction(
() => {
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
if (!el) {
return false
}
// Walk up to the nearest positioned ancestor — overlays are
// `position: fixed; inset: 0`. If the hit element or an ancestor
// is a full-viewport fixed overlay, we're still covered.
let node: Element | null = el
while (node) {
const cs = window.getComputedStyle(node)
if (cs.position === 'fixed') {
const rect = node.getBoundingClientRect()
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
return false
}
}
node = node.parentElement
}
return true
},
undefined,
{ timeout: timeoutMs },
)
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
// and the window stays hidden even though the DOM is rendered. The main
// process has a TEST_WORKER_INDEX-gated fallback that force-shows the
// window, but the DOM can be ready before that fires. Poll until the
// window is actually visible so interactions (click, screenshot) don't
// hit a hidden surface.
if (app) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const visible = await app.evaluate(({ BrowserWindow }) => {
const w = BrowserWindow.getAllWindows()[0]
return w ? w.isVisible() : false
}).catch(() => false)
if (visible) {break}
await page.waitForTimeout(500)
}
}
}
/**
* Wait for the onboarding overlay to appear (no provider configured).
*/
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
// The onboarding overlay contains a heading with "Choose your provider"
// or similar text. We look for any text that indicates the picker.
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
return (
text.includes('provider') ||
text.includes('Provider') ||
text.includes('Choose') ||
text.includes('API key') ||
text.includes('Sign in')
)
},
undefined,
{ timeout: timeoutMs },
)
}
/**
* Wait for the boot failure overlay to appear.
*/
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
await page.waitForFunction(
() => {
// Boot failure is terminal: the backend gave up. The renderer shows
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
// falls back to the onboarding picker (z-1300) as a recovery path.
// We wait for the failure dialog itself — the Preparing component may
// still paint its progress bar (recolored red) underneath the overlay,
// which is harmless.
const text = document.body.textContent ?? ''
// BootFailureOverlay buttons.
const hasFailureUI =
text.includes('Retry') ||
text.includes('Repair') ||
text.includes('Use local gateway') ||
text.includes('Connection settings')
// The error toast / notification that fires on failDesktopBoot().
const hasErrorToast = text.includes('Desktop boot failed')
return hasFailureUI || hasErrorToast
},
undefined,
{ timeout: timeoutMs },
)
}

View file

@ -0,0 +1,215 @@
/**
* E2E test for the interim-assistant-message preservation fix (#65919).
*
* Reproduces the bug across all three layers (agent core tui_gateway
* desktop renderer): when the agent emits assistant text alongside a tool
* call, then completes the turn with a *different* final answer, the
* interim text must survive in the transcript not be wiped when
* message.complete replaces the streaming bubble.
*
* The mock server walks through a multi-turn script when it sees the
* trigger keyword:
*
* Turn 1: "Let me start by planning the approach." + todo tool_call
* Turn 2: "Now checking the details before answering." + todo tool_call
* Turn 3: (no text) + todo tool_call NO interim (no visible text)
* Turn 4: "Found something interesting worth noting." + todo tool_call
* Turn 5: "All done! Here is the complete summary..." (final, stop)
*
* Two describe blocks exercise the config flag both ways:
*
* display.interim_assistant_messages: true (default)
* ALL interim texts AND the final text must be visible in the
* transcript.
*
* display.interim_assistant_messages: false
* only the final text is visible (no message.interim events emitted,
* so all streamed interim text is replaced at message.complete).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
// ─── Helpers ──────────────────────────────────────────────────────────
/** Unique trigger keyword the mock server detects to switch to the script. */
const TRIGGER = 'E2E_INTERIM_TRIGGER'
/**
* Send a message and wait for BOTH the user's message and the agent's
* final response to appear in the transcript. Returns when the final text
* is visible, which means message.complete has fired and the transcript
* has settled.
*/
async function sendInterimMessage(page: Page): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type(TRIGGER, { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the user's trigger message to appear.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'),
undefined,
{ timeout: 15_000 },
)
// Wait for the agent's FINAL response (last turn). This means
// message.complete has fired and the transcript is settled.
await page.waitForFunction(
(finalText) => (document.body.textContent ?? '').includes(finalText),
INTERIM_TEXTS.finalText,
{ timeout: 90_000 },
)
// Give the renderer a moment to settle any final state updates
// (hydration, session refresh) before asserting.
await page.waitForTimeout(2000)
}
/**
* Count how many times `text` appears as distinct text in the chat transcript
* (excluding the session sidebar, whose session-preview label shows the
* first streamed text as a title).
*
* The desktop app renders the transcript inside a
* `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react).
* The session sidebar's preview labels live outside that container, so
* scoping the DOM walk to the viewport cleanly excludes them.
*/
async function countTranscriptMessagesContaining(page: Page, text: string): Promise<number> {
return page.evaluate(
(search) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) {
return 0
}
let count = 0
const walker = document.createTreeWalker(
viewport,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: (node) => {
const el = node as HTMLElement
const directText = el.textContent ?? ''
if (!directText.includes(search)) {
return NodeFilter.FILTER_SKIP
}
// Only count leaf-ish elements to avoid double-counting.
const hasChildWithText = Array.from(el.children).some(
(child) => (child.textContent ?? '').includes(search),
)
if (hasChildWithText) {
return NodeFilter.FILTER_SKIP
}
return NodeFilter.FILTER_ACCEPT
},
},
)
while (walker.nextNode()) {
count++
}
return count
},
text,
)
}
// ─── Flag ON: interim_assistant_messages = true (default) ─────────────
test.describe('interim assistant messages — flag ON (default)', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('all interim texts survive alongside the final response', async () => {
const page = fixture.page
await sendInterimMessage(page)
// Every interim text (turns with visible text + tool calls) must be
// present in the transcript as its own sealed message — NOT wiped by
// message.complete.
for (const interimText of INTERIM_TEXTS.interims) {
await expect
.poll(
() => countTranscriptMessagesContaining(page, interimText),
{ timeout: 15_000, message: `interim text "${interimText}" should be visible` },
)
.toBeGreaterThanOrEqual(1)
}
// The final text must also be visible.
await expect
.poll(
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
{ timeout: 15_000, message: 'final text should be visible' },
)
.toBeGreaterThanOrEqual(1)
})
})
// ─── Flag OFF: interim_assistant_messages = false ────────────────────
test.describe('interim assistant messages — flag OFF', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend({
extraDisplayConfig: ' interim_assistant_messages: false',
})
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('only the final response is visible; all interim texts are wiped', async () => {
const page = fixture.page
await sendInterimMessage(page)
// The final text must be visible.
await expect
.poll(
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
{ timeout: 15_000, message: 'final text should be visible' },
)
.toBeGreaterThanOrEqual(1)
// NONE of the interim texts should be visible — with the flag off,
// the tui_gateway never installs interim_assistant_callback, so no
// message.interim events are emitted. All streamed interim text is
// accumulated into the streaming bubble and replaced by
// message.complete.
for (const interimText of INTERIM_TEXTS.interims) {
const count = await countTranscriptMessagesContaining(page, interimText)
expect(
count,
`interim text "${interimText}" should NOT be visible when flag is off`,
).toBe(0)
}
})
})

View file

@ -0,0 +1,235 @@
import { spawnSync } from 'node:child_process'
import * as path from 'node:path'
import { type TestInfo } from '@playwright/test'
import { expect, test, type ElectronApplication, type Page } from './test'
import {
buildAppEnv,
createSandbox,
launchDesktop,
type Sandbox,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py')
const SESSION_TITLE = 'E2E large persisted session'
const EXPECTED_TEXT = 'E2E persisted user message 52'
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
interface SeededFixture {
app: ElectronApplication
mock: MockServer
mockUrl: string
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
interface PaintState {
bursts: number
timeline: Array<{ mutations: number; time: number }>
}
async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
const mock = await startMockServer(mockServer)
const sandbox = createSandbox('large-session')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], {
cwd: REPO_ROOT,
encoding: 'utf8',
env: { ...process.env, PYTHONPATH: REPO_ROOT },
})
if (seeded.status !== 0) {
throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`)
}
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
return {
app,
mock,
mockUrl: mock.url,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
function sessionRow(page: Page) {
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
}
async function openSeededSession(page: Page): Promise<void> {
const row = sessionRow(page)
await row.waitFor({ state: 'visible', timeout: 60_000 })
await row.click()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
EXPECTED_TEXT,
{ timeout: 30_000 },
)
}
async function openNewSession(page: Page): Promise<void> {
const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first()
await button.waitFor({ state: 'visible', timeout: 10_000 })
await button.click()
await page.waitForFunction(
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
EXPECTED_TEXT,
{ timeout: 15_000 },
)
}
async function submitPrompt(page: Page, prompt: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(prompt, { delay: 2 })
await page.keyboard.press('Enter')
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
prompt,
{ timeout: 15_000 },
)
}
async function startPaintObserver(page: Page): Promise<void> {
await page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> }
;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state
if (!viewport) return
let additions = 0
let flushTimer: ReturnType<typeof setTimeout> | undefined
new MutationObserver(records => {
additions += records.reduce(
(count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0),
0,
)
if (additions === 0) return
if (flushTimer) clearTimeout(flushTimer)
flushTimer = setTimeout(() => {
state.bursts += 1
state.timeline.push({ mutations: additions, time: Date.now() })
additions = 0
}, 30)
}).observe(viewport, { childList: true, subtree: true })
})
}
async function paintState(page: Page): Promise<PaintState> {
const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints)
expect(state, 'paint observer should attach to the thread viewport').toBeDefined()
return state!
}
async function textNodeOccurrences(page: Page, expected: string): Promise<number> {
return page.evaluate(text => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return 0
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
let count = 0
while (walker.nextNode()) {
if (walker.currentNode.textContent?.includes(text)) {
count += 1
}
}
return count
}, expected)
}
async function reloadIntoColdRenderer(fixture: SeededFixture): Promise<void> {
await fixture.page.reload()
await waitForAppReady(fixture, 120_000)
await openNewSession(fixture.page)
}
async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
await openSeededSession(page)
await page.waitForTimeout(1_000)
await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false })
const paints = await paintState(page)
expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1)
// A warm session first restores its retained view, then reconciles it with the
// authoritative transcript. That is bounded at two builds; a third paint was
// the old eager-prefetch + runtime-rebuild regression. A cold restore has one.
expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2)
}
test.describe('large session resume', () => {
let fixture: SeededFixture | null = null
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
fixture = await setupSeededDesktop()
await waitForAppReady(fixture, 120_000)
await startPaintObserver(fixture.page)
await assertUnchangedResume(fixture.page, testInfo)
})
test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
// Known RED: a rapid warm resume rebuilds the transcript three times
// (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the
// regression visible without making unrelated desktop work fail CI.
test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild')
fixture = await setupSeededDesktop()
await waitForAppReady(fixture, 120_000)
await openSeededSession(fixture.page)
await openNewSession(fixture.page)
await startPaintObserver(fixture.page)
await assertUnchangedResume(fixture.page, testInfo)
})
for (const resumeKind of ['fast', 'cold'] as const) {
test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => {
fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT })
await waitForAppReady(fixture, 120_000)
await openSeededSession(fixture.page)
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
await fixture.mock.waitForHeldStream()
await openNewSession(fixture.page)
if (resumeKind === 'cold') {
await reloadIntoColdRenderer(fixture)
}
await openSeededSession(fixture.page)
fixture.mock.releaseHeldStream()
await fixture.page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 },
)
await fixture.page.waitForTimeout(300)
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1)
})
}
})

View file

@ -0,0 +1,88 @@
import { expect, test } from './test'
import {
PACKAGED_BINARY_PATH,
type PackagedAppFixture,
packagedBinaryExists,
setupPackagedApp,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
/**
* E2E smoke tests for the packaged Hermes desktop app.
*
* Launches the real packaged Electron binary (produced by `npm run pack`
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
*
* Skips if the packaged binary doesn't exist run `npm run pack` first.
*/
let fixture: PackagedAppFixture | null = null
test.beforeAll(async () => {
test.skip(
!packagedBinaryExists(),
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
fixture = await setupPackagedApp()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('window opens with the Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer loads and shows DOM content', async () => {
const page = fixture!.page
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
const childCount = await page.locator('#root > *').count()
expect(childCount).toBeGreaterThan(0)
})
test('boot progress overlay fades out or shows error state', async () => {
const page = fixture!.page
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
// Error path: boot failure overlay renders an error message.
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
return true
}
// Success path: overlay disappears and the app renders. If there's
// no "boot" / "starting" / "installing" text visible, boot has
// completed (either to the main UI or to onboarding).
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
const lower = text.toLowerCase()
return !bootIndicators.some((word) => lower.includes(word))
},
undefined,
{ timeout: 60_000 },
)
})
test('can capture a screenshot for the CI artifact', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
// Visual snapshot — won't fail on diff, just logs + generates diff image
await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
})

View file

@ -0,0 +1,87 @@
/**
* E2E tests asserting the mock backend gets the app past the setup/onboarding
* screen.
*
* The mock backend fixture writes a config.yaml with a pre-configured mock
* provider pointing at a mock inference server. When the app boots, the
* runtime readiness check should detect the working provider and dismiss the
* onboarding overlay landing straight on the chat UI without ever showing
* the "Let's get you setup with Hermes Agent" screen.
*
* If these tests fail, the mock backend config isn't getting the app past
* onboarding the chat interaction tests (chat.spec.ts) will also fail
* because the composer is blocked by the setup overlay.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('mock backend gets past setup screen', () => {
test('onboarding overlay is not shown', async () => {
const page = fixture!.page
// The onboarding overlay renders "Let's get you setup with Hermes Agent"
// when the runtime check fails to find a working provider. With the mock
// backend configured, the runtime check should pass and the overlay
// returns null — this text should NOT be present in the DOM.
await page.waitForFunction(
() => {
const text = document.body.textContent ?? ''
return !text.includes("Let's get you setup")
},
undefined,
{ timeout: 30_000 },
)
})
test('chat composer is visible', async () => {
const page = fixture!.page
// The composer (contenteditable div) should be visible and not blocked
// by the onboarding overlay. If the first test passed, the overlay is
// gone and the composer is the primary interactive surface.
const composer = page.locator('[contenteditable="true"]').first()
await expect(composer).toBeVisible()
})
test('can type into the composer', async () => {
const page = fixture!.page
// If the setup overlay is truly gone, the composer accepts input.
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('hello mock backend', { delay: 20 })
// Verify the typed text appears in the DOM.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('hello mock backend'),
undefined,
{ timeout: 10_000 },
)
})
test('screenshot shows chat UI without setup screen', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
})
})

View file

@ -0,0 +1,695 @@
/**
* Minimal OpenAI-compatible mock inference server for E2E tests.
*
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
* provider, list models, and stream a canned chat completion back to the
* desktop app without any real LLM.
*
* Endpoints:
* GET /v1/models { data: [{ id, ... }] }
* POST /v1/chat/completions streaming (SSE) or non-streaming response
*
* The canned response is a short, deterministic assistant message. Tool-call
* requests are not simulated the E2E tests only need the chat surface to
* prove the full boot gateway inference renderer chain works.
*/
import http from 'node:http'
import type { ServerResponse } from 'node:http'
/** A canned assistant reply used for every chat completion request. */
export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
export interface MockServerOptions {
/** Pause the matching stream after its first token for session-switch E2E coverage. */
holdFirstStreamForPrompt?: string
}
export interface MockServer {
port: number
url: string
receivedPrompts: string[]
waitForHeldStream: () => Promise<void>
releaseHeldStream: () => void
close: () => Promise<void>
}
// ─── Multi-turn interim script ─────────────────────────────────────────
//
// When the user's message contains the trigger keyword, the mock server
// walks through a scripted sequence of responses that exercise the
// interim-assistant-message fix (#65919) across several patterns:
//
// 1. text + single tool_call → should produce an interim message
// 2. text + single tool_call → another interim message
// 3. no text + tool_call → NO interim (no visible text alongside tools)
// 4. text + single tool_call → another interim message
// 5. final answer (stop) → message.complete, different from all interims
//
// Each "turn" is one API call. The agent executes the tool after each
// tool_calls response, then re-calls the API, advancing to the next turn.
export interface ScriptedTurn {
/** Assistant text content to stream. Empty string = no visible text. */
text: string
/** Tool calls to emit. Empty array = final turn (finish_reason: stop). */
toolCalls?: Array<{
name: string
args: Record<string, unknown>
}>
}
const INTERIM_SCRIPT: ScriptedTurn[] = [
{
text: 'Let me start by planning the approach.',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '1', content: 'Plan', status: 'in_progress' }] } }],
},
{
text: 'Now checking the details before answering.',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '2', content: 'Check details', status: 'in_progress' }] } }],
},
{
// No visible text alongside this tool call — should NOT produce an
// interim message. The agent fires _emit_interim_assistant_message
// but _interim_assistant_visible_text returns "" so it's a no-op.
text: '',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '3', content: 'Silent step', status: 'completed' }] } }],
},
{
text: 'Found something interesting worth noting.',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '4', content: 'Note finding', status: 'completed' }] } }],
},
{
// Final answer — different from all interim texts.
text: 'All done! Here is the complete summary of what I found.',
},
]
/** Per-server request counter so we can walk through the script turns. */
let _scriptIndex = 0
/** Per-server counter for the sidebar-states script (independent from _scriptIndex). */
let _sidebarScriptIndex = 0
/** Per-server counter for the cross-session sidebar script. */
let _sidebarCrossIndex = 0
/** Per-server counter for the queue-stop script. */
let _queueStopIndex = 0
/** Per-server counter for the correction/session-switch script. */
let _correctionSwitchIndex = 0
/** User messages received by the mock, for E2E assertions on real submits. */
const _receivedUserTexts: string[] = []
/** Reset the script indices (called between tests via restartMockServer). */
function resetScriptIndex(): void {
_scriptIndex = 0
_sidebarScriptIndex = 0
_sidebarCrossIndex = 0
_queueStopIndex = 0
_correctionSwitchIndex = 0
_receivedUserTexts.length = 0
}
/** Return the user prompts the real backend submitted to this mock server. */
export function receivedUserTexts(): readonly string[] {
return _receivedUserTexts
}
// ─── Sidebar-states script ─────────────────────────────────────────────
//
// A separate trigger (E2E_SIDEBAR_TRIGGER) exercises the desktop sidebar's
// background-process and subagent states. The mock returns tool_calls that
// the agent executes for real — `terminal(background=true)` spawns a real
// (but trivial) background process, and `delegate_task` spawns a real
// subagent that calls the mock server and gets the canned reply.
//
// Turn 1: text + terminal(bg=true) + delegate_task → tools execute
// Turn 2: final answer → message.complete, dot transitions
const SIDEBAR_SCRIPT: ScriptedTurn[] = [
{
text: 'Let me run a background task and delegate some work.',
toolCalls: [
{
name: 'terminal',
args: {
command: 'echo "background process output" && sleep 1 && echo "done"',
background: true,
notify_on_complete: true,
},
},
{
name: 'delegate_task',
args: {
goal: 'Summarize the test results',
context: 'This is a test subagent for the sidebar states E2E test.',
},
},
],
},
{
text: 'All tasks complete. The background process finished and the subagent returned its summary.',
},
]
// ─── Sidebar cross-session script ──────────────────────────────────────
//
// E2E_SIDEBAR_CROSS trigger uses a longer background process (sleep 5) so
// the "background running" dot is visible long enough for the test to:
// 1. See the background dot while the subagent runs.
// 2. Open a different session and see session A's dot transition to
// "finished unread" when the background process completes.
const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = [
{
text: 'Starting a long background task and delegating work.',
toolCalls: [
{
name: 'terminal',
args: {
command: 'echo "long bg output" && sleep 5 && echo "finished"',
background: true,
notify_on_complete: true,
},
},
{
name: 'delegate_task',
args: {
goal: 'Analyze cross-session state',
context: 'Testing that the background dot updates across sessions.',
},
},
],
},
{
text: 'Both tasks are running in the background now.',
},
]
const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [
{
text: 'Starting a task that will keep this turn active.',
toolCalls: [{ name: 'clarify', args: { question: 'Keep working?', choices: ['Yes', 'No'] } }],
},
{ text: 'The paused task completed.' },
]
// The reported correction arrived while a foreground tool was still running.
// Keep that boundary open long enough for the renderer to redirect the turn,
// then let the next model request complete normally.
const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [
{
text: 'Checking the long-running task before I continue.',
toolCalls: [{ name: 'terminal', args: { command: 'sleep 5' } }],
},
{ text: 'The corrected task finished.' },
]
export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER'
/**
* A marker that makes the mock emit a real blocking clarify tool call. Tests
* use it to hold a turn open while exercising busy-composer interactions.
*/
export const BLOCKING_CLARIFY_TRIGGER = 'E2E_BLOCKING_CLARIFY_TRIGGER'
export const BLOCKING_CLARIFY_QUESTION = 'Keep this test turn running?'
const BLOCKING_CLARIFY_TURN: ScriptedTurn = {
text: '',
toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }],
}
function includesBlockingClarifyTrigger(value: unknown): boolean {
if (typeof value === 'string') {
return value.includes(BLOCKING_CLARIFY_TRIGGER)
}
if (Array.isArray(value)) {
return value.some(includesBlockingClarifyTrigger)
}
if (value && typeof value === 'object') {
return Object.values(value).some(includesBlockingClarifyTrigger)
}
return false
}
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
*/
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
return new Promise((resolve, reject) => {
const receivedPrompts: string[] = []
let resolveHeldStreamStarted: (() => void) | null = null
let releaseHeldStream: (() => void) | null = null
const heldStreamStarted = new Promise<void>(resolveHeld => {
resolveHeldStreamStarted = resolveHeld
})
const heldStreamReleased = new Promise<void>(resolveRelease => {
releaseHeldStream = resolveRelease
})
const server = http.createServer((req, res) => {
// CORS headers — the Electron renderer doesn't need them, but they
// don't hurt and make the server usable from a browser context too.
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
// GET /v1/models — return a single fake model.
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [
{
id: 'mock-model',
object: 'model',
created: 0,
owned_by: 'mock',
},
],
}),
)
return
}
// POST /v1/chat/completions — return a canned response.
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
req.on('end', () => {
let parsed: any = {}
try {
parsed = JSON.parse(body)
} catch {
// malformed JSON — treat as non-streaming with defaults
}
const lastUserMessage = [...(parsed.messages ?? [])]
.reverse()
.find((message: { role?: unknown }) => message?.role === 'user')
if (typeof lastUserMessage?.content === 'string') {
receivedPrompts.push(lastUserMessage.content)
}
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
// Detect the interim-message test trigger: the user's message
// contains a specific keyword. The mock walks through the
// INTERIM_SCRIPT turns in sequence.
//
// The trigger keyword is chosen so normal chat tests (which send
// "Hello, can you hear me?" etc.) never hit this path.
const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : []
const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user')
const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : ''
if (userText) {
_receivedUserTexts.push(userText)
}
const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER')
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
const isCorrectionSwitchTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
)
if (includesBlockingClarifyTrigger(parsed.messages)) {
if (stream) {
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
} else {
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
}
return
}
if (isQueueStopTrigger) {
const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1]
_queueStopIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isCorrectionSwitchTrigger) {
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
_correctionSwitchIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isSidebarCrossTrigger) {
const turn = SIDEBAR_CROSS_SCRIPT[_sidebarCrossIndex] ?? SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1]
_sidebarCrossIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isSidebarTrigger) {
const turn = SIDEBAR_SCRIPT[_sidebarScriptIndex] ?? SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1]
_sidebarScriptIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isInterimTrigger) {
const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1]
_scriptIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (stream) {
const holdThisStream = Boolean(
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
)
streamTextResponse(res, model, MOCK_REPLY, holdThisStream ? () => {
resolveHeldStreamStarted?.()
return heldStreamReleased
} : undefined)
} else {
nonStreamingTextResponse(res, model, MOCK_REPLY)
}
})
req.on('error', () => {
res.writeHead(400)
res.end('Bad request')
})
return
}
// Fallback — 404 for anything else
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
const port = addr.port
const url = `http://127.0.0.1:${port}`
resolve({
port,
url,
receivedPrompts,
waitForHeldStream: () => heldStreamStarted,
releaseHeldStream: () => releaseHeldStream?.(),
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
if (err) {
rejectClose(err)
} else {
resolveClose()
}
})
}),
})
})
})
}
// ─── Response helpers ──────────────────────────────────────────────────
/** SSE chunk shape for a streaming chat completion. */
function sseChunk(model: string, delta: Record<string, unknown>, finishReason: string | null = null): string {
return `data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`
}
/**
* Stream a plain text response (no tool calls) as SSE, finishing with
* `finish_reason: "stop"`. This is the default canned-reply path.
*/
function streamTextResponse(
res: ServerResponse,
model: string,
text: string,
waitForRelease?: () => Promise<void>,
): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const words = text.split(' ')
let i = 0
const sendChunk = (): void => {
if (i >= words.length) {
res.write(sseChunk(model, {}, 'stop'))
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(sseChunk(model, { content: word }))
i++
if (waitForRelease && i === 1) {
waitForRelease().then(() => setTimeout(sendChunk, 20))
return
}
setTimeout(sendChunk, 20)
}
sendChunk()
}
/** Non-streaming plain text response. */
function nonStreamingTextResponse(res: ServerResponse, model: string, text: string): void {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: text },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
/**
* Stream a single scripted turn: first the text content (word by word),
* then a chunk carrying the tool_calls (if any), with the appropriate
* finish_reason.
*
* If the turn has no text and no tool calls, it's an empty final response.
* If it has text but no tool calls, it's a final answer (finish_reason: stop).
* If it has tool calls (with or without text), finish_reason is "tool_calls".
*/
function streamScriptedTurn(
res: ServerResponse,
model: string,
turn: ScriptedTurn,
): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
// If there's no text to stream, go straight to the tool_calls / finish.
if (!turn.text) {
if (hasToolCalls) {
res.write(
sseChunk(model, {
tool_calls: turn.toolCalls!.map((tc, idx) => ({
index: idx,
id: `call_e2e_${_scriptIndex}_${idx}`,
type: 'function',
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
})),
}, finishReason),
)
} else {
res.write(sseChunk(model, {}, finishReason))
}
res.write('data: [DONE]\n\n')
res.end()
return
}
// Stream the text word by word, then emit tool_calls if present.
const words = turn.text.split(' ')
let i = 0
const sendChunk = (): void => {
if (i >= words.length) {
// All text streamed — emit tool_calls if present, then finish.
if (hasToolCalls) {
res.write(
sseChunk(model, {
tool_calls: turn.toolCalls!.map((tc, idx) => ({
index: idx,
id: `call_e2e_${_scriptIndex}_${idx}`,
type: 'function',
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
})),
}, finishReason),
)
} else {
res.write(sseChunk(model, {}, finishReason))
}
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(sseChunk(model, { content: word }))
i++
setTimeout(sendChunk, 20)
}
sendChunk()
}
/** Non-streaming version of a scripted turn. */
function nonStreamingScriptedTurn(
res: ServerResponse,
model: string,
turn: ScriptedTurn,
): void {
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
const message: Record<string, unknown> = { role: 'assistant' }
if (turn.text) {
message.content = turn.text
}
if (hasToolCalls) {
message.tool_calls = turn.toolCalls!.map((tc, idx) => ({
id: `call_e2e_${_scriptIndex}_${idx}`,
type: 'function',
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}))
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [{ index: 0, message, finish_reason: finishReason }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
/**
* Restart the mock server's script index so each test starts from turn 0.
* Call this between tests that use the interim trigger.
*/
export function restartMockServer(): void {
resetScriptIndex()
}
/**
* The interim script's text constants, exported for test assertions.
* Each entry is the visible text of one turn. Turns with empty text
* produce no interim message and are excluded from this list.
*/
export const INTERIM_TEXTS = {
/** All interim texts that should appear as sealed messages when the flag is ON. */
interims: INTERIM_SCRIPT
.filter((t) => t.text && t.toolCalls)
.map((t) => t.text),
/** The final answer text. */
finalText: INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1].text,
/** Text that should NOT produce an interim (empty-text tool turn). */
silentTurnIndex: INTERIM_SCRIPT.findIndex((t) => !t.text && t.toolCalls),
} as const
/** The sidebar-states script's text constants, exported for test assertions. */
export const SIDEBAR_TEXTS = {
/** The interim text from turn 1 (alongside tool calls). */
interimText: SIDEBAR_SCRIPT[0].text,
/** The final answer text. */
finalText: SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1].text,
/** The background process command (for asserting process.list entries). */
bgCommand: 'echo "background process output" && sleep 1 && echo "done"',
/** The subagent's goal (for asserting subagent panel state). */
subagentGoal: 'Summarize the test results',
} as const
/** The cross-session sidebar script's text constants. */
export const SIDEBAR_CROSS_TEXTS = {
/** The interim text from turn 1. */
interimText: SIDEBAR_CROSS_SCRIPT[0].text,
/** The final answer text. */
finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text,
/** The longer background process command (sleep 5). */
bgCommand: 'echo "long bg output" && sleep 5 && echo "finished"',
/** The subagent's goal. */
subagentGoal: 'Analyze cross-session state',
} as const

View file

@ -0,0 +1,76 @@
/**
* E2E onboarding tests verify the provider picker appears when no
* inference provider is configured.
*
* Launches the app with an empty config.yaml (no providers). The renderer
* should detect the unconfigured state and show the DesktopOnboardingOverlay
* with provider options / API key form.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import {
type NoProviderFixture,
setupNoProvider,
waitForOnboarding,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: NoProviderFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('onboarding with no provider configured', () => {
test('onboarding overlay appears on first boot', async () => {
fixture = await setupNoProvider()
// The app should boot (hermes serve starts fine even without a provider),
// but the renderer should show the onboarding overlay because no
// provider is configured.
await waitForOnboarding(fixture.page, 90_000)
})
test('onboarding shows provider options or API key form', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
const page = fixture.page
// The onboarding overlay should contain provider-related text.
// It might show OAuth providers, an API key form, or a "choose later"
// link. Verify at least one of these is visible.
const rootText = await page.evaluate(() => {
const root = document.getElementById('root')
return root?.textContent ?? ''
})
const hasProviderText =
rootText.includes('provider') ||
rootText.includes('Provider') ||
rootText.includes('API key') ||
rootText.includes('Sign in') ||
rootText.includes('OpenRouter') ||
rootText.includes('OpenAI')
expect(hasProviderText).toBe(true)
})
test('screenshot of onboarding overlay', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
})
})

View file

@ -0,0 +1,118 @@
/**
* A queued prompt must remain local until the current inference turn settles.
*
* Hold the first streamed reply open after its first token. This gives the
* composer a live, busy turn while the user queues a follow-up, then lets us
* assert against the mock provider's real request log before and after the
* held turn completes.
*/
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY } from './mock-server'
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
function steerTurnOrder(messages: string[]): string[] {
return messages.flatMap(message => {
if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT]
if (message.includes(STEER_PROMPT)) return [STEER_PROMPT]
if (message.includes(MOCK_REPLY)) return [MOCK_REPLY]
return []
})
}
test.describe('queued prompt turn boundary', () => {
let fixture: MockBackendFixture | null = null
test.beforeEach(async () => {
fixture = await setupMockBackend({
mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT }
})
await waitForAppReady(fixture, 120_000)
})
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('submits a queued prompt only after the active turn completes', async () => {
const { mock, page } = fixture!
const composer = page.locator('[contenteditable="true"]').first()
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await composer.click()
await composer.type(QUEUED_PROMPT, { delay: 5 })
await queue.click()
await expect(page.getByText('1 Queued')).toBeVisible()
// The mock keeps the active SSE stream open, so a queued prompt has no
// completed-turn boundary that could legitimately drain it. Wait past the
// queue retry interval and assert the provider saw only the active turn.
await page.waitForTimeout(1_000)
expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
})
test('places a steer prompt before the reply it redirects', async () => {
const { mock, page } = fixture!
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await steer(page, STEER_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
})
})

View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Seed a deterministic, tool-free large session into an isolated state.db."""
import sys
from pathlib import Path
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
SESSION_ID = "e2e-large-session"
SESSION_TITLE = "E2E large persisted session"
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} <state.db>")
messages = []
for index in range(53):
role = "user" if index % 2 == 0 else "assistant"
content = (
f"E2E persisted user message {index}: audit the compatibility matrix"
if role == "user"
else f"E2E persisted assistant reply {index}: recorded the audit result"
)
messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index})
database = SessionDB(db_path=Path(sys.argv[1]))
result = database.import_sessions(
[
{
"id": SESSION_ID,
"source": "desktop",
"model": "mock-model",
"started_at": 1_700_000_000,
"title": SESSION_TITLE,
"cwd": str(repo_root),
"system_prompt": "",
"messages": messages,
}
]
)
database.close()
if not result.get("ok") or result.get("imported") != 1:
raise SystemExit(f"failed to seed large session: {result}")
if __name__ == "__main__":
main()

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