Compare commits

..
Author SHA1 Message Date
Teknium f3c6282dae fix(catalog): point seed entry at a real plugin subdir
The example-plugins repo is a multi-plugin repo with no root plugin.yaml;
the admission CI correctly rejected the root-level seed entry. Pin the
plugin-llm-example subdir instead — the admission gate catching its own
seed entry is the E2E proof it works.
2026-07-22 08:23:57 -07:00
Teknium ab1d9f4c15 fix: dedupe catalog sidecar helpers into public aliases
Lane 2 (CLI) and lane 5 (dashboard) each shipped a sidecar read/write pair
with identical format; keep the CLI pair and alias the public names the
dashboard imports.
2026-07-22 08:17:11 -07:00
Teknium 7880be4580 Merge remote-tracking branch 'origin/feat/plugcat-dash' into feat/plugin-catalog 2026-07-22 08:15:38 -07:00
Teknium 741d06445d Merge remote-tracking branch 'origin/feat/plugcat-cli' into feat/plugin-catalog 2026-07-22 08:15:38 -07:00
Teknium 1c3d6e59dd Merge remote-tracking branch 'origin/feat/plugcat-docs' into feat/plugin-catalog 2026-07-22 08:15:38 -07:00
Teknium ae68edcfe3 Merge remote-tracking branch 'origin/feat/plugcat-action' into feat/plugin-catalog 2026-07-22 08:15:38 -07:00
Teknium 355a8e4076 Merge remote-tracking branch 'origin/feat/plugcat-core' into feat/plugin-catalog 2026-07-22 08:15:38 -07:00
Teknium 373ad70f4c test(plugins): cover catalog CLI surface and plugin validation
Behavior contracts for catalog-name install resolution (ref pin +
sidecar), custom-URL banner, --allow-removed wiring, catalog-pin
updates, list annotations, live-index fetch/fallback/TTL cache,
search/browse/info rendering, doctor, argparse dispatch, and the
validate checks incl. undeclared-capability diffs, crash containment,
and built-in tool collisions.
2026-07-22 08:14:51 -07:00
Teknium f1b30414d5 feat(plugins): catalog CLI surface — search/browse/info/install/update/doctor
- install: catalog names resolve to the pinned SHA (ref= checkout),
  print tier + capability summary before the enable prompt, and write a
  .hermes-catalog.json provenance sidecar; raw git URLs get a
  'custom (unreviewed) source' banner; --allow-removed loudly bypasses
  the removed blocklist (skip_removed_check=True)
- update: sidecar installs compare against the current catalog pin and
  force-reinstall at the new SHA (enabled state preserved); plain git
  installs keep the git-pull flow
- list: catalog:<tier>@<shaShort> annotation + red 'REMOVED from
  catalog' lines for blocklisted installs (table and --json)
- search/browse/info: live-index catalog tables and full entry detail
  with removed-list warnings
- validate: human ✓/✗ output, --json for CI, exit 0/1
- doctor: per-plugin manifest/enabled/load-error/env/requires_hermes/
  provenance/pin/removed diagnosis, compact table or single-name detail
2026-07-22 08:14:51 -07:00
Teknium 44624631bf feat(plugins): subprocess-isolated plugin validation for catalog CI
New hermes_cli/plugin_validate.py — the checks behind
'hermes plugins validate <dir>': manifest fields, strict requires_hermes
spec parsing, config: shape, UPPER_SNAKE requires_env, a capability
probe that imports the plugin and calls register(ctx) against a
recording stub in a scratch subprocess (throwaway HERMES_HOME, 30s
timeout) and diffs actual registrations against provides_* (undeclared
= fail, unregistered = warn), plus built-in tool collision checks via
the discovered tool registry.
2026-07-22 08:14:51 -07:00
Teknium 8b70bf4c40 feat(plugins): live catalog index with 6h cache and fallback
Add fetch_live_catalog()/load_catalog_live() to hermes_cli.plugin_catalog:
list plugin-catalog/*.yaml via the GitHub contents API (unauthenticated,
5s timeout), raw-fetch each entry, and cache under
<hermes_home>/cache/plugin-catalog/ with a 6h TTL. Any network failure
falls back silently to the in-tree catalog. Also expose filter_entries()
so callers can reuse search semantics on a live entry list.
2026-07-22 08:14:51 -07:00
Teknium fe9dc06071 feat(dashboard): plugin catalog surface — browse, capability-confirm install, removed banners
- GET /api/dashboard/plugins/catalog: catalog entries merged with
  installed-state (sidecar SHA, update_available, runtime_status) +
  removed blocklist + generated_at
- agent-plugins/install accepts catalog_name: resolves the catalog
  entry, refuses removed plugins (400, no dashboard bypass), installs
  at the pinned SHA and writes the .hermes-catalog.json sidecar
- plugins/hub rows annotated with removed_reason
- PluginsPage: Catalog section with search, tier badges, capability
  chips, sha/docs links, capability-summary confirm dialog install
  flow, and red removed banners on catalog + installed rows
- en i18n keys with en-only optional-key fallback convention
2026-07-22 07:57:39 -07:00
Teknium 6f5608bed3 test(plugins): cover plugin catalog, version gate, and installer
- catalog loader validation, search, removed matching, capability summary
- _version_satisfies operators and permissive fallbacks
- requires_hermes load gate (skip vs normal load)
- config spec parsing + ctx.plugin_config merge
- _install_plugin_core ref checkout and removed-block via local file:// repos
2026-07-22 07:30:55 -07:00
Teknium dc4d991373 feat(plugins): install-time ref checkout and removed-blocklist check
- _install_plugin_core accepts ref= (full-depth clone + git checkout,
  PluginOperationError on failure) and skip_removed_check=
- installs are refused when the identifier or resolved repo URL matches
  plugin-catalog/removed.yaml, with the recorded reason and date
2026-07-22 07:30:55 -07:00
Teknium dcdb9b25e4 feat(plugins): requires_hermes gate, config spec, ctx.plugin_config
- plugin.yaml gains requires_hermes (version spec) and config: (list of
  {key, prompt, type, default, secret}) parsed onto PluginManifest
- PluginManager skips loading (clean error, no traceback) when the
  running Hermes version does not satisfy requires_hermes; local
  _version_satisfies helper supports >=,>,<=,<,==,!= and comma specs
- PluginContext.plugin_config merges config-spec defaults under
  plugins.entries.<plugin_id> values from config.yaml
2026-07-22 07:30:55 -07:00
Teknium a22d2918d6 feat(plugins): add curated plugin catalog module and in-tree catalog dir
- hermes_cli/plugin_catalog.py: loader/search/removed-blocklist for the
  new plugin-catalog/ directory (exact 40-hex SHA pins, https-only repos,
  official/community tiers; invalid entries skipped with a warning)
- plugin-catalog/: admission-policy README, example-plugin seed entry,
  and removed.yaml blocklist
2026-07-22 07:30:55 -07:00
Teknium fb40a768fc feat(docs): add /docs/plugins catalog page fed by plugin-catalog/ extractor
- website/scripts/extract-plugins.py: reads plugin-catalog/*.yaml (+removed.yaml),
  emits static/api/plugins.json + plugins-meta.json; degrades to an empty
  catalog with exit 0 when plugin-catalog/ does not exist yet
- website/src/pages/plugins/: catalog page with search, tier tabs
  (All/Official/Community), capability chips, pinned-SHA repo links,
  copyable install commands, and an empty-state submission CTA
- cross-nav between Skills Hub and Plugin Catalog pages + navbar item
- user docs: user-guide/features/plugin-catalog.md (trust model, install,
  submission checklist, custom git-URL contrast), registered in sidebars.ts
- wired into deploy-site.yml and prebuild.mjs; artifacts gitignored
- tests: tests/website/test_extract_plugins.py
2026-07-22 07:28:53 -07:00
Teknium 8dd07bd517 feat(ci): plugin-validate reusable action + plugin-catalog admission gate
- scripts/validate_plugin_catalog.py: standalone stdlib+pyyaml structural
  validator for plugin-catalog entries and removed.yaml (no hermes install
  needed; runtime twin of hermes_cli/plugin_catalog.py). --json support,
  unknown top-level keys warn instead of failing for forward compat.
- .github/actions/plugin-validate: composite action plugin authors drop
  into their own repo's CI — installs hermes-agent from a chosen ref and
  runs 'hermes plugins validate <path>'.
- .github/workflows/plugin-catalog-ci.yml: admission gate on PRs touching
  plugin-catalog/** — structural job plus pinned-source job that clones
  each changed entry's repo, hard-fails on unreachable pinned sha
  (supply-chain gate), and validates the plugin at that exact commit.
2026-07-22 07:22:43 -07:00
1396 changed files with 20565 additions and 115991 deletions
+3
View File
@@ -97,6 +97,9 @@ 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
@@ -39,9 +39,6 @@ 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
+8 -18
View File
@@ -5,32 +5,24 @@ description: >-
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.
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
this happens on fork PRs where repo secrets are unavailable. The fallback
ensures classification, timings, and review comments still work on
forks (with the lower GITHUB_TOKEN rate limit).
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.
Composite actions cannot access the secrets context directly, so the
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
as inputs. When both are empty (fork PRs), the fallback fires.
inputs:
client-id:
description: GitHub App Client ID. Pass vars.APP_CLIENT_ID from the calling workflow.
description: GitHub App Client ID. Pass secrets.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:
@@ -59,8 +51,6 @@ runs:
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
@@ -0,0 +1,56 @@
name: Hermes Plugin Validate
description: >-
Validate a Hermes Agent plugin (plugin.yaml manifest schema AND
declared-vs-actually-registered capabilities) using
`hermes plugins validate`. Drop this into your plugin repo's CI:
- uses: actions/checkout@<sha>
- uses: NousResearch/hermes-agent/.github/actions/plugin-validate@main
with:
path: .
The caller's job owns checkout; this action installs Python + hermes-agent
(git install — a supported CI-context install route) and runs the
validator against your plugin directory.
inputs:
path:
description: Path to the plugin directory (containing plugin.yaml).
default: "."
hermes-ref:
description: hermes-agent git ref (branch/tag/sha) to install and validate with.
default: "main"
runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install hermes-agent
shell: bash
env:
_HERMES_REF: ${{ inputs.hermes-ref }}
run: |
set -euo pipefail
# CI-context install from git; the ref lets plugin authors validate
# against a pinned hermes release instead of main.
pip install "git+https://github.com/NousResearch/hermes-agent@${_HERMES_REF}"
- name: Validate plugin
shell: bash
env:
_PLUGIN_PATH: ${{ inputs.path }}
run: |
set -uo pipefail
# `hermes plugins validate` checks the plugin.yaml manifest schema
# and loads the plugin in a scratch subprocess to verify that the
# capabilities it DECLARES match what it actually registers.
if hermes plugins validate "$_PLUGIN_PATH"; then
echo "✅ PASS: plugin at '$_PLUGIN_PATH' validated cleanly"
else
echo "❌ FAIL: plugin at '$_PLUGIN_PATH' failed validation (see output above)"
exit 1
fi
+51 -49
View File
@@ -9,10 +9,6 @@ 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:
@@ -50,15 +46,22 @@ 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
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
github-token: ${{ github.token }}
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
github-token: ${{ steps.app-token.outputs.token }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@@ -71,6 +74,7 @@ jobs:
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit
lint:
name: Python lints
@@ -79,12 +83,14 @@ jobs:
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
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
@@ -97,44 +103,48 @@ jobs:
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
# Trusted main pushes run docker.yml directly so its container-publish
# environment secrets never cross this reusable-workflow call. PR runs
# remain build/test-only and secret-free.
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true')
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
@@ -153,13 +163,14 @@ jobs:
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' }}
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
secrets: inherit
osv-scanner:
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Live-updating PR review comment.
@@ -169,21 +180,19 @@ jobs:
# whatever results are available, and upserts it via the
# ``<!-- hermes-ci-review-bot -->`` marker.
#
# When the visible job set goes quiet, the poller waits 10 seconds and polls
# once more so downstream jobs created by an aggregate gate get included.
# 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]
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
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:
@@ -304,8 +313,8 @@ jobs:
# 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 can read the standalone review-status artifact
# after the HTML report is uploaded, so its link points straight at that report.
# 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
@@ -317,6 +326,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Restore baseline cache (PR only)
if: github.event_name == 'pull_request'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -330,52 +346,38 @@ jobs:
- name: Collect timings and generate report
env:
GITHUB_TOKEN: ${{ github.token }}
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so the timings API read still works
# there instead of hard-failing this advisory job on every fork PR.
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
GITHUB_TOKEN: ${{ steps.app-token.outputs.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-html
id: ci-timings-artifact
with:
name: ci-timings-report
path: ci-timings-report.html
retention-days: 14
- name: Build linked review status
if: hashFiles('ci-timings.json') != ''
env:
CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }}
run: |
python3 scripts/ci/timings_report.py \
--from-json ci-timings.json \
--baseline ci-timings-baseline.json \
--review-status-out review-status.json \
--review-status-only
- name: Upload review status
if: hashFiles('review-status.json') != ''
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-timings-review-status
path: review-status.json
path: |
ci-timings-report.html
review-status.json
retention-days: 14
- name: Output summary
env:
REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}}
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
run: |
{
echo "# CI Timing report"
echo "[View the full interactive report]($REPORT_URL)"
} >> "$GITHUB_STEP_SUMMARY"
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Save baseline cache (main only)
+4 -1
View File
@@ -60,7 +60,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -157,6 +157,9 @@ jobs:
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Extract plugin catalog for the Plugins page
run: python3 website/scripts/extract-plugins.py
- name: Regenerate per-skill docs pages + catalogs
run: python3 website/scripts/generate-skill-docs.py
+45 -80
View File
@@ -1,15 +1,8 @@
name: Docker Build, Test, and Publish
on:
# Trusted main pushes run this workflow directly so environment-scoped
# Docker Hub secrets are resolved by the top-level workflow, never across
# a reusable-workflow boundary.
push:
branches: [main]
release:
types: [published]
# CI calls this only for untrusted PR build/test coverage. Those runs never
# reach the protected publish or merge jobs below.
workflow_call:
permissions:
@@ -27,9 +20,7 @@ env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# 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, test, and optionally push the image for each architecture.
build:
if: github.repository == 'NousResearch/hermes-agent'
strategy:
@@ -71,6 +62,49 @@ 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`).
@@ -113,74 +147,6 @@ 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 —
@@ -192,9 +158,8 @@ 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: [publish]
needs: [build]
timeout-minutes: 10
environment: container-publish
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+24 -67
View File
@@ -2,10 +2,6 @@ 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
@@ -19,8 +15,6 @@ jobs:
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
@@ -64,8 +58,7 @@ jobs:
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.
- run: npm run --prefix apps/desktop build
# ── Restore visual baseline screenshots from main ──────────────────
# Baselines are generated on main (via --update-snapshots) and cached.
@@ -86,18 +79,16 @@ jobs:
# 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" \
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" \
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
@@ -152,38 +143,6 @@ jobs:
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
@@ -197,50 +156,49 @@ jobs:
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
run: |
{
echo "## Desktop E2E — Visual Diff Report"
echo ""
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# 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)."
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
else
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**"
echo ""
echo "| Test | Diff | Actual | Expected |"
echo "|------|------|--------|----------|"
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
# 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}
base=$(echo "$diff" | sed 's/-diff\.png$//')
test_name=$(basename "$base")
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |"
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
done
fi
echo ""
echo "📥 **Artifacts:**"
echo ""
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$RESULTS_URL" ]; then
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces"
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$REPORT_URL" ]; then
echo "- [playwright-report]($REPORT_URL) — interactive HTML report"
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$DIFFS_URL" ]; then
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
fi
echo ""
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
# Also parse the JSON report for pass/fail counts
if [ -f playwright-report/results.json ]; then
echo ""
echo "### Test Results"
echo ""
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
node -e "
const r = require('./playwright-report/results.json');
const stats = r.stats || {};
@@ -250,6 +208,5 @@ jobs:
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
" 2>/dev/null || true
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
fi
} >> "$GITHUB_STEP_SUMMARY"
+1 -2
View File
@@ -122,7 +122,6 @@ 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
@@ -133,7 +132,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Download patch
+11 -29
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
checks: ${{ steps.set-matrix.outputs.checks }}
packages: ${{ steps.set-matrix.outputs.packages }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -22,40 +22,21 @@ jobs:
command: npm ci --ignore-scripts
- id: set-matrix
run: |
node -e '
const { execSync } = require("child_process");
const pkgs = JSON.parse(execSync("npm query .workspace", { encoding: "utf-8" }));
if (pkgs.length === 0) {
console.error("::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently).");
process.exit(1);
}
const checks = [];
for (const pkg of pkgs) {
const scripts = pkg.scripts || {};
const subs = Object.keys(scripts).filter(s => /^check:.+$/.test(s));
if (subs.length > 0) {
for (const script of subs) {
checks.push({ package: pkg.location, script });
}
} else if (scripts.check) {
checks.push({ package: pkg.location, script: "check" });
}
}
if (checks.length === 0) {
console.error("::error::No check scripts found in any workspace package.");
process.exit(1);
}
process.stdout.write("checks=" + JSON.stringify(checks) + "\n");
' >> "$GITHUB_OUTPUT"
PACKAGES=$(npm query .workspace | jq -c '[.[].location]')
if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then
echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)."
exit 1
fi
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
check:
name: ${{ matrix.package }} / ${{ matrix.script }}
name: Typecheck & Test
needs: workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
include: ${{ fromJson(needs.workspaces.outputs.checks) }}
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -66,4 +47,5 @@ jobs:
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix ${{ matrix.package }} ${{ matrix.script }}
- run: npm run --prefix ${{ matrix.package }} check
- run: npm run --prefix ${{ matrix.package }} fix
+1 -4
View File
@@ -48,9 +48,6 @@ jobs:
--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:
@@ -67,7 +64,7 @@ jobs:
- name: Download SARIF result
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: OSV Scanner SARIF file
name: osv-results
path: /tmp/osv-results
continue-on-error: true
+140
View File
@@ -0,0 +1,140 @@
name: Plugin Catalog CI
# Admission gate for plugin-catalog entries. Fires ONLY on PRs touching
# plugin-catalog/** so it can never go red on unrelated PRs.
#
# Two gates:
# structural — cheap schema check, no hermes install needed
# pinned-source-validate — supply-chain gate: the pinned sha MUST be
# reachable in the entry's repo, and the plugin
# at that exact commit must pass
# `hermes plugins validate`.
on:
pull_request:
paths:
- "plugin-catalog/**"
permissions:
contents: read
jobs:
structural:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install PyYAML
uses: ./.github/actions/retry
with:
command: pip install pyyaml==6.0.2
- name: Validate catalog files (structural)
run: |
set -euo pipefail
# Validating the whole directory is simpler than diffing and keeps
# the invariant that EVERYTHING in plugin-catalog/ stays valid.
python3 scripts/validate_plugin_catalog.py plugin-catalog/
pinned-source-validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need the merge-base to diff changed catalog files
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Find changed catalog entries
id: changed
run: |
set -euo pipefail
MERGE_BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
# Added + modified entry files only; deletions and removed.yaml
# have nothing to clone.
CHANGED=$(git diff --name-only --diff-filter=AM "$MERGE_BASE"...HEAD \
-- 'plugin-catalog/*.yaml' 'plugin-catalog/*.yml' \
| grep -v '/removed\.yaml$' || true)
echo "Changed catalog entries:"
echo "${CHANGED:-<none>}"
{
echo 'files<<__EOF__'
echo "$CHANGED"
echo '__EOF__'
} >> "$GITHUB_OUTPUT"
- name: Install hermes-agent from the PR's own checkout
if: steps.changed.outputs.files != ''
uses: ./.github/actions/retry
with:
command: pip install -e .
- name: Clone each entry at its pinned sha and validate
if: steps.changed.outputs.files != ''
env:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
run: |
set -euo pipefail
FAILED=0
while IFS= read -r entry; do
[ -z "$entry" ] && continue
echo "::group::validate $entry"
# Parse repo / sha / subdir from the entry yaml.
eval "$(python3 - "$entry" <<'PYEOF'
import shlex
import sys
import yaml
with open(sys.argv[1], encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
print(f"REPO={shlex.quote(str(data.get('repo', '')))}")
print(f"SHA={shlex.quote(str(data.get('sha', '')))}")
print(f"SUBDIR={shlex.quote(str(data.get('subdir', '') or ''))}")
PYEOF
)"
echo "repo=$REPO sha=$SHA subdir=$SUBDIR"
CLONE_DIR=$(mktemp -d)
# Full clone (no --depth 1): the pinned sha may not be the branch tip.
if ! git clone "$REPO" "$CLONE_DIR"; then
echo "::error file=$entry::clone failed for $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
# SUPPLY-CHAIN GATE: the pinned sha must be reachable in the repo.
if ! git -C "$CLONE_DIR" checkout --detach "$SHA"; then
echo "::error file=$entry::pinned sha $SHA is not reachable in $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
PLUGIN_DIR="$CLONE_DIR${SUBDIR:+/$SUBDIR}"
if [ ! -f "$PLUGIN_DIR/plugin.yaml" ]; then
echo "::error file=$entry::no plugin.yaml at subdir '$SUBDIR' of $REPO@$SHA"
FAILED=1; echo "::endgroup::"; continue
fi
# Manifest schema + declared-vs-registered capability check.
if hermes plugins validate "$PLUGIN_DIR"; then
echo "✅ PASS: $entry"
else
echo "::error file=$entry::hermes plugins validate failed"
FAILED=1
fi
echo "::endgroup::"
done <<< "$CHANGED_FILES"
if [ "$FAILED" -ne 0 ]; then
echo "❌ FAIL: one or more catalog entries failed pinned-source validation"
exit 1
fi
echo "✅ PASS: all changed catalog entries validated at their pinned shas"
@@ -1,71 +0,0 @@
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
env:
GH_TOKEN: ${{ github.token }}
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"
+1 -12
View File
@@ -23,10 +23,6 @@ on:
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
@@ -82,25 +78,18 @@ jobs:
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"
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
- name: Fail on missing label
if: steps.label-check.outputs.ci_reviewed != 'true'
+1 -2
View File
@@ -21,7 +21,6 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: trusted-automation
steps:
- name: Probe live index
id: probe
@@ -114,7 +113,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Open issue on degraded / failed probe
+2 -4
View File
@@ -21,7 +21,6 @@ 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
@@ -29,7 +28,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -61,13 +60,12 @@ 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 }}
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Trigger Deploy Site workflow
env:
+11 -8
View File
@@ -65,11 +65,17 @@ jobs:
with:
fetch-depth: 0
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ github.token }}
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
@@ -87,7 +93,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 --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
@@ -135,11 +141,8 @@ 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 --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
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
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: Install-hook file added or modified
These files can execute code during package installation or interpreter startup.
+5
View File
@@ -215,6 +215,11 @@ 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
+188
View File
@@ -0,0 +1,188 @@
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: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# 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: ${{ steps.app-token.outputs.token }}
# 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
+4 -7
View File
@@ -1,8 +1,6 @@
.DS_Store
/venv/
/venv.old/
/venv.stale.runtime-*/
/.hermes-runtime/
/_pycache/
*.pyc*
__pycache__/
@@ -115,6 +113,10 @@ website/static/api/skills-index.json
# every build).
website/static/api/skills.json
website/static/api/skills-meta.json
# plugins.json + plugins-meta.json are build artifacts emitted by
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
website/static/api/plugins.json
website/static/api/plugins-meta.json
# automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/automation-blueprints-index.json
@@ -152,11 +154,6 @@ 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/
+14
View File
@@ -0,0 +1,14 @@
graft skills
graft optional-skills
graft optional-mcps
graft hermes_cli/web_dist
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]
+6 -9
View File
@@ -32,7 +32,6 @@ else:
import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
@@ -191,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 the runtime lazy installer.
with ``hermes postinstall`` and the runtime lazy installer.
Returns 0 on success, 1 on failure.
"""
@@ -252,13 +251,11 @@ def main(argv: list[str] | None = None) -> None:
# MCP servers dynamically via asyncio.to_thread inside the event
# loop; that path is unaffected.) Moved from model_tools.py module
# scope to avoid freezing the gateway's loop on lazy import (#16856).
# Metadata-only hosts can opt out of unrelated global MCP startup.
if os.environ.get("HERMES_ACP_SKIP_CONFIGURED_MCP", "").strip() != "1":
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug("MCP tool discovery failed at ACP startup", exc_info=True)
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug("MCP tool discovery failed at ACP startup", exc_info=True)
agent = HermesACPAgent()
try:
+50 -338
View File
@@ -74,10 +74,6 @@ 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,
@@ -85,110 +81,6 @@ from tools.approval import (
logger = logging.getLogger(__name__)
def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, str]]]]:
"""Return ``(slug, label, [(model_id, description), ...])`` for named endpoints.
Covers both the v12 ``providers:`` mapping and the legacy
``custom_providers:`` list. These endpoints never appear in canonical
provider enumeration, so without this the ACP model selector hides every
named endpoint that the TUI ``/model`` picker already renders (#47039
implemented named-endpoint rows for the TUI surface only).
Model lists come from the entry's declared models (``default_model`` +
``models``), refreshed from the endpoint's live ``/models`` listing when a
credential is available and ``discover_models`` is not disabled. Declared
models are kept even when live discovery fails — some OpenAI-compatible
endpoints (e.g. Bedrock Mantle Responses) expose no ``/models`` route at
all yet serve the declared models fine.
Slugs use the ``custom:<name>`` shape that ``parse_model_input`` and
``resolve_runtime_provider`` already resolve, so encoded choice ids
(``custom:<name>:<model>``) round-trip through ``set_session_model``
unchanged.
"""
try:
from hermes_cli.config import (
get_compatible_custom_providers,
is_provider_enabled,
load_config,
)
from hermes_cli.models import fetch_api_models
except ImportError:
return []
try:
cfg = load_config()
entries = get_compatible_custom_providers(cfg)
except Exception:
logger.debug("Could not load named custom providers", exc_info=True)
return []
# ``get_compatible_custom_providers`` drops the ``enabled`` flag during
# normalization, so collect explicitly disabled provider keys from the
# raw config and skip their entries below.
disabled_keys: set[str] = set()
raw_providers = cfg.get("providers") if isinstance(cfg, dict) else None
if isinstance(raw_providers, dict):
for raw_key, raw_entry in raw_providers.items():
if isinstance(raw_entry, dict) and not is_provider_enabled(raw_entry):
disabled_keys.add(str(raw_key).strip().lower())
catalogs: list[tuple[str, str, list[tuple[str, str]]]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
provider_key = str(entry.get("provider_key", "") or "").strip()
if provider_key.lower() in disabled_keys:
continue
name = str(entry.get("name", "") or "").strip()
base_url = str(entry.get("base_url", "") or "").strip()
if not name or not base_url:
continue
slug_source = provider_key or name
slug = "custom:" + slug_source.strip().lower().replace(" ", "-")
api_key = str(entry.get("api_key", "") or "").strip()
if not api_key:
key_env = str(entry.get("key_env", "") or "").strip()
api_key = os.environ.get(key_env, "").strip() if key_env else ""
declared: list[str] = []
default_model = str(entry.get("model", "") or "").strip()
if default_model:
declared.append(default_model)
models_cfg = entry.get("models")
if isinstance(models_cfg, dict):
for mid in models_cfg:
mid = str(mid or "").strip()
if mid and mid not in declared:
declared.append(mid)
if not api_key and not declared:
# No credential to discover with and nothing declared:
# not addressable from the selector.
continue
model_ids = list(declared)
discover = entry.get("discover_models", True)
if isinstance(discover, str):
discover = discover.lower() not in {"false", "no", "0"}
if discover and api_key:
try:
live = fetch_api_models(
api_key, base_url, api_mode=entry.get("api_mode")
)
except Exception:
live = None
if live:
model_ids = declared + [m for m in live if m not in declared]
if not model_ids:
continue
catalogs.append((slug, name, [(mid, "") for mid in model_ids]))
return catalogs
try:
from hermes_cli import __version__ as HERMES_VERSION
except Exception:
@@ -201,13 +93,6 @@ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent")
# does not expose a client-side limit, so this is a fixed cap that clients
# paginate against using `cursor` / `next_cursor`.
_LIST_SESSIONS_PAGE_SIZE = 50
# Per-provider cap for the ACP model selector. ACP clients (Zed, Buzz) render
# the whole `availableModels` array in one dropdown, so an unbounded
# cross-provider catalog degrades the picker. Mirrors the cap the MoA picker
# already uses (`hermes_cli/moa_cmd.py`). This bounds each provider's row, not
# the total; aggregator providers stay intentionally uncapped inside the shared
# inventory, and the current model is always kept via the fallback insert below.
ACP_MAX_MODELS_PER_PROVIDER = 200
_MAX_ACP_RESOURCE_BYTES = 512 * 1024
_TEXT_RESOURCE_MIME_PREFIXES = ("text/",)
_TEXT_RESOURCE_MIME_TYPES = {
@@ -696,108 +581,46 @@ class HermesACPAgent(acp.Agent):
return f"{raw_provider}:{raw_model}"
def _build_model_state(self, state: SessionState) -> SessionModelState | None:
"""Return authenticated providers and their models for ACP clients.
The shared Hermes inventory is also used by ``hermes model``, the TUI,
and the dashboard. Keeping ACP on that substrate prevents its selector
from silently collapsing to the current provider's curated list.
"""
"""Return the ACP model selector payload for editors like Zed."""
model = str(state.model or getattr(state.agent, "model", "") or "").strip()
provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter"
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
from hermes_cli.models import normalize_provider, provider_label
from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label
normalized_provider = normalize_provider(provider)
context = load_picker_context().with_overrides(
current_provider=normalized_provider,
current_model=model,
current_base_url=str(getattr(state.agent, "base_url", "") or ""),
)
payload = build_models_payload(
context,
explicit_only=True,
include_unconfigured=False,
picker_hints=False,
canonical_order=True,
pricing=False,
capabilities=False,
refresh=False,
probe_custom_providers=False,
probe_current_custom_provider=False,
max_models=ACP_MAX_MODELS_PER_PROVIDER,
)
provider_name = provider_label(normalized_provider)
available_models: list[ModelInfo] = []
seen_ids: set[str] = set()
for row in payload.get("providers") or []:
row_provider = normalize_provider(str(row.get("slug") or "").strip())
if not row_provider:
continue
provider_name = str(row.get("name") or "").strip() or provider_label(
row_provider
)
for model_entry in row.get("models") or []:
if isinstance(model_entry, dict):
rendered_model = str(
model_entry.get("id")
or model_entry.get("model")
or model_entry.get("name")
or ""
).strip()
else:
rendered_model = str(model_entry or "").strip()
if not rendered_model:
continue
choice_id = self._encode_model_choice(row_provider, rendered_model)
if choice_id in seen_ids:
continue
is_current = (
row_provider == normalized_provider and rendered_model == model
)
description = f"Provider: {provider_name}"
if is_current:
description += " • current"
available_models.append(
ModelInfo(
model_id=choice_id,
name=f"{provider_name} · {rendered_model}",
description=description,
)
)
seen_ids.add(choice_id)
# Named user-defined endpoints (providers: / custom_providers:)
# are invisible to canonical provider enumeration — append them
# so editor clients can select them like the TUI /model picker.
for named_slug, named_label, named_catalog in _named_custom_provider_catalogs():
for named_model, named_desc in named_catalog:
named_choice = self._encode_model_choice(named_slug, named_model)
if not named_choice or named_choice in seen_ids:
continue
named_parts = [f"Provider: {named_label}"]
if named_desc:
named_parts.append(str(named_desc).strip())
if named_slug == normalized_provider and named_model == model:
named_parts.append("current")
available_models.append(
ModelInfo(
model_id=named_choice,
name=named_model,
description="".join(part for part in named_parts if part),
)
for model_id, description in curated_models_for_provider(normalized_provider):
rendered_model = str(model_id or "").strip()
if not rendered_model:
continue
choice_id = self._encode_model_choice(normalized_provider, rendered_model)
if choice_id in seen_ids:
continue
desc_parts = [f"Provider: {provider_name}"]
if description:
desc_parts.append(str(description).strip())
if rendered_model == model:
desc_parts.append("current")
available_models.append(
ModelInfo(
model_id=choice_id,
name=rendered_model,
description="".join(part for part in desc_parts if part),
)
seen_ids.add(named_choice)
)
seen_ids.add(choice_id)
current_model_id = self._encode_model_choice(normalized_provider, model)
if current_model_id and current_model_id not in seen_ids:
provider_name = provider_label(normalized_provider)
available_models.insert(
0,
ModelInfo(
model_id=current_model_id,
name=f"{provider_name} · {model}",
name=model,
description=f"Provider: {provider_name} • current",
),
)
@@ -1146,49 +969,11 @@ 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)
@@ -1196,13 +981,11 @@ 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
@@ -1273,11 +1056,7 @@ class HermesACPAgent(acp.Agent):
if role == "user":
text = self._history_message_text(message)
if text:
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
update = self._history_message_update(role=role, text=text)
if update is not None and not await _send(update):
return
continue
@@ -1289,11 +1068,7 @@ class HermesACPAgent(acp.Agent):
text = self._history_message_text(message)
if text:
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
update = self._history_message_update(role=role, text=text)
if update is not None and not await _send(update):
return
@@ -1443,19 +1218,12 @@ 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
# 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,
)
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(
@@ -1584,26 +1352,6 @@ 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,
@@ -1618,54 +1366,23 @@ class HermesACPAgent(acp.Agent):
await self._send_usage_update(state)
return PromptResponse(stop_reason="end_turn")
# 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
# 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.
with state.runtime_lock:
if state.is_running:
if (
text_only_prompt
and isinstance(user_content, str)
and getattr(
state.agent,
"_supports_active_turn_redirect",
False,
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)"
)
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")
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]"
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
@@ -2109,8 +1826,8 @@ class HermesACPAgent(acp.Agent):
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]
for t in tools:
name = (t.get("function") or {}).get("name", "?")
desc = (t.get("function") or {}).get("description", "")
name = t.get("function", {}).get("name", "?")
desc = t.get("function", {}).get("description", "")
# Truncate long descriptions
if len(desc) > 80:
desc = desc[:77] + "..."
@@ -2194,10 +1911,7 @@ class HermesACPAgent(acp.Agent):
lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens")
if getattr(agent, "compression_enabled", True) is False:
lines.append(
"Auto-compaction is disabled (compression.enabled: false); "
"/compress still compresses manually."
)
lines.append("Compression is disabled for this agent.")
else:
lines.append("Tip: run /compress to compress manually before the threshold.")
@@ -2224,9 +1938,8 @@ class HermesACPAgent(acp.Agent):
return "Nothing to compress — conversation is empty."
try:
agent = state.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 getattr(agent, "compression_enabled", True):
return "Context compression is disabled for this agent."
if not hasattr(agent, "_compress_context"):
return "Context compression not available for this agent."
@@ -2251,7 +1964,6 @@ 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
+16
View File
@@ -0,0 +1,16 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.19.0",
"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.19.0",
"args": ["hermes-acp"]
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 882 B

-12
View File
@@ -701,18 +701,6 @@ 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=(
+43 -153
View File
@@ -69,43 +69,6 @@ def _ra():
return run_agent
def _moa_reference_output_allowed(agent: Any) -> bool:
"""Keep MoA display events off only the machine-readable ``-Q`` surface."""
return not (
getattr(agent, "platform", None) == "cli"
and getattr(agent, "tool_progress_mode", "all") == "off"
)
def _relay_moa_reference_event(agent: Any, event: str, **kwargs: Any) -> None:
"""Relay MoA display events while preserving the ``-Q`` stdout contract."""
if not _moa_reference_output_allowed(agent):
return
cb = getattr(agent, "tool_progress_callback", None)
if cb is None:
return
try:
if event == "moa.reference":
cb(
"moa.reference",
str(kwargs.get("label") or ""),
str(kwargs.get("text") or ""),
None,
moa_index=kwargs.get("index"),
moa_count=kwargs.get("count"),
)
elif event == "moa.aggregating":
cb(
"moa.aggregating",
str(kwargs.get("aggregator") or ""),
None,
None,
moa_ref_count=kwargs.get("ref_count"),
)
except Exception:
pass
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)
@@ -517,7 +480,6 @@ def init_agent(
checkpoint_max_total_size_mb: int = 500,
checkpoint_max_file_size_mb: int = 10,
pass_session_id: bool = False,
requested_provider: str = None,
):
"""
Initialize the AI Agent.
@@ -526,7 +488,6 @@ def init_agent(
base_url (str): Base URL for the model API (optional)
api_key (str): API key for authentication (optional, uses env var if not provided)
provider (str): Provider identifier (optional; used for telemetry/routing hints)
requested_provider (str): Original provider identity before runtime canonicalization
api_mode (str): API mode override: "chat_completions" or "codex_responses"
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
max_iterations (int): Maximum number of tool calling iterations (default: 90)
@@ -607,11 +568,6 @@ def init_agent(
agent.base_url = base_url or ""
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
agent.provider = provider_name or ""
agent.requested_provider = (
requested_provider.strip().lower()
if isinstance(requested_provider, str) and requested_provider.strip()
else agent.provider
)
agent._credential_pool = credential_pool
agent.acp_command = acp_command or command
agent.acp_args = list(acp_args or args or [])
@@ -764,8 +720,6 @@ 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
@@ -777,13 +731,6 @@ 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
@@ -823,10 +770,9 @@ def init_agent(
# Anthropic prompt caching: auto-enabled for Claude models on native
# Anthropic, OpenRouter, and third-party gateways that speak the
# Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces
# input costs by ~75% on multi-turn conversations. Uses four breakpoints:
# the static system prefix, full system prompt, and last two messages
# (falling back to system-and-3 when no static prefix is available). See
# ``_anthropic_prompt_cache_policy`` for the layout-vs-transport decision.
# input costs by ~75% on multi-turn conversations. Uses system_and_3
# strategy (4 breakpoints). See ``_anthropic_prompt_cache_policy``
# for the layout-vs-transport decision.
agent._use_prompt_caching, agent._use_native_cache_layout = (
agent._anthropic_prompt_cache_policy()
)
@@ -951,12 +897,6 @@ 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).
@@ -1063,20 +1003,49 @@ def init_agent(
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
elif agent.provider == "moa":
from agent.moa_loop import build_moa_facade
from agent.moa_loop import MoAClient
agent.api_mode = "chat_completions"
# build_moa_facade wires the reference relay that routes
# reference-model outputs to the agent's tool_progress_callback so
# Route reference-model outputs to the agent's tool_progress_callback so
# every surface that already consumes it (CLI spinner/scrollback, TUI,
# desktop, gateway) can show each reference's answer as a labelled
# block before the aggregator acts. The facade emits "moa.reference",
# "moa.progress", "moa.phase", and "moa.aggregating" events, forwarded
# through the same callback the tool lifecycle uses. Best-effort and
# cache-safe — display-only events, they never touch the message
# history. The factory is shared with the fallback-restore/recovery
# paths so a restored facade keeps emitting these events (#53802).
agent.client = build_moa_facade(agent, agent.model)
# desktop, gateway) can show each reference's answer as a labelled block
# before the aggregator acts. The facade emits "moa.reference" and
# "moa.aggregating" events; we forward them through the same callback
# the tool lifecycle uses. Best-effort and cache-safe — these are
# display-only events, they never touch the message history.
def _moa_reference_relay(event: str, **kwargs: Any) -> None:
cb = getattr(agent, "tool_progress_callback", None)
if cb is None:
return
try:
if event == "moa.reference":
label = str(kwargs.get("label") or "")
text = str(kwargs.get("text") or "")
idx = kwargs.get("index")
count = kwargs.get("count")
cb(
"moa.reference",
label,
text,
None,
moa_index=idx,
moa_count=count,
)
elif event == "moa.aggregating":
cb(
"moa.aggregating",
str(kwargs.get("aggregator") or ""),
None,
None,
moa_ref_count=kwargs.get("ref_count"),
)
except Exception:
pass
agent.client = MoAClient(
agent.model or "default",
reference_callback=_moa_reference_relay,
)
agent._client_kwargs = {}
agent.api_key = api_key or "moa-virtual-provider"
agent.base_url = "moa://local"
@@ -1342,13 +1311,6 @@ def init_agent(
print("⚠️ Warning: API key appears invalid or missing")
except Exception as e:
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
# Keep a stable identity for the pool entry that supplied this runtime.
# OAuth refreshes can replace the runtime token before a failed request is
# recovered, so the mutable API-key value alone cannot reliably attribute
# the failure to its source entry.
from agent.agent_runtime_helpers import sync_credential_pool_entry_id
sync_credential_pool_entry_id(agent)
# Provider fallback chain — ordered list of backup providers tried
# when the primary is exhausted (rate-limit, overload, connection
@@ -1495,9 +1457,6 @@ def init_agent(
# Cached system prompt -- built once per session, only rebuilt on compression
agent._cached_system_prompt: Optional[str] = None
# Cross-session-stable prefix of the cached prompt. It remains separate
# from the persisted string and is used only to place an early cache marker.
agent._cached_system_prompt_static: Optional[str] = None
# Filesystem checkpoint manager (transparent — not a tool)
from tools.checkpoint_manager import CheckpointManager
@@ -1829,28 +1788,6 @@ 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))
# Minimum REAL (actionable) user messages guaranteed to survive in the
# uncompressed tail (compression.min_tail_user_messages). Default 1
# preserves current behavior exactly — the existing single-user tail
# anchor. Values > 1 extend the guarantee to the last N actionable
# user turns. Booleans rejected (bool subclasses int), non-int-like
# values fall back to 1, floor at 1.
_raw_min_tail_users = _compression_cfg.get("min_tail_user_messages", 1)
if isinstance(_raw_min_tail_users, bool):
compression_min_tail_users = 1
elif isinstance(_raw_min_tail_users, int):
compression_min_tail_users = _raw_min_tail_users
elif isinstance(_raw_min_tail_users, float):
compression_min_tail_users = (
int(_raw_min_tail_users) if _raw_min_tail_users.is_integer() else 1
)
else:
try:
compression_min_tail_users = int(str(_raw_min_tail_users).strip())
except (TypeError, ValueError):
compression_min_tail_users = 1
if compression_min_tail_users < 1:
compression_min_tail_users = 1
# 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
@@ -1879,39 +1816,6 @@ def init_agent(
if compression_max_attempts < 1:
compression_max_attempts = 3
compression_max_attempts = min(compression_max_attempts, 10)
def _parse_prune_int(raw, default):
# Same parser semantics as compression.max_attempts above: reject
# booleans (bool subclasses int — YAML `true` would coerce to 1),
# reject fractional floats rather than truncating them, accept
# integral floats and numeric strings, fall back to the default on
# anything else.
if isinstance(raw, bool):
return default
if isinstance(raw, int):
return raw
if isinstance(raw, float):
return int(raw) if raw.is_integer() else default
try:
return int(str(raw).strip())
except (TypeError, ValueError):
return default
# Opt-in proactive tool-result prune trigger (0 = disabled — the
# default, so an unset key is behavior-neutral). Negative values are
# treated as disabled rather than erroring.
compression_proactive_prune_tokens = max(
0, _parse_prune_int(_compression_cfg.get("proactive_prune_tokens", 0), 0)
)
compression_proactive_prune_min_chars = _parse_prune_int(
_compression_cfg.get("proactive_prune_min_result_chars", 8000), 8000
)
compression_proactive_prune_min_reclaim = max(
0,
_parse_prune_int(
_compression_cfg.get("proactive_prune_min_reclaim_tokens", 4096), 4096
),
)
# 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
@@ -1965,12 +1869,6 @@ 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
@@ -2386,10 +2284,6 @@ def init_agent(
max_tokens=agent.max_tokens,
model_thresholds=compression_model_thresholds,
threshold_tokens_cap=compression_threshold_tokens,
proactive_prune_tokens=compression_proactive_prune_tokens,
proactive_prune_min_result_chars=compression_proactive_prune_min_chars,
proactive_prune_min_reclaim_tokens=compression_proactive_prune_min_reclaim,
min_tail_user_messages=compression_min_tail_users,
)
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(_bind_session_state):
@@ -2401,9 +2295,6 @@ def init_agent(
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).
@@ -2654,7 +2545,6 @@ def init_agent(
agent._primary_runtime = {
"model": agent.model,
"provider": agent.provider,
"requested_provider": agent.requested_provider,
"base_url": agent.base_url,
"api_mode": agent.api_mode,
"api_key": getattr(agent, "api_key", ""),
+23 -133
View File
@@ -850,25 +850,6 @@ def strip_think_blocks(agent, content: str) -> str:
def sync_credential_pool_entry_id(agent) -> None:
"""Rebind ``agent._credential_pool_entry_id`` from the current pool + key.
OAuth refreshes can replace the runtime token before a failed request is
recovered, so the mutable API-key value alone cannot reliably attribute
the failure to its source entry. This resolves the stable pool-entry ID
for the agent's current ``api_key`` and clears it when no pool is bound.
"""
pool = getattr(agent, "_credential_pool", None)
try:
agent._credential_pool_entry_id = (
pool.entry_id_for_api_key(getattr(agent, "api_key", None))
if pool is not None
else None
)
except Exception:
agent._credential_pool_entry_id = None
def recover_with_credential_pool(
agent,
*,
@@ -941,43 +922,6 @@ def recover_with_credential_pool(
)
return False, has_retried_429
# Attribute the failure to the API key the agent actually dispatched the
# request with, not to pool.current(). The current() pointer is shared,
# mutable state — round-robin select() advances it on every call, and
# concurrent turns or a second process (gateway/dashboard) reloading the
# pool reset it to None — so by the time recovery runs it routinely points
# at a DIFFERENT, healthy entry. Marking that entry exhausted copies this
# request's error/reset time onto it and can take the whole pool offline
# from a single rate-limited key (#43747). ``_swap_credential`` keeps
# ``agent.api_key`` in sync with the entry in use, so it identifies the
# failing entry exactly; fall back to current()'s key only when the agent
# carries no key at all.
_api_key_hint = getattr(agent, "api_key", None) or None
_raw_credential_id = getattr(agent, "_credential_pool_entry_id", None)
_credential_id = (
_raw_credential_id
if isinstance(_raw_credential_id, str) and _raw_credential_id
else None
)
if not _api_key_hint:
_cur = pool.current()
if _cur:
_api_key_hint = getattr(_cur, "runtime_api_key", None)
if not _credential_id:
_current_id = getattr(_cur, "id", None)
if isinstance(_current_id, str) and _current_id:
_credential_id = _current_id
def _rotate_failed_credential(rotate_status: int):
kwargs = {
"status_code": rotate_status,
"error_context": error_context,
"api_key_hint": _api_key_hint,
}
if _credential_id:
kwargs["credential_id"] = _credential_id
return pool.mark_exhausted_and_rotate(**kwargs)
effective_reason = classified_reason
if effective_reason is None:
if status_code == 402:
@@ -1008,10 +952,14 @@ def recover_with_credential_pool(
if effective_reason == FailoverReason.billing:
rotate_status = status_code if status_code is not None else 402
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
api_key_hint=getattr(agent, "api_key", None),
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (billing) — rotated to pool entry %s",
@@ -1027,21 +975,7 @@ def recover_with_credential_pool(
# rotate immediately. This prevents the "cancel-between-429s" trap
# where has_retried_429 (a local var) gets reset on each new prompt,
# causing the pool to retry the same exhausted credential forever.
# Prefer the entry matching the failing key over the shared current()
# pointer, for the same attribution reason as above.
current_entry = None
if _credential_id:
current_entry = next(
(e for e in pool.entries() if e.id == _credential_id),
None,
)
if _api_key_hint:
current_entry = current_entry or next(
(e for e in pool.entries() if e.runtime_api_key == _api_key_hint),
None,
)
if current_entry is None:
current_entry = pool.current()
current_entry = pool.current()
current_last_status = getattr(current_entry, "last_status", None) if current_entry else None
if current_last_status == STATUS_EXHAUSTED:
_ra().logger.info(
@@ -1049,7 +983,7 @@ def recover_with_credential_pool(
current_last_status,
)
rotate_status = status_code if status_code is not None else 429
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
@@ -1073,7 +1007,7 @@ def recover_with_credential_pool(
if not has_retried_429 and not usage_limit_reached:
return False, True
rotate_status = status_code if status_code is not None else 429
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit) — rotated to pool entry %s",
@@ -1088,7 +1022,7 @@ def recover_with_credential_pool(
# Subscription/entitlement 403s look like auth failures on the wire
# but refresh cannot fix them — the OAuth token is already valid,
# the account simply lacks the entitlement. Without this guard,
# the refresh path keeps minting fresh tokens against the
# ``try_refresh_current()`` keeps minting fresh tokens against the
# same unsubscribed account and the main agent loop spins re-issuing
# the same 403 until the user Ctrl+C's.
#
@@ -1141,16 +1075,9 @@ def recover_with_credential_pool(
agent.provider or "provider",
)
return False, has_retried_429
# Refresh the entry that supplied the failing key, not current():
# the shared pointer can reference a different, healthy entry, and
# refreshing it would consume that entry's single-use refresh token
# (or mark it exhausted on failure) for a failure it never had.
refresh_kwargs = {"api_key_hint": _api_key_hint}
if _credential_id:
refresh_kwargs["credential_id"] = _credential_id
refreshed = pool.try_refresh_matching(**refresh_kwargs)
refreshed = pool.try_refresh_current()
if refreshed is not None:
# ``try_refresh_matching()`` re-mints a fresh OAuth token and reports
# ``try_refresh_current()`` re-mints a fresh OAuth token and reports
# success even when the upstream keeps rejecting it — a single-entry
# pool (common for OAuth/Max subscribers) has nothing to rotate to,
# so a bare "refreshed → retry" loop spins forever on the same dead
@@ -1178,9 +1105,9 @@ def recover_with_credential_pool(
agent._swap_credential(refreshed)
return True, has_retried_429
# Refresh failed — rotate to next credential instead of giving up.
# The failed entry is already marked exhausted by the refresh attempt.
# The failed entry is already marked exhausted by try_refresh_current().
rotate_status = status_code if status_code is not None else 401
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
if next_entry is not None:
_ra().logger.info(
"Credential %s (auth refresh failed) — rotated to pool entry %s",
@@ -1225,17 +1152,11 @@ def try_recover_primary_transport(
return False
try:
# Retire the existing client to release stale connections. #70773:
# never hard-close the shared client here — this runs on the
# conversation-loop thread while workers from stale-killed streaming
# attempts may still be unwinding their SSL BIOs on the old pool.
# ``_retire_shared_openai_client`` shuts the sockets down (FD-safe
# from any thread) and defers the FD release to GC, which cannot
# complete until every borrowing thread has unwound.
# Close existing client to release stale connections
if getattr(agent, "client", None) is not None:
try:
agent._retire_shared_openai_client(
agent.client, reason="primary_recovery",
agent._close_openai_client(
agent.client, reason="primary_recovery", shared=True,
)
except Exception:
pass
@@ -1245,7 +1166,6 @@ def try_recover_primary_transport(
agent._client_kwargs = dict(rt["client_kwargs"])
agent.model = rt["model"]
agent.provider = rt["provider"]
agent.requested_provider = rt.get("requested_provider", agent.provider)
agent.base_url = rt["base_url"]
agent.api_mode = rt["api_mode"]
if hasattr(agent, "_transport_cache"):
@@ -1262,14 +1182,6 @@ def try_recover_primary_transport(
)
agent._is_anthropic_oauth = rt["is_anthropic_oauth"]
agent.client = None
elif (agent.provider or "").strip().lower() == "moa":
# MoA is a virtual provider with empty client_kwargs — rebuilding
# via _create_openai_client would raise "api_key client option
# must be set". Recreate the facade through the shared factory so
# the reference_callback relay survives recovery (#53802).
from agent.moa_loop import build_moa_facade
agent.client = build_moa_facade(agent, agent.model)
else:
agent.client = agent._create_openai_client(
dict(rt["client_kwargs"]),
@@ -1417,7 +1329,6 @@ def restore_primary_runtime(agent) -> bool:
# ── Core runtime state ──
agent.model = rt["model"]
agent.provider = rt["provider"]
agent.requested_provider = rt.get("requested_provider", agent.provider)
agent.base_url = rt["base_url"] # setter updates _base_url_lower
agent.api_mode = rt["api_mode"]
if hasattr(agent, "_transport_cache"):
@@ -1433,18 +1344,7 @@ def restore_primary_runtime(agent) -> bool:
)
# ── Rebuild client for the primary provider ──
if agent.provider == "moa":
# MoA is a virtual chat-completions provider. It never has real
# OpenAI client kwargs; restoring it after a fallback must recreate
# the facade, not call OpenAI() with an empty api_key. Use the
# shared factory so the restored facade keeps the reference_callback
# relay wired at init — a bare MoAClient() would silently stop
# emitting moa.reference/moa.aggregating display events (#53802).
from agent.moa_loop import build_moa_facade
agent.client = build_moa_facade(agent, agent.model)
agent._anthropic_client = None
elif agent.api_mode == "anthropic_messages":
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
@@ -1497,7 +1397,6 @@ def restore_primary_runtime(agent) -> bool:
pool_matches_primary = False
if pool is not None and pool_provider and not pool_matches_primary:
agent._credential_pool = None
agent._credential_pool_entry_id = None
try:
from agent.credential_pool import load_pool
@@ -1517,7 +1416,6 @@ def restore_primary_runtime(agent) -> bool:
# the pool for its current best entry and swap the live credential in.
# When the pool is absent, empty, or the entry has no usable key, we
# keep the snapshot key (the existing behavior). Fixes #25205.
agent._credential_pool_entry_id = None
pool = getattr(agent, "_credential_pool", None)
if pool is not None and pool.has_available():
entry = pool.select()
@@ -2095,7 +1993,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
for name in (
"model",
"provider",
"requested_provider",
"base_url",
"api_mode",
"api_key",
@@ -2114,9 +2011,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# restore the original pool (issue #52727: pool reload is part of this
# switch and must be reversible on rollback).
_snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING)
_snapshot["_credential_pool_entry_id"] = getattr(
agent, "_credential_pool_entry_id", _MISSING
)
try:
# Clear the per-config context_length override so the new model's
@@ -2127,7 +2021,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Swap core runtime fields ──
agent.model = new_model
agent.provider = new_provider
agent.requested_provider = new_provider
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
@@ -2173,7 +2066,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# A pool bound to the old provider is worse than no pool: the
# recovery guard rejects it and every later 401/429 skips rotation.
agent._credential_pool = None
agent._credential_pool_entry_id = None
try:
from agent.credential_pool import load_pool
agent._credential_pool = load_pool(new_provider)
@@ -2183,9 +2075,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"continuing without pool rotation this turn",
new_provider, _pool_exc,
)
# ── Build new client ──
if (new_provider or "").strip().lower() == "moa":
from agent.moa_loop import build_moa_facade
from agent.moa_loop import MoAClient
# The MoA virtual provider speaks only chat.completions via the
# MoAClient facade — the aggregator's real transport
@@ -2202,7 +2095,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent.api_key = api_key or "moa-virtual-provider"
agent.base_url = "moa://local"
agent._client_kwargs = {}
agent.client = build_moa_facade(agent, agent.model)
agent.client = MoAClient(agent.model or "default")
elif api_mode == "anthropic_messages":
from agent.anthropic_adapter import (
build_anthropic_client,
@@ -2278,8 +2171,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
reason="switch_model",
shared=True,
)
sync_credential_pool_entry_id(agent)
except Exception:
# Rollback every mutated field to the pre-swap snapshot so the agent
# is left consistent (old model + old provider + old client) and the
@@ -2379,7 +2270,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent._primary_runtime = {
"model": agent.model,
"provider": agent.provider,
"requested_provider": agent.requested_provider,
"base_url": agent.base_url,
"api_mode": agent.api_mode,
"api_key": getattr(agent, "api_key", ""),
+12 -114
View File
@@ -368,7 +368,7 @@ def _detect_claude_code_version() -> str:
try:
result = _sp.run(
[cmd, "--version"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
# Output is like "2.1.74 (Claude Code)" or just "2.1.74"
@@ -914,7 +914,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
"-s", "Claude Code-credentials",
"-w"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
@@ -1881,28 +1881,6 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
return out
_EMPTY_TEXT_PLACEHOLDER = "(empty)"
def _safe_text(text: Any) -> str:
"""Return ``text`` if it's non-whitespace, else a non-whitespace placeholder.
The Anthropic Messages API rejects requests where a text content block is
empty or whitespace-only (HTTP 400 "text content blocks must contain
non-whitespace text"). When such a block gets stored in session history —
e.g. produced by context compression it is replayed verbatim on every
subsequent turn, permanently wedging the session. Coercing to a
non-whitespace placeholder is self-healing: the next API call recovers.
Mirrors ``bedrock_adapter._safe_text`` (#9486); ref #69512.
"""
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 _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Strip output-only fields from a stored Anthropic content block so it is
valid as REQUEST input on replay.
@@ -1920,18 +1898,7 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return None
btype = b.get("type")
if btype == "text":
text_val = b.get("text", "")
# Bedrock and strict Anthropic-compatible endpoints reject text
# blocks where "text" is empty or whitespace-only (#69512). Drop the
# blank block (the caller relocates any cache_control it carried and
# falls back to a non-whitespace placeholder when nothing survives)
# rather than coercing in place — a coerced "(empty)" block would be
# model-visible noise next to surviving thinking/tool_use blocks.
# Type-safe: captured blocks can carry text=None from an invalid
# upstream payload, which a bare .strip() would crash on.
if not isinstance(text_val, str) or not text_val.strip():
return None
out: Dict[str, Any] = {"type": "text", "text": text_val}
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
# citations is input-valid ONLY when it's a non-empty list; the SDK
# emits citations=None on responses, which the input schema rejects.
cits = b.get("citations")
@@ -2019,17 +1986,9 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
parsed_args = {}
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
replayed: List[Dict[str, Any]] = []
_relocated_replay_cache_control = None
_dropped_blank_text = False
for b in ordered_blocks:
clean = _sanitize_replay_block(b)
if clean is None:
if isinstance(b, dict) and b.get("type") == "text":
_dropped_blank_text = True
if isinstance(b, dict) and isinstance(b.get("cache_control"), dict):
# A dropped blank text block can still carry the cache
# breakpoint marker -- relocate it rather than losing it.
_relocated_replay_cache_control = b["cache_control"]
continue
if clean.get("type") == "tool_use":
# Override raw (un-redacted) input with the redacted copy when
@@ -2039,68 +1998,20 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
if redacted is not None:
clean["input"] = redacted
replayed.append(clean)
# When every text block was blank and nothing cacheable survived
# (e.g. signed thinking + a blank text block, or a SOLE blank
# cache-marked block), emit the non-whitespace placeholder so the
# replayed message stays schema-valid (#69512) and a relocated cache
# marker still has a carrier instead of being silently lost.
_has_cacheable_replay = any(
isinstance(b, dict) and b.get("type") in {"text", "tool_use"}
for b in replayed
)
if not _has_cacheable_replay and (
_dropped_blank_text or _relocated_replay_cache_control is not None
):
replayed.append({"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER})
if replayed:
if _relocated_replay_cache_control is not None:
_apply_assistant_cache_control_to_last_cacheable_block(
replayed, _relocated_replay_cache_control
)
_apply_assistant_cache_control_to_last_cacheable_block(
replayed, m.get("cache_control")
)
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
# Cache markers dropped along with a blank block are relocated onto the
# last surviving cacheable block below (via
# _apply_assistant_cache_control_to_last_cacheable_block), rather than
# lost -- prompt_caching.py's _apply_cache_marker() sets cache_control
# directly on content[-1] for list content, so if that last part happens
# to be blank text, dropping it silently would lose the breakpoint.
_relocated_cache_control = None
if content:
if isinstance(content, list):
converted_content = _convert_content_to_anthropic(content)
if isinstance(converted_content, list):
# Bedrock and strict Anthropic-compatible endpoints reject
# text blocks where "text" is empty or whitespace-only. The
# ordered-replay path enforces the same invariant via
# _sanitize_replay_block(). Type-safe against ANY invalid
# "text" value from an upstream payload -- None, or a
# truthy non-string like an int -- not just None: checking
# isinstance() first (rather than `blk.get("text") or ""`)
# means a non-string value is treated as blank/invalid
# instead of reaching .strip() and raising AttributeError.
for blk in converted_content:
_blk_text = blk.get("text") if isinstance(blk, dict) else None
if (
isinstance(blk, dict)
and blk.get("type") == "text"
and (not isinstance(_blk_text, str) or not _blk_text.strip())
):
if isinstance(blk.get("cache_control"), dict):
_relocated_cache_control = blk["cache_control"]
continue
blocks.append(blk)
blocks.extend(converted_content)
else:
# Scalar (non-list) content: a whitespace-only string is the
# same invalid-payload case as an empty list block -- drop it
# rather than emitting a blank text block.
text_str = str(content)
if text_str.strip():
blocks.append({"type": "text", "text": text_str})
blocks.append({"type": "text", "text": str(content)})
for tc in m.get("tool_calls", []):
if not tc or not isinstance(tc, dict):
continue
@@ -2116,6 +2027,9 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"name": fn.get("name", ""),
"input": parsed_args,
})
_apply_assistant_cache_control_to_last_cacheable_block(
blocks, m.get("cache_control")
)
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
# tool-call messages to carry reasoning_content when thinking is
# enabled server-side. Preserve it as a thinking block so Kimi
@@ -2141,26 +2055,10 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
)
if isinstance(reasoning_content, str) and not _already_has_thinking:
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
# Anthropic rejects empty assistant content. IMPORTANT: fall back only
# to the placeholder, never to the raw `content` variable -- `content`
# is the UNFILTERED original message content, and can itself be exactly
# the blank/whitespace-only payload the filtering above just removed
# (a sole blank text block, or scalar whitespace with no tool_calls).
# `blocks or content` there would silently restore the invalid provider
# payload this function exists to prevent (#69512).
effective = blocks if blocks else [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
# Applied here (after the empty-fallback resolution) rather than
# earlier against `blocks` directly, so a cache_control relocated from
# a dropped blank block that was the ONLY block still lands on the
# (empty) placeholder instead of being silently lost when blocks was
# empty at the point the marker would otherwise have been applied.
if _relocated_cache_control is not None:
_apply_assistant_cache_control_to_last_cacheable_block(
effective, _relocated_cache_control
)
_apply_assistant_cache_control_to_last_cacheable_block(
effective, m.get("cache_control")
)
# Anthropic rejects empty assistant content
effective = blocks or content
if not effective or effective == "":
effective = [{"type": "text", "text": "(empty)"}]
return {"role": "assistant", "content": effective}
+41 -260
View File
@@ -1058,29 +1058,16 @@ class _CodexCompletionsAdapter:
# key in extra_body (not top-level) and GitHub/Copilot Responses opts
# out of cache-key routing entirely — for those hosts, skip it here.
try:
from agent.transports.codex import (
_content_cache_key,
_default_prompt_cache_retention_for_request,
)
from agent.transports.codex import _content_cache_key
from utils import base_url_host_matches
_host_src = str(getattr(self._client, "base_url", "") or "")
_is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai")
_is_github = (
base_url_host_matches(_host_src, "githubcopilot.com")
or base_url_host_matches(_host_src, "models.github.ai")
)
_is_github = base_url_host_matches(_host_src, "githubcopilot.com")
if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs:
_cache_key = _content_cache_key(instructions, resp_kwargs.get("tools"))
if _cache_key:
resp_kwargs["prompt_cache_key"] = _cache_key
if "prompt_cache_retention" not in resp_kwargs:
_cache_retention = _default_prompt_cache_retention_for_request(
model,
_host_src,
)
if _cache_retention:
resp_kwargs["prompt_cache_retention"] = _cache_retention
except Exception:
logger.debug(
"Codex auxiliary: prompt_cache_key derivation skipped", exc_info=True
@@ -1716,7 +1703,7 @@ def _read_nous_auth() -> Optional[dict]:
try:
if not _AUTH_JSON_PATH.is_file():
return None
data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8"))
data = json.loads(_AUTH_JSON_PATH.read_text())
if data.get("active_provider") != "nous":
return None
provider = data.get("providers", {}).get("nous", {})
@@ -2316,62 +2303,6 @@ def _read_main_base_url() -> str:
return ""
def _resolve_moa_aggregator(preset_name: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""Resolve a MoA preset to its aggregator (provider, model) pair.
"moa" is a virtual provider the acting model of a preset is its
aggregator slot, and there is no real "moa" HTTP endpoint. Auxiliary
tasks (title generation, compression, vision, commit messages, ) don't
need the reference fan-out, so every aux resolution layer maps
provider="moa"/model=<preset> to the aggregator's real provider+model
through this single helper (shared by ``_resolve_auto``,
``_resolve_task_provider_model``, and ``resolve_provider_client`` so the
preset lookup and validation cannot drift between paths).
Args:
preset_name: The MoA preset name (usually carried in the "model"
field), or None/"" to resolve the user's default preset.
Returns:
(aggregator_provider, aggregator_model), or (None, None) when the
preset cannot be resolved (missing config, renamed/deleted preset,
or a malformed aggregator slot).
"""
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
preset = resolve_moa_preset(load_config().get("moa") or {}, preset_name or None)
agg = preset.get("aggregator") or {}
agg_provider = str(agg.get("provider") or "").strip()
agg_model = str(agg.get("model") or "").strip()
if agg_provider and agg_model and agg_provider.lower() != "moa":
return agg_provider, agg_model
except Exception:
logger.debug(
"MoA aggregator resolution failed for preset %r", preset_name, exc_info=True
)
return None, None
def _read_main_model_for_aux() -> str:
"""Main model with MoA presets unwrapped to the aggregator's model.
When the main provider is ``moa``, ``_read_main_model()`` returns a MoA
*preset name* (e.g. "opus-gpt") never a valid wire model id on any
provider. Auxiliary fallback chains that pre-fill a missing model from
the main model must use this reader instead, so unset aux models default
to the preset's acting (aggregator) model. Returns "" when the main
provider is moa but the preset cannot be resolved sending nothing is
strictly better than sending a preset name that 400s.
"""
model = _read_main_model()
if (_read_main_provider() or "").strip().lower() == "moa":
_, agg_model = _resolve_moa_aggregator(model)
return agg_model or ""
return model
def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
"""Return the main api_key only when *aux_base_url* points at the same
host as the main model's base_url.
@@ -2447,7 +2378,6 @@ def set_runtime_main(
provider: str,
model: str,
*,
requested_provider: str = "",
base_url: str = "",
api_key: Any = "",
api_mode: str = "",
@@ -2463,7 +2393,6 @@ def set_runtime_main(
global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT
runtime = {
"provider": (provider or "").strip().lower(),
"requested_provider": (requested_provider or "").strip().lower(),
"model": (model or "").strip(),
"base_url": (base_url or "").strip(),
"api_key": (
@@ -2644,7 +2573,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
return None, None
if custom_base.lower().startswith(_CODEX_AUX_BASE_URL.lower()):
return None, None
model = _read_main_model_for_aux() or "gpt-4o-mini"
model = _read_main_model() or "gpt-4o-mini"
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
_clean_base, _dq = _extract_url_query_params(custom_base)
_extra = {"default_query": _dq} if _dq else {}
@@ -2936,7 +2865,6 @@ _AUTO_PROVIDER_LABELS = {
}
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode")
_MAIN_RUNTIME_CONTEXT_FIELDS = _MAIN_RUNTIME_FIELDS + ("requested_provider",)
def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]:
@@ -2959,7 +2887,7 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
if not isinstance(main_runtime, dict):
return {}
normalized: Dict[str, Any] = {}
for field in _MAIN_RUNTIME_CONTEXT_FIELDS:
for field in _MAIN_RUNTIME_FIELDS:
value = main_runtime.get(field)
# Preserve a callable api_key (Entra ID bearer provider) unchanged.
if field == "api_key" and callable(value) and not isinstance(value, str):
@@ -2967,10 +2895,9 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
continue
if isinstance(value, str) and value.strip():
normalized[field] = value.strip()
for identity_field in ("provider", "requested_provider"):
identity = normalized.get(identity_field)
if isinstance(identity, str):
normalized[identity_field] = identity.lower()
provider = normalized.get("provider")
if isinstance(provider, str):
normalized["provider"] = provider.lower()
return normalized
@@ -3661,7 +3588,6 @@ def _retry_same_provider_sync(
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
extra_headers: Optional[Dict[str, str]] = None,
) -> Any:
if task == "vision":
_, retry_client, retry_model = resolve_vision_provider_client(
@@ -3697,13 +3623,7 @@ def _retry_same_provider_sync(
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
task=task,
)
# Preserve per-request attribution headers (e.g. Copilot's
# ``x-initiator: user``) across the rebuilt-client retry — dropping them
# here would let a recovery retry silently lose capability gating (#60293).
if extra_headers:
retry_kwargs["extra_headers"] = dict(extra_headers)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
@@ -3727,7 +3647,6 @@ async def _retry_same_provider_async(
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
extra_headers: Optional[Dict[str, str]] = None,
) -> Any:
if task == "vision":
_, retry_client, retry_model = resolve_vision_provider_client(
@@ -3763,12 +3682,7 @@ async def _retry_same_provider_async(
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
task=task,
)
# Preserve per-request attribution headers across the rebuilt-client
# retry — see the sync variant above (#60293).
if extra_headers:
retry_kwargs["extra_headers"] = dict(extra_headers)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
@@ -3843,24 +3757,6 @@ def _refresh_provider_credentials(provider: str) -> bool:
return False
_evict_cached_clients(normalized)
return True
if normalized == "vertex":
# Mirrors run_agent.py's _try_refresh_vertex_client_credentials
# for the main conversation loop. Without this branch, an
# auxiliary Vertex client (vision, title generation, reflection,
# context compression, ...) that 401s on its ~1h token expiry
# falls through to the final `return False` below: the stale
# client is never evicted from _client_cache (whose cache key
# ignores the rotating bearer token), so every subsequent
# auxiliary Vertex call keeps 401ing until process restart.
from agent.vertex_adapter import get_vertex_config
token, base_url = get_vertex_config()
if not isinstance(token, str) or not token.strip():
return False
if not isinstance(base_url, str) or not base_url.strip():
return False
_evict_cached_clients(normalized)
return True
except Exception as exc:
logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc)
return False
@@ -3974,7 +3870,7 @@ def _call_fallback_candidate_sync(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base, task=task)
base_url=fb_base)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
@@ -3991,7 +3887,7 @@ def _call_fallback_candidate_sync(
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task)
@@ -4040,7 +3936,7 @@ async def _call_fallback_candidate_async(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base, task=task)
base_url=fb_base)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
@@ -4058,7 +3954,7 @@ async def _call_fallback_candidate_async(
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
@@ -4145,13 +4041,6 @@ def _try_main_agent_model_fallback(
"""
main_provider = (_read_main_provider() or "").strip()
main_model = (_read_main_model() or "").strip()
if main_provider.lower() == "moa":
# MoA virtual provider: fall back to the preset's aggregator — the
# acting model — instead of the unreachable "moa"/<preset-name> pair.
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
if not _agg_provider or not _agg_model:
return None, None, ""
main_provider, main_model = _agg_provider, _agg_model
if not main_provider or not main_model or main_provider.lower() in {"auto", ""}:
return None, None, ""
@@ -4552,17 +4441,26 @@ def _resolve_auto(
# model. Resolve the MoA preset to its aggregator slot and continue Step 1
# with that real provider+model. Mirrors the MoA context-length resolution.
if main_provider == "moa":
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
if _agg_provider and _agg_model:
main_provider = _agg_provider
main_model = _agg_model
# The MoA virtual runtime carries a non-HTTP base_url
# ("moa://local") and a placeholder api_key; they belong to the
# facade, not the aggregator's real provider. Drop them so the
# aggregator resolves through its own provider credentials.
runtime_base_url = ""
runtime_api_key = ""
runtime_api_mode = ""
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
_preset = resolve_moa_preset(load_config().get("moa") or {}, main_model)
_agg = _preset.get("aggregator") or {}
_agg_provider = str(_agg.get("provider") or "").strip()
_agg_model = str(_agg.get("model") or "").strip()
if _agg_provider and _agg_model and _agg_provider.lower() != "moa":
main_provider = _agg_provider
main_model = _agg_model
# The MoA virtual runtime carries a non-HTTP base_url
# ("moa://local") and a placeholder api_key; they belong to the
# facade, not the aggregator's real provider. Drop them so the
# aggregator resolves through its own provider credentials.
runtime_base_url = ""
runtime_api_key = ""
runtime_api_mode = ""
except Exception:
logger.debug("MoA aux resolution to aggregator failed", exc_info=True)
if (main_provider and main_model
and main_provider not in {"auto", ""}):
@@ -4817,27 +4715,6 @@ def resolve_provider_client(
# Normalise aliases
provider = _normalize_aux_provider(provider)
# MoA virtual provider chokepoint: "moa" is not a real HTTP provider —
# its acting model is the preset's aggregator slot. The two resolver
# layers above (_resolve_auto, _resolve_task_provider_model) already
# unwrap their own paths, but callers that route here directly (vision
# auto-detect, _try_main_agent_model_fallback, get_available_vision_backends,
# plugin code) would otherwise dead-end in the unknown-provider branch.
# ``model`` carries the preset name for moa calls; when the preset can't
# be resolved we leave the call untouched and let the normal
# missing-provider handling produce its diagnostic.
if provider == "moa":
_agg_provider, _agg_model = _resolve_moa_aggregator(model)
if _agg_provider and _agg_model:
original_provider = _agg_provider.strip().lower()
provider = _normalize_aux_provider(_agg_provider)
model = _agg_model
# The moa:// facade endpoint and placeholder key belong to the
# virtual runtime, not the aggregator's real provider.
if explicit_base_url and str(explicit_base_url).lower().startswith("moa://"):
explicit_base_url = None
explicit_api_key = None
# Universal model-resolution fallback for concrete providers. ``auto`` is
# intentionally excluded: `_resolve_auto(main_runtime=...)` returns the
# model paired with the provider it actually selected. Pre-filling an auto
@@ -4858,10 +4735,6 @@ def resolve_provider_client(
# the load-bearing step for OAuth providers: an xai-oauth user
# with grok-4.3 configured gets grok-4.3 for title generation
# instead of silently dropping to whatever Step-2 fallback (#31845).
# When the main provider is MoA, ``_read_main_model_for_aux()``
# substitutes the preset's aggregator model — the preset NAME is
# never a valid wire model id, so unset aux models default to the
# preset's acting model instead.
#
# Each provider branch below sees a non-empty ``model`` whenever the
# user has *anything* configured — no provider-specific empty-model
@@ -4878,7 +4751,7 @@ def resolve_provider_client(
# return the actual current runtime model when the caller did not explicitly
# request one. (# compression-current-model)
if not model and provider != "auto":
model = _get_aux_model_for_provider(provider) or _read_main_model_for_aux() or model
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
"""Decide if a plain OpenAI client should be wrapped for Responses API.
@@ -5152,7 +5025,7 @@ def resolve_provider_client(
model
or custom_entry.get("model")
or (main_runtime.get("model") if main_runtime else None)
or _read_main_model_for_aux()
or _read_main_model()
or "gpt-4o-mini",
provider,
)
@@ -5387,7 +5260,7 @@ def resolve_provider_client(
final_model = _normalize_resolved_model(
model
or (main_runtime.get("model") if main_runtime else None)
or _read_main_model_for_aux(),
or _read_main_model(),
provider,
)
if provider == "copilot-acp":
@@ -5755,24 +5628,7 @@ def resolve_vision_provider_client(
# 5. Stop
main_provider = str(runtime.get("provider") or _read_main_provider())
main_model = str(runtime.get("model") or _read_main_model())
if main_provider.strip().lower() == "moa":
# MoA virtual provider: main_model is a preset NAME, and every
# capability probe below (_PROVIDERS_WITHOUT_VISION,
# _main_model_supports_vision, _resolve_provider_vision_default)
# would run against a provider/model pair that doesn't exist on
# any wire. Unwrap to the preset's aggregator slot first so the
# checks and the eventual client target the real acting model.
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
if _agg_provider and _agg_model:
main_provider, main_model = _agg_provider, _agg_model
# Drop the moa:// facade endpoint from the runtime view used
# below — it belongs to the virtual provider, not the
# aggregator's real provider.
runtime = dict(runtime)
runtime["base_url"] = ""
runtime["api_key"] = ""
runtime["api_mode"] = ""
if main_provider and main_provider not in {"auto", "", "moa"}:
if main_provider and main_provider not in {"auto", ""}:
# A provider-specific vision default wins over the user's chat model:
# static overrides (xiaomi/zai) and catalog-backed discovery (the
# DeepInfra profile hook) both yield a *known* vision-capable model,
@@ -6369,8 +6225,8 @@ def _resolve_task_provider_model(
task: str = None,
provider: str = None,
model: str = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
base_url: str = None,
api_key: str = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call.
@@ -6413,57 +6269,12 @@ def _resolve_task_provider_model(
# which downstream consumers like ContextCompressor accept as the task output.
# The provider-side 'auto' is handled in _resolve_auto() via main_runtime
# fallback, so dropping cfg_model to None here lets that path do its job.
#
# The explicit `model` kwarg needs the identical normalization: MoA slots
# (agent/moa_loop.py's _slot_runtime) forward a preset's `model:` field as
# this explicit argument rather than through auxiliary.<task> config, so a
# user-configured `model: auto` on a MoA reference/aggregator slot reaches
# this function here, not as cfg_model. Only normalizing cfg_model let that
# literal "auto" slip through via `model or cfg_model` below.
if model and model.lower() == "auto":
model = None
if cfg_model and cfg_model.lower() == "auto":
cfg_model = None
resolved_model = model or cfg_model
resolved_api_mode = cfg_api_mode
# MoA virtual provider: an *explicit* `provider: moa` override (either the
# caller-passed `provider` arg or `auxiliary.<task>.provider` in
# config.yaml) reaches this function directly — it never goes through
# _resolve_auto(), which only unwraps the *implicit* "main provider is
# moa" case (#53827). Left as-is, "moa" is returned verbatim and
# resolve_provider_client() looks it up in PROVIDER_REGISTRY (which has
# no "moa" entry — it's not a real HTTP provider), falls to the
# unknown-provider dead end, and call_llm surfaces a nonsensical
# "MOA_API_KEY environment variable" error for a provider that was never
# meant to be reached over the wire. Auxiliary tasks don't need the
# reference fan-out — resolve to the preset's aggregator slot instead,
# exactly like the implicit path does (shared helper: _resolve_moa_aggregator).
def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[str]]:
if prov.strip().lower() != "moa":
return prov, mdl
agg_provider, agg_model = _resolve_moa_aggregator(mdl)
if agg_provider and agg_model:
return agg_provider, agg_model
return prov, mdl
if provider and str(provider).strip().lower() == "moa":
provider, resolved_model = _unwrap_moa_provider(provider, resolved_model)
# The moa:// virtual endpoint (if any explicit base_url/api_key was
# passed alongside provider="moa") belongs to the facade, not the
# aggregator's real provider — drop it so the aggregator resolves
# through its own provider credentials, mirroring _resolve_auto().
if provider and provider.lower() != "moa":
base_url = None
api_key = None
elif cfg_provider and str(cfg_provider).strip().lower() == "moa":
cfg_provider, cfg_model = _unwrap_moa_provider(cfg_provider, resolved_model)
if cfg_provider and cfg_provider.lower() != "moa":
resolved_model = cfg_model
cfg_base_url = None
cfg_api_key = None
# Convenience aliases for direct API-key endpoints that aren't first-class
# providers (e.g. ``provider: openai`` → custom + api.openai.com/v1).
# Applied to both explicit args and config-derived values. When the user
@@ -6820,7 +6631,6 @@ def _build_call_kwargs(
extra_body: Optional[dict] = None,
reasoning_config: Optional[dict] = None,
base_url: Optional[str] = None,
task: Optional[str] = None,
) -> dict:
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
kwargs: Dict[str, Any] = {
@@ -6875,32 +6685,11 @@ def _build_call_kwargs(
_provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"}
or base_url_host_matches(_effective_base, "integrate.api.nvidia.com")
)
_is_moa = bool(task) and str(task) == "moa_reference"
# Gemini's native generateContent maps max_tokens → maxOutputTokens and,
# when it is omitted, applies a fixed 65,535-token ceiling rather than
# "the model's full budget" (see gemini_native_adapter.build_gemini_request).
# So an explicit cap is both safe and the ONLY way to honor it here —
# dropping max_tokens silently makes MoA's reference_max_tokens a no-op
# for gemini advisors (they run effectively uncapped).
_is_gemini_native = _provider_norm in {
"gemini", "google", "google-gemini", "google-ai-studio",
}
if not _is_gemini_native and _effective_base:
try:
from agent.gemini_native_adapter import is_native_gemini_base_url
_is_gemini_native = is_native_gemini_base_url(_effective_base)
except Exception:
pass
if (
_is_anthropic_compat_endpoint(provider, _effective_base)
or _is_nvidia_nim
or _is_moa
or _is_gemini_native
):
# Use auxiliary_max_tokens_param() so models that require
# max_completion_tokens (GPT-5 family, Copilot) get the right
# parameter name instead of a hardcoded max_tokens that 400s.
kwargs.update(auxiliary_max_tokens_param(max_tokens, model=model))
kwargs["max_tokens"] = max_tokens
if tools:
# Defensive dedup: providers like Google Vertex, Azure, and Bedrock
@@ -7132,7 +6921,6 @@ def call_llm(
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
extra_headers: Optional[Dict[str, str]] = None,
api_mode: str = None,
stream: bool = False,
stream_options: dict = None,
@@ -7158,9 +6946,6 @@ def call_llm(
extra_body: Additional request body fields.
reasoning_config: Optional Hermes reasoning config for direct model calls
such as MoA reference/aggregator slots.
extra_headers: Additional per-request HTTP headers. These override
client-level defaults for providers that gate capabilities on
request attribution (for example Copilot's ``x-initiator``).
stream: When True, return the raw SDK streaming iterator instead of a
validated complete response. The caller is responsible for consuming
chunks (and for any fallback). Used by the MoA aggregator so its
@@ -7273,9 +7058,7 @@ def call_llm(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_base_info or resolved_base_url, task=task)
if extra_headers:
kwargs["extra_headers"] = dict(extra_headers)
base_url=_base_info or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
@@ -7529,7 +7312,6 @@ def call_llm(
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
extra_headers=extra_headers,
)
# ── Same-provider credential-pool recovery ─────────────────────
@@ -7573,7 +7355,6 @@ def call_llm(
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
extra_headers=extra_headers,
)
except Exception as retry2_err:
# The rotated key also hit a quota/auth wall. Mark it
@@ -7893,7 +7674,7 @@ async def async_call_llm(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_client_base or resolved_base_url, task=task)
base_url=_client_base or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
+11 -61
View File
@@ -433,29 +433,6 @@ def _model_supports_tool_use(model_id: str) -> bool:
return not any(pattern in model_lower for pattern in _NON_TOOL_CALLING_PATTERNS)
# ---------------------------------------------------------------------------
# Prompt-cache capability detection (Converse API cachePoint)
# ---------------------------------------------------------------------------
# Claude on Bedrock already gets prompt caching through the AnthropicBedrock
# SDK path (see is_anthropic_bedrock_model / runtime_provider.py's dual-path
# routing) — it never reaches build_converse_kwargs unless bearer-token auth
# forces the Converse path (#28156). This allowlist covers the Converse API
# itself: sending an unsupported model a cachePoint block raises a
# ValidationException, so — like _model_supports_tool_use but inverted —
# unknown models default to NOT receiving cache markers until confirmed.
# Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
_CACHE_POINT_PATTERNS = [
"anthropic.claude", # bearer-token fallback path
"amazon.nova",
]
def _model_supports_prompt_cache(model_id: str) -> bool:
"""Return True if the model accepts a Converse API cachePoint block."""
model_lower = model_id.lower()
return any(pattern in model_lower for pattern in _CACHE_POINT_PATTERNS)
def is_anthropic_bedrock_model(model_id: str) -> bool:
"""Return True if the model is an Anthropic Claude model on Bedrock.
@@ -787,22 +764,14 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace:
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
)
# Build usage stats. Converse's inputTokens excludes cache read/write
# tokens (unlike OpenAI's prompt_tokens, which includes them) — restore
# the OpenAI-style "total includes cache" convention here so downstream
# normalize_usage() can subtract them back out consistently, and surface
# the Anthropic-named fields it already falls back to for cache reads.
# Build usage stats
usage_data = response.get("usage", {})
input_tokens = usage_data.get("inputTokens", 0)
cache_read_tokens = usage_data.get("cacheReadInputTokens", 0)
cache_write_tokens = usage_data.get("cacheWriteInputTokens", 0)
output_tokens = usage_data.get("outputTokens", 0)
usage = SimpleNamespace(
prompt_tokens=input_tokens + cache_read_tokens + cache_write_tokens,
completion_tokens=output_tokens,
total_tokens=input_tokens + cache_read_tokens + cache_write_tokens + output_tokens,
cache_read_input_tokens=cache_read_tokens,
cache_creation_input_tokens=cache_write_tokens,
prompt_tokens=usage_data.get("inputTokens", 0),
completion_tokens=usage_data.get("outputTokens", 0),
total_tokens=(
usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0)
),
)
finish_reason = _converse_stop_reason_to_openai(stop_reason)
@@ -967,8 +936,6 @@ def stream_converse_with_callbacks(
usage_data = {
"inputTokens": meta_usage.get("inputTokens", 0),
"outputTokens": meta_usage.get("outputTokens", 0),
"cacheReadInputTokens": meta_usage.get("cacheReadInputTokens", 0),
"cacheWriteInputTokens": meta_usage.get("cacheWriteInputTokens", 0),
}
# Flush remaining text
@@ -982,16 +949,12 @@ def stream_converse_with_callbacks(
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
)
input_tokens = usage_data.get("inputTokens", 0)
cache_read_tokens = usage_data.get("cacheReadInputTokens", 0)
cache_write_tokens = usage_data.get("cacheWriteInputTokens", 0)
output_tokens = usage_data.get("outputTokens", 0)
usage = SimpleNamespace(
prompt_tokens=input_tokens + cache_read_tokens + cache_write_tokens,
completion_tokens=output_tokens,
total_tokens=input_tokens + cache_read_tokens + cache_write_tokens + output_tokens,
cache_read_input_tokens=cache_read_tokens,
cache_creation_input_tokens=cache_write_tokens,
prompt_tokens=usage_data.get("inputTokens", 0),
completion_tokens=usage_data.get("outputTokens", 0),
total_tokens=(
usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0)
),
)
finish_reason = _converse_stop_reason_to_openai(stop_reason)
@@ -1030,7 +993,6 @@ def build_converse_kwargs(
Converts OpenAI-format inputs to Converse API parameters.
"""
system_prompt, converse_messages = convert_messages_to_converse(messages)
cache_enabled = _model_supports_prompt_cache(model)
kwargs: Dict[str, Any] = {
"modelId": model,
@@ -1041,8 +1003,6 @@ def build_converse_kwargs(
}
if system_prompt:
if cache_enabled:
system_prompt = system_prompt + [{"cachePoint": {"type": "default"}}]
kwargs["system"] = system_prompt
from agent.anthropic_adapter import _forbids_sampling_params
@@ -1066,8 +1026,6 @@ def build_converse_kwargs(
# Strip tools for known non-tool-calling models and warn the user.
# Ref: PR #7920 feedback from @ptlally, pattern from PR #4346.
if _model_supports_tool_use(model):
if cache_enabled:
converse_tools = converse_tools + [{"cachePoint": {"type": "default"}}]
kwargs["toolConfig"] = {"tools": converse_tools}
else:
logger.warning(
@@ -1075,14 +1033,6 @@ def build_converse_kwargs(
"The agent will operate in text-only mode.", model
)
if cache_enabled and len(converse_messages) >= 2:
# Checkpoint everything up to (not including) the newest turn, so the
# marker survives unchanged across requests as only the tail grows —
# mirroring the Anthropic system_and_3 strategy in prompt_caching.py.
content = converse_messages[-2].get("content")
if isinstance(content, list) and content:
content.append({"cachePoint": {"type": "default"}})
if guardrail_config:
kwargs["guardrailConfig"] = guardrail_config
-124
View File
@@ -1,124 +0,0 @@
"""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 "")
+29 -139
View File
@@ -1076,7 +1076,6 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
tools=tools_for_api,
reasoning_config=agent.reasoning_config,
session_id=getattr(agent, "session_id", None),
base_url=agent.base_url,
max_tokens=agent.max_tokens,
timeout=agent._resolved_api_call_timeout(),
request_overrides=agent.request_overrides,
@@ -1514,45 +1513,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]:
)
def _fallback_entry_is_same_backend_by_base_url(
*,
current_provider: str,
fb_provider: str,
current_base_url: str,
fb_base_url: str,
current_model: str,
fb_model: str,
) -> bool:
"""True when base_url+model identity means the fallback is the same backend.
Issue #22548: two ``custom_providers`` aliases that point at the same shim
URL with the same model must be skipped, or failover loops on the dead
backend. First-class providers that share a host while using different
auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are
distinct credential surfaces skipping them strands configured failover
when primary and fallback reuse the same model slug on that host.
"""
if not (
fb_base_url
and current_base_url
and fb_base_url == current_base_url
and fb_model == current_model
):
return False
if fb_provider == current_provider:
return True
try:
from hermes_cli.auth import PROVIDER_REGISTRY
# Both sides are registered first-class providers → different auth
# identities even when the inference host matches. Allow failover.
if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY:
return False
except Exception:
pass
return True
def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]:
"""Return a skip reason for fallback entries known to be unusable locally."""
fb_provider = (fb.get("provider") or "").strip().lower()
@@ -1641,9 +1601,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# Skip entries that resolve to the current (provider, model) — falling
# back to the same backend that just failed loops the failure. Compare
# base_url too so two distinct custom_providers entries pointing at the
# same shim/proxy URL also dedup. See issue #22548. Do NOT treat
# first-class providers that share a host (xai-oauth vs xai) as the same
# backend — they use different credentials.
# same shim/proxy URL also dedup. See issue #22548.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
current_model = (getattr(agent, "model", "") or "").strip()
current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower()
@@ -1654,13 +1612,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model,
)
return agent._try_activate_fallback(reason)
if _fallback_entry_is_same_backend_by_base_url(
current_provider=current_provider,
fb_provider=fb_provider,
current_base_url=current_base_url,
fb_base_url=fb_base_url_for_dedup,
current_model=current_model,
fb_model=fb_model,
if (
fb_base_url_for_dedup
and current_base_url
and fb_base_url_for_dedup == current_base_url
and fb_model == current_model
):
logger.warning(
"Fallback skip: chain entry base_url %s matches current backend",
@@ -1756,7 +1712,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent._config_context_length = None
agent.model = fb_model
agent.provider = fb_provider
agent.requested_provider = fb_provider
agent.base_url = fb_base_url
agent.api_mode = fb_api_mode
if hasattr(agent, "_transport_cache"):
@@ -1783,7 +1738,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model, _pool_provider,
)
agent._credential_pool = None
agent._credential_pool_entry_id = None
if getattr(agent, "_credential_pool", None) is None:
try:
from agent.credential_pool import load_pool
@@ -1846,9 +1800,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# not only after a later credential-rotation rebuild.
agent._replace_primary_openai_client(reason="fallback_timeout_apply")
from agent.agent_runtime_helpers import sync_credential_pool_entry_id
sync_credential_pool_entry_id(agent)
# Re-evaluate prompt caching for the new provider/model
agent._use_prompt_caching, agent._use_native_cache_layout = (
agent._anthropic_prompt_cache_policy(
@@ -1994,17 +1945,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
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:
# In MoA mode, agent.model is the virtual preset name,
# not the actual aggregator model. Resolve the real
# aggregator model so Gemini preserves thought_signature.
_sanitize_model = agent.model
if agent.provider == "moa":
_moa_client = getattr(agent, "client", None)
if _moa_client is not None:
_agg_slot = getattr(_moa_client, "last_aggregator_slot", None)
if _agg_slot and _agg_slot.get("model"):
_sanitize_model = _agg_slot["model"]
agent._sanitize_tool_calls_for_strict_api(api_msg, model=_sanitize_model)
agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model)
api_messages.append(api_msg)
effective_system = agent._cached_system_prompt or ""
@@ -2521,11 +2462,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
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). ``kind="stream"`` registers a per-request
# *stream handle* instead of a client — used under the MoA facade, whose
# singleton client has no per-request sockets to abort
# (_abort_request_openai_client is a no-op on it), so interrupts must
# close the stream object itself (#57354).
# close helpers (#67142).
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
@@ -2545,44 +2482,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
request_client_holder["owner_tid"] = threading.get_ident()
return client
def _stream_close_callable(stream):
close = getattr(stream, "close", None)
if callable(close):
return close
response = getattr(stream, "response", None)
close = getattr(response, "close", None)
if callable(close):
return close
return None
def _set_request_stream_handle(stream):
# Register the per-request *stream* under kind="stream" so an
# interrupt closes the stream handle itself. Under the MoA facade the
# registered "client" is the shared facade singleton whose
# per-request abort helpers are no-ops, leaving the underlying HTTP
# stream open until the provider drained it (#57354).
if _stream_close_callable(stream) is None:
return stream
with request_client_lock:
request_client_holder["client"] = stream
request_client_kind["value"] = "stream"
request_client_holder["owner_tid"] = threading.get_ident()
return stream
def _close_request_stream_handle(stream, reason: str) -> None:
close = _stream_close_callable(stream)
if close is None:
return
try:
close()
logger.info("Streaming response handle closed (%s)", reason)
except Exception as exc:
logger.debug(
"Streaming response handle close failed (%s): %s",
reason,
exc,
)
def _close_request_client_once(reason: str) -> None:
# See #29507 explanation in the non-streaming variant above. A
# stranger thread (the interrupt-check / stale-stream detector loop)
@@ -2590,15 +2489,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# so the worker thread retains ownership of the FD release.
with request_client_lock:
request_client = request_client_holder.get("client")
request_kind = request_client_kind.get("value", "openai")
owner_tid = request_client_holder.get("owner_tid")
# A registered stream handle (kind="stream", MoA facade path) is
# safe to close from any thread — closing IS the abort — so the
# stranger-thread ownership carve-out only applies to real
# per-request clients (#57354).
stranger_thread = (
request_kind != "stream"
and request_client is not None
request_client is not None
and owner_tid is not None
and owner_tid != threading.get_ident()
)
@@ -2607,9 +2500,8 @@ 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 request_kind == "stream":
_close_request_stream_handle(request_client, reason)
elif request_kind == "anthropic_messages":
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
@@ -2788,11 +2680,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
stream = request_client.chat.completions.create(**stream_kwargs)
if agent.provider == "moa":
# The MoA facade is a shared singleton — abort/close of the
# registered client is a no-op, so register the stream handle
# itself for interrupt teardown (#57354).
stream = _set_request_stream_handle(stream)
# Claim the delta sink for THIS attempt (#65991). If a prior attempt's
# stream is somehow still alive (a stale-stream reconnect whose socket
# abort raced), this claim supersedes it so its late chunks are fenced
@@ -3492,9 +3379,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# 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.
# #70773: same FD-recycle corruption vector for OpenAI.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next attempt.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_mid_tool_retry_pool_cleanup"
)
except Exception:
pass
continue
# SSE error events from proxies (e.g. OpenRouter sends
@@ -3553,9 +3444,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# above; next attempt builds fresh), so the shared
# _anthropic_client is never closed from inside a
# request — only the OpenAI-wire primary is refreshed.
# #70773: same FD-recycle corruption vector for OpenAI.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next attempt.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_retry_pool_cleanup"
)
except Exception:
pass
continue
# Retries exhausted. Log the final failure with
# full diagnostic detail (chain, headers,
@@ -3798,15 +3693,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# FD-recycle corruption vector. Nothing further is needed.
pass
else:
# #70773: same FD-recycle corruption vector as #67142.
# The shared OpenAI client's connection pool must NOT be
# closed from this watchdog/poll thread — worker threads
# from previous stale-killed attempts may still be
# unwinding their SSL BIOs. The request-local client is
# already closed above via _close_request_client_once.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next request.
pass
try:
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
except Exception:
pass
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
+3 -9
View File
@@ -912,8 +912,7 @@ def _preflight_codex_api_kwargs(
allowed_keys = {
"model", "instructions", "input", "tools", "store",
"reasoning", "include", "max_output_tokens", "temperature",
"tool_choice", "parallel_tool_calls", "prompt_cache_key",
"prompt_cache_retention", "service_tier",
"tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier",
"extra_headers", "extra_body", "timeout",
}
normalized: Dict[str, Any] = {
@@ -951,13 +950,8 @@ def _preflight_codex_api_kwargs(
if isinstance(temperature, (int, float)):
normalized["temperature"] = float(temperature)
# Pass through cache routing/retention and tool-dispatch hints.
for passthrough_key in (
"tool_choice",
"parallel_tool_calls",
"prompt_cache_key",
"prompt_cache_retention",
):
# Pass through tool_choice, parallel_tool_calls, prompt_cache_key
for passthrough_key in ("tool_choice", "parallel_tool_calls", "prompt_cache_key"):
val = api_kwargs.get(passthrough_key)
if val is not None:
normalized[passthrough_key] = val
-34
View File
@@ -702,16 +702,6 @@ 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}. "
@@ -721,27 +711,9 @@ 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
@@ -847,12 +819,6 @@ 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
+8 -37
View File
@@ -520,46 +520,30 @@ class RuntimeMode:
return None
return [self.profile.toolset, *_enabled_mcp_servers(config)]
def system_prompt_parts(self) -> tuple[list[str], list[str], list[str]]:
"""Return prefix, workspace, and trailing posture blocks separately.
def system_blocks(self) -> list[str]:
"""Stable system-prompt blocks for this posture (brief + workspace).
The operating brief carries a model-family edit-format nudge appended
to it (one cached string, not a separate block) so the model is steered
toward the `patch` mode it handles best see ``_edit_format_line``.
The three lists preserve the historical flat prompt order: the brief,
the live workspace snapshot, then configured operator instructions.
Prompt assembly can therefore put a cache boundary before the snapshot
without changing the persisted system-prompt bytes.
"""
if not self.is_coding:
return [], [], []
prefix: list[str] = []
workspace_parts: list[str] = []
trailing: list[str] = []
return []
blocks: list[str] = []
if self.profile.guidance:
brief = self.profile.guidance
edit_line = _edit_format_line(self.model)
if edit_line:
brief = f"{brief}\n{edit_line}"
prefix.append(brief)
blocks.append(brief)
workspace = build_coding_workspace_block(self.cwd)
if workspace:
workspace_parts.append(workspace)
blocks.append(workspace)
# Operator instructions ride their own block so the brief (block 0) stays
# byte-stable and cache-keyed independently of user config.
if self.instructions:
trailing.append(f"Operator instructions (from config):\n{self.instructions}")
return prefix, workspace_parts, trailing
def system_blocks(self) -> list[str]:
"""Return posture blocks in their historical display order.
``system_prompt_parts`` is the cache-aware API. This compatibility
helper retains the public flat list for callers outside prompt assembly.
"""
prefix, workspace, trailing = self.system_prompt_parts()
return [*prefix, *workspace, *trailing]
blocks.append(f"Operator instructions (from config):\n{self.instructions}")
return blocks
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the prompt's skill index.
@@ -660,19 +644,6 @@ def coding_system_blocks(
).system_blocks()
def coding_system_prompt_parts(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> tuple[list[str], list[str], list[str]]:
"""Return coding prefix, workspace snapshot, and trailing guidance."""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config, model=model
).system_prompt_parts()
def coding_compact_skill_categories(
*,
platform: Optional[str] = None,
+160 -1243
View File
File diff suppressed because it is too large Load Diff
-211
View File
@@ -53,39 +53,6 @@ def sanitize_memory_context(memory_context: str) -> str:
)
def automatic_compaction_status_message(
engine: Any,
*,
phase: str,
default_message: str,
**context: Any,
) -> str | None:
"""Resolve host-visible status for an automatic compaction event.
Engines can suppress routine automatic status with
``emit_automatic_compaction_status = False`` or customize it by defining
``get_automatic_compaction_status_message(...)``. Empty strings and
``None`` mean "do not emit a lifecycle status".
"""
if not getattr(engine, "emit_automatic_compaction_status", True):
return None
formatter = getattr(engine, "get_automatic_compaction_status_message", None)
if callable(formatter):
message = formatter(
phase=phase,
default_message=default_message,
**context,
)
else:
message = default_message
if message is None:
return None
message = str(message).strip()
return message or None
class ContextEngine(ABC):
"""Base class all context engines must implement."""
@@ -122,12 +89,6 @@ class ContextEngine(ABC):
protect_first_n: int = 3
protect_last_n: int = 6
# User-visible lifecycle status for automatic host-triggered compaction.
# Alternative engines that treat compaction as routine background
# maintenance can set this false to keep successful automatic passes silent;
# warnings, errors, and explicit manual commands should still surface.
emit_automatic_compaction_status: bool = True
# -- Core interface ----------------------------------------------------
@abstractmethod
@@ -146,19 +107,6 @@ class ContextEngine(ABC):
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Return True if compaction should fire this turn."""
def should_compress_info(self, prompt_tokens: int = None) -> "tuple[bool, str | None]":
"""Return ``(should_compress, reason)``.
The base implementation is backward-compatible: engines that only
implement ``should_compress`` get ``(should_compress(prompt_tokens),
None)``. Concrete engines with richer block reasons (e.g. a
summary-LLM cooldown or an anti-thrashing guard) override this to
surface a human-readable reason so callers can warn the user instead
of silently skipping compression. Added for the silent-overflow
warning fix (#62625) so plugin engines don't raise AttributeError.
"""
return self.should_compress(prompt_tokens), None
@abstractmethod
def compress(
self,
@@ -189,144 +137,6 @@ class ContextEngine(ABC):
host filters unsupported optional arguments by signature.
"""
# -- Optional: proactive tool-result prune -----------------------------
def prune_tool_results_only(
self,
messages: List[Dict[str, Any]],
current_tokens: int | None = None,
) -> tuple[List[Dict[str, Any]], int]:
"""Deterministically trim old tool-result payloads without an LLM call.
Runs on a low, cost-oriented trigger independent of ``should_compress``
so large-window engines can reclaim re-sent tool output long before full
compaction would fire. Returns ``(messages, n_pruned)``.
Default is a safe no-op: the list is returned unchanged with ``0``
pruned. Engines that don't implement a cheap prune — and any engine that
predates this hook inherit this default, so the agent loop's
post-tool-call prune path never raises ``AttributeError`` on them. The
built-in ContextCompressor overrides this with the real implementation.
"""
return messages, 0
# -- Optional: per-turn context selection (distinct from compression) --
def select_context(
self,
request_messages: List[Dict[str, Any]],
*,
conversation_messages: List[Dict[str, Any]] = None,
incoming_message: Dict[str, Any] = None,
budget_tokens: int = 0,
) -> List[Dict[str, Any]]:
"""Optionally choose/replace the context for THIS request, pre-generation.
Called every turn after the request message list is assembled and
before it is dispatched to the provider independent of
``should_compress()``. This lets an engine *select* which context
enters the prompt (retrieval, topic routing, role/branch switching)
rather than *shrink* context that is already there. The two verbs are
orthogonal:
- ``compress()`` : context is too long -> make it shorter.
- ``select_context()``: this turn belongs to a different context
-> use that one instead.
Without this hook, engines that need per-turn access to the message
list have to force ``should_compress()`` to return ``True`` so that
``compress()`` is invoked every turn purely as a callback which
conflates selection with compression and degrades behaviour when the
engine's backend is unavailable. ``select_context()`` removes the need
for that workaround.
The returned list is request-only: it replaces the messages sent to
the provider for this single call and MUST NOT be treated as persisted
transcript state. The conversation history in the session DB is left
untouched, so nothing leaks across turns. Return ``None`` to leave the
request unchanged.
Unlike the ``pre_llm_call`` plugin hook (which appends to the user
message and intentionally never rewrites the list, to preserve the
cache prefix), ``select_context()`` may *replace* the message list.
Ordering / cache contract: the host runs this hook **before** prompt
cache-control and **before** every request sanitizer (orphaned-tool
cleanup, thinking-only/role normalization, whitespace/JSON
normalization). So (a) whatever the hook returns still passes through
the same validation as any request a malformed replacement cannot
reach the provider and (b) prompt-cache stability (an AGENTS.md
invariant) is preserved: the default no-op leaves the request
byte-identical, so cache behaviour is unchanged for the built-in
compressor and any non-implementing engine. An engine that *does*
replace the list changes its own cache prefix by definition; that is
the engine's concern, and cache-control breakpoints are re-derived on
the selected list. The hook is evaluated per provider request (so it
re-runs on retries within a turn), consistent with "select the context
for THIS request".
Args:
request_messages: The assembled request message list (system
prompt + history + any ephemeral prefill), in OpenAI format.
conversation_messages: The unmodified persisted conversation
history, for reference only (do not mutate).
incoming_message: The current turn's user message, if available.
budget_tokens: The active model's context length, or 0 if unknown.
Default returns ``None`` (no-op) zero impact on the built-in
compressor or any existing engine.
"""
return None
def on_turn_complete(
self,
messages: List[Dict[str, Any]],
usage: Dict[str, Any] = None,
**kwargs: Any,
) -> None:
"""Observe a finished user turn (post-turn ingestion / observation).
Called from the standard turn-finalization path once the assistant/tool
loop completes, with the finalized in-memory transcript snapshot. This
is the complement to ``select_context()``: selection happens *before*
the request, while observation happens *after* the turn. It lets an
engine ingest, index, summarize, or update routing / topic / session
state from what actually happened so the next ``select_context()``
can act on it.
Coverage: this fires from the normal finalization seam. Some abnormal
early-return paths in the loop (e.g. a content-policy block or a
provider terminal failure) persist and return without routing through
finalization, and therefore do not currently emit this hook. Treat it
as a best-effort post-turn observation for completed turns, not a
guaranteed callback for every possible early exit; unifying all
terminal paths behind one finalization seam is a separate follow-up.
Together the two hooks remove the need to abuse ``should_compress()`` /
``compress()`` as a generic per-turn callback just to observe history,
and they cover the case where a turn finishes and there may be no next
request from which to infer the previous turn.
``messages`` is a shallow copy and should be treated as read-only:
return values are ignored and this hook must not rely on transcript
mutation for persistence. ``kwargs`` may include ``turn_id``,
``task_id``, ``api_call_count``, ``interrupted``, ``failed``, and
``turn_exit_reason``.
``usage`` carries the completed turn's canonical token usage (the same
dict shape passed to ``update_from_response`` ``prompt_tokens`` /
``completion_tokens`` / ``total_tokens`` plus the canonical
``input_tokens`` / ``output_tokens`` / ``cache_read_tokens`` /
``cache_write_tokens`` / ``reasoning_tokens`` buckets) so an engine can
weigh how large/expensive the selected context actually was when
deciding the next ``select_context()``. It is ``None`` on finalized
turns that never reached a provider response (e.g. interrupt); engines
must treat it as optional.
Default is a no-op.
"""
return None
# -- Optional: pre-flight check ----------------------------------------
def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
@@ -346,27 +156,6 @@ class ContextEngine(ABC):
"""
return False
def get_automatic_compaction_status_message(
self,
*,
phase: str,
default_message: str,
**context: Any,
) -> str | None:
"""Return user-visible status for automatic host-triggered compaction.
Return ``None`` to suppress successful automatic lifecycle status for
this compaction event. ``phase`` identifies the host call site (for
example ``"preflight"`` or ``"compress"``). ``context`` contains
best-effort fields such as ``approx_tokens`` and ``threshold_tokens``.
This hook does not control warning/error messages or explicit manual
commands such as ``/compress``.
"""
if not self.emit_automatic_compaction_status:
return None
return default_message
# -- Optional: manual /compress preflight ------------------------------
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
+2 -2
View File
@@ -308,7 +308,7 @@ def _expand_git_reference(
["git", *args],
cwd=cwd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
@@ -534,7 +534,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
["rg", "--files", str(path.relative_to(cwd))],
cwd=cwd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
+93 -619
View File
@@ -40,12 +40,9 @@ import uuid
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Optional, Tuple
from agent.context_engine import (
automatic_compaction_status_message,
sanitize_memory_context,
)
from agent.context_engine import sanitize_memory_context
from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
@@ -60,87 +57,6 @@ COMPACTION_STATUS = (
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
)
COMPACTION_DONE_STATUS = "✓ Context compaction complete — continuing turn..."
def _emit_compaction_done(agent: Any) -> None:
"""Emit the structured terminal edge for a started compaction."""
status_callback = getattr(agent, "status_callback", None)
if not status_callback:
return
try:
status_callback("compacted", COMPACTION_DONE_STATUS)
except Exception:
logger.debug("status_callback error in compaction completion", exc_info=True)
# ── Routine compression status templates ────────────────────────────────────
# Every ROUTINE (non-failure, non-manual-/compress) compression status line the
# agent emits lives here so the gateway noise filter and its tests can couple
# to the real emitted wording instead of hand-copied literals. These are
# suppressed on human-facing chat platforms by _TELEGRAM_NOISY_STATUS_RE
# (gateway/run.py) — when rewording ANY of them, update that regex and the
# pinned data in tests/gateway/test_telegram_noise_filter.py in the same PR.
# Failure notices (⚠ Compression aborted / empty transcript / codex compaction
# failed) and manual /compress feedback (manual_compression_feedback.py) are
# deliberate carve-outs from silence and must NOT be added here.
PRE_API_COMPRESSION_STATUS_TEMPLATE = (
"📦 Pre-API compression: ~{tokens:,} tokens "
"near the context/output limit. Compacting before the next model call."
)
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE = (
"📦 Preflight compression: ~{tokens:,} tokens "
">= {threshold:,} threshold. This may take a moment."
)
IDLE_COMPACTION_STATUS_TEMPLATE = (
"💤 Resumed after {idle_seconds}s idle — compacting "
"~{tokens:,} tokens before continuing."
)
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE = (
"🗜️ Context too large (~{tokens:,} tokens) — compressing ({attempt}/{cap})..."
)
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE = (
"🗜️ Compressed {before}{after} messages, retrying..."
)
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE = (
"🗜️ Compressed ~{before:,} → ~{after:,} tokens, retrying..."
)
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE = (
"🗜️ Context reduced to {new_ctx:,} tokens (was {old_ctx:,}), retrying..."
)
# FAILURE-CLASS notice — a deliberate carve-out from routine-compression
# silence (#16775 class): the context is over the compression threshold but
# compression is blocked (summary-LLM cooldown / anti-thrash breaker), so the
# session will keep growing until the hard provider token limit kills it.
# This MUST stay visible on chat gateways. Do NOT add it to
# ROUTINE_COMPRESSION_STATUS_SAMPLES or the gateway noise regex
# (_TELEGRAM_NOISY_STATUS_RE); it is pinned un-swallowed in
# tests/gateway/test_telegram_noise_filter.py::VISIBLE_COMPRESSION_MESSAGES.
CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE = (
"⚠ Context is over the compression threshold "
"(~{tokens:,} tokens >= {threshold:,}) "
"but compression is currently blocked ({reason}). "
"The model may stop responding. Run /new to start a fresh "
"session or /compress to retry immediately."
)
# Sample-formatted instances of every routine compression status line, for
# behavioral tests that iterate the ACTUAL emitted wording (formatted from the
# same constants the emission sites use) through the gateway noise filter.
ROUTINE_COMPRESSION_STATUS_SAMPLES = (
COMPACTION_STATUS,
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(tokens=123456),
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(tokens=120000, threshold=100000),
IDLE_COMPACTION_STATUS_TEMPLATE.format(idle_seconds=3600, tokens=120000),
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=250000, attempt=1, cap=3),
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=30, after=12),
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=250000, after=120000),
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
new_ctx=120000, old_ctx=250000
),
)
def _builtin_memory_prompt_snapshot(agent: Any) -> Optional[Tuple[str, str]]:
"""Return the built-in memory text that can affect a system prompt.
@@ -207,64 +123,6 @@ def _cached_prompt_reflects_builtin_memory(agent: Any, cached_prompt: str) -> bo
return True
class CompressionCommitFence:
"""Fence timeout cancellation against post-summary session mutation.
Compression itself is synchronous and may be running in an executor thread.
A caller can stop waiting for the summary, but it cannot kill that thread.
This fence makes the commit boundary deterministic: cancellation either wins
before session mutation starts, or waits until an already-started commit is
fully complete before the caller proceeds.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._cancelled = False
self._commit_started = False
def cancel_before_commit(self) -> bool:
"""Cancel a pending commit, or wait for an active commit to finish.
Returns ``True`` when cancellation won before the commit boundary.
Returns ``False`` when the worker had already entered the boundary; in
that case acquiring this lock waits until all session mutation finishes.
"""
with self._lock:
if self._commit_started:
return False
self._cancelled = True
return True
def try_cancel_before_commit(self) -> Optional[bool]:
"""Non-blocking form of :meth:`cancel_before_commit`.
Returns ``None`` while an active commit owns the fence, allowing an
async caller to yield instead of blocking its event loop.
"""
if not self._lock.acquire(blocking=False):
return None
try:
if self._commit_started:
return False
self._cancelled = True
return True
finally:
self._lock.release()
def begin_commit(self) -> bool:
"""Enter the commit boundary unless cancellation already won."""
self._lock.acquire()
if self._cancelled:
self._lock.release()
return False
self._commit_started = True
return True
def finish_commit(self) -> None:
"""Leave a commit boundary entered by :meth:`begin_commit`."""
self._lock.release()
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
"""Whether the live in-memory SessionDB class structurally predates locks.
@@ -293,7 +151,6 @@ def _refresh_persisted_compression_guards(compressor: Any) -> None:
method_calls = (
("get_active_compression_failure_cooldown", {"refresh": True}),
("_load_fallback_compression_streak", {}),
("_load_ineffective_compression_count", {}),
)
for method_name, kwargs in method_calls:
method = getattr(type(compressor), method_name, None)
@@ -355,143 +212,6 @@ def _emit_compression_attempt_telemetry(
logger.debug("failed to emit compression attempt telemetry: %s", exc)
def compression_skipped_due_to_lock(agent: Any) -> bool:
"""Type-pinned read of the #69870 lock-skip signal.
``agent._compression_skipped_due_to_lock`` is set by ``compress_context``
when a compression pass no-ops because another path holds the per-session
compression lock (holder string when the holder was confirmed, ``True``
otherwise) and cleared to ``None`` at the entry of every call.
The read MUST be type-pinned (``is True or isinstance(x, str)``), never
bare truthiness: MagicMock test-double agents auto-create truthy
attributes, and a bare ``if getattr(agent, ...)`` would hijack every
mocked agent in sibling suites into the lock-skip branch (the
#69870 × #69840 type-ahead incident).
"""
_sig = getattr(agent, "_compression_skipped_due_to_lock", None)
return _sig is True or isinstance(_sig, str)
def _adopt_live_compression_child(
agent: Any,
session_db: Any,
parent_session_id: str,
) -> Optional[List[Dict[str, Any]]]:
"""Move a stale compression contender onto the unique durable child.
Resolve and load first, then mutate the live agent. This ordering keeps the
stale contender fail-closed when lineage is ambiguous or the compacted
handoff cannot be read.
"""
finder = getattr(type(session_db), "find_live_compression_child", None)
loader = getattr(type(session_db), "get_messages_as_conversation", None)
if not callable(finder) or not callable(loader):
return None
child = finder(session_db, parent_session_id)
if not child or not child.get("id"):
return None
child_session_id = str(child["id"])
recovered = loader(session_db, child_session_id)
if not isinstance(recovered, list) or not recovered:
return None
# Revalidate after loading: the child may have rotated or a competing
# continuation may have appeared between the two DB reads.
confirmed = finder(session_db, parent_session_id)
if not confirmed or str(confirmed.get("id") or "") != child_session_id:
return None
agent.session_id = child_session_id
try:
from gateway.session_context import set_current_session_id
set_current_session_id(child_session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = child_session_id
try:
from hermes_logging import set_session_context
set_session_context(child_session_id)
except Exception:
pass
agent._session_db_created = True
if child.get("system_prompt"):
agent._cached_system_prompt = child["system_prompt"]
agent._last_flushed_db_idx = len(recovered)
agent._flushed_db_message_session_id = child_session_id
agent._flushed_db_message_ids = {
id(message) for message in recovered if isinstance(message, dict)
}
on_session_start = getattr(agent.context_compressor, "on_session_start", None)
if callable(on_session_start):
try:
on_session_start(
child_session_id,
boundary_reason="compression",
old_session_id=parent_session_id,
session_db=session_db,
platform=getattr(agent, "platform", None) or "cli",
conversation_id=getattr(agent, "_gateway_session_key", None),
)
except Exception as exc:
logger.debug("context engine compression-child adoption failed: %s", exc)
else:
bind_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(bind_state):
try:
bind_state(session_db=session_db, session_id=child_session_id)
except Exception:
pass
try:
if agent._memory_manager:
agent._memory_manager.on_session_switch(
child_session_id,
parent_session_id=parent_session_id,
reset=False,
reason="compression",
)
except Exception as exc:
logger.debug("memory manager compression-child adoption failed: %s", exc)
return recovered
def recover_rotated_compression_session(
agent: Any,
) -> Optional[List[Dict[str, Any]]]:
"""Recover a stale live agent before a new turn writes to its old parent."""
session_db = getattr(agent, "_session_db", None)
session_id = getattr(agent, "session_id", None) or ""
if session_db is None or not session_id:
return None
try:
if not _session_was_rotated_by_compression(session_db, session_id):
return None
# Rotation publication holds the parent compression lease until the
# child handoff is durable. A concurrent turn waits briefly rather than
# observing the intentional parent-ended/child-empty intermediate state.
holder_getter = getattr(session_db, "get_compression_lock_holder", None)
for attempt in range(21):
recovered = _adopt_live_compression_child(agent, session_db, session_id)
if recovered is not None:
return recovered
holder = holder_getter(session_id) if callable(holder_getter) else None
if not holder or attempt == 20:
return None
time.sleep(0.05)
return None
except Exception as exc:
logger.warning(
"compression session recovery failed for session=%s (%s: %s)",
session_id,
type(exc).__name__,
exc,
)
return None
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -945,11 +665,7 @@ def replay_compression_warning(agent: Any) -> None:
pass
def conversation_history_after_compression(
agent: Any,
messages: list,
previous_history: Optional[list] = None,
) -> Optional[list]:
def conversation_history_after_compression(agent: Any, messages: list) -> Optional[list]:
"""Return the correct flush baseline after a compression boundary.
Legacy compression rotates to a fresh child session. That child has not
@@ -966,19 +682,7 @@ def conversation_history_after_compression(
A shallow copy is intentional: it captures the current compacted dict
identities as history while allowing later same-turn appends to remain new.
An aborted or no-op attempt after an earlier in-place compaction must retain
the pre-attempt baseline. Treating all current messages as persisted would
drop any later, unflushed turns on restart; clearing the baseline would
append the already-persisted compacted rows a second time.
"""
if bool(getattr(agent, "_last_compression_attempt_recorded", False)):
attempt_in_place = getattr(agent, "_last_compression_attempt_in_place", None)
if attempt_in_place is True:
return list(messages)
if attempt_in_place is False:
return None
return previous_history
if bool(getattr(agent, "_last_compaction_in_place", False)):
return list(messages)
return None
@@ -1011,7 +715,6 @@ _SYNTHETIC_USER_FLAGS = (
"_empty_recovery_synthetic",
"_verification_stop_synthetic",
"_pre_verify_synthetic",
"_dropped_toolcall_nudge",
)
@@ -1038,37 +741,6 @@ def _is_real_user_message(message: Any) -> bool:
return not ContextCompressor._is_synthetic_compression_user_turn(message)
def _strip_stale_todo_snapshot(content: Any) -> Any:
"""Remove a previously merged todo-snapshot block from message content.
Snapshot merges (see the injection site in ``compress_context``) always
append the block at the end of the trailing user turn, so a surviving
header marks stale todo state from an earlier compaction boundary.
Stripping before re-injection keeps repeated boundaries from
accumulating outdated snapshots (#26981).
"""
from tools.todo_tool import TODO_INJECTION_HEADER
if isinstance(content, str):
idx = content.find(TODO_INJECTION_HEADER)
if idx == -1:
return content
return content[:idx].rstrip()
if isinstance(content, list):
return [
part
for part in content
if not (
isinstance(part, dict)
and part.get("type") == "text"
and str(part.get("text") or "")
.lstrip()
.startswith(TODO_INJECTION_HEADER)
)
]
return content
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
"""Fold the human anchor into an existing user-role scaffolding turn.
@@ -1238,7 +910,6 @@ def compress_context(
focus_topic: Optional[str] = None,
force: bool = False,
defer_context_engine_notification: bool = False,
commit_fence: Optional[CompressionCommitFence] = None,
) -> Tuple[list, str]:
"""Compress conversation context and split the session in SQLite.
@@ -1258,9 +929,6 @@ def compress_context(
callers use the default ``False``.
defer_context_engine_notification: Delay the existing context-engine
hook until a manual host commits its outer history transaction.
commit_fence: Optional cooperative fence for executor callers that
may time out. It prevents a late worker from mutating session state
after its caller has moved on.
Returns:
``(compressed_messages, new_system_prompt)`` tuple. When
@@ -1275,21 +943,6 @@ def compress_context(
):
raise RuntimeError("a compression notification is already pending")
# ``conversation_history_after_compression()`` needs the latest attempt's
# outcome, while ``_last_compaction_in_place`` remains the run-level signal
# read by gateway callers. ``None`` means this attempt aborted or made no
# boundary, so the previous flush baseline remains authoritative.
agent._last_compression_attempt_recorded = True
agent._last_compression_attempt_in_place = None
# Clear the lock-skip signal at the VERY TOP, before the codex route and
# the breaker gates below can early-return (per-attempt state rule,
# #58630/#69853). A stale ``True``/holder value from a prior lock-skip
# must never make a later breaker/codex no-op look like lock contention
# to the automatic-path consumers (compression_deferred, #49874) — the
# second clear before lock acquisition below stays for the same reason
# it was added in #69870 and is simply idempotent now.
agent._compression_skipped_due_to_lock = None
_attempt_started_at = time.monotonic()
_attempt_id = uuid.uuid4().hex
_trigger_source = "manual" if force else "auto"
@@ -1312,26 +965,14 @@ def compress_context(
# the app server does not expose its native summary prompt, so there is no
# truthful injection point for ``on_pre_compress()`` return text here.
if getattr(agent, "api_mode", None) == "codex_app_server":
_codex_fence_entered = False
if commit_fence is not None:
_codex_fence_entered = commit_fence.begin_commit()
if not _codex_fence_entered:
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
try:
return _compress_context_via_codex_app_server(
agent,
messages,
system_message,
approx_tokens=approx_tokens,
task_id=task_id,
force=force,
)
finally:
if _codex_fence_entered:
commit_fence.finish_commit()
return _compress_context_via_codex_app_server(
agent,
messages,
system_message,
approx_tokens=approx_tokens,
task_id=task_id,
force=force,
)
# Every automatic entrypoint must honor compressor-owned cooldown and
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
@@ -1383,32 +1024,7 @@ def compress_context(
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
focus_topic,
)
_compaction_status = COMPACTION_STATUS
if not force:
_compaction_status = automatic_compaction_status_message(
agent.context_compressor,
phase="compress",
default_message=_compaction_status,
approx_tokens=approx_tokens,
message_count=_pre_msg_count,
model=agent.model,
focus_topic=focus_topic,
)
_compaction_status_emitted = bool(_compaction_status)
if _compaction_status:
agent._emit_status(_compaction_status)
_compaction_done_emitted = False
def _complete_compaction_lifecycle() -> None:
nonlocal _compaction_done_emitted
if _compaction_done_emitted:
return
_compaction_done_emitted = True
# A suppressed start (quiet context engine) opened no visible
# compaction phase — emit no terminal edge either. Failure warnings
# go through agent._emit_warning and are never suppressed here.
if _compaction_status_emitted:
_emit_compaction_done(agent)
agent._emit_status(COMPACTION_STATUS)
# ── Compression lock ────────────────────────────────────────────────
# Atomic, state.db-backed lock per session_id. Without this, two
@@ -1445,12 +1061,6 @@ def compress_context(
_try_acquire_lock = None
_lock_lookup_error: Optional[Exception] = None
_legacy_session_db_without_lock_api = False
# Clear any stale lock-skip signal from a prior call so this call's
# outcome alone determines what callers see. Without this an
# auto-compress lock-skip followed by a successful manual /compress
# would falsely report "Compression already in progress" and discard
# the compression results.
agent._compression_skipped_due_to_lock = None
if _lock_db is not None:
try:
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
@@ -1538,11 +1148,6 @@ def compress_context(
_lock_sid, existing,
)
_lock_holder = None # don't release a lock we don't own
# Signal to callers that this no-op is due to a concurrent lock,
# not a genuine "nothing to compress" or aux-model failure.
# Manual /compress callers can surface a clear status message
# instead of the misleading "No changes from compression" text.
agent._compression_skipped_due_to_lock = existing or True
# Surface to the user once — quiet for downstream auto-compress loops
if getattr(agent, "_last_compression_lock_warning_sid", None) != _lock_sid:
agent._last_compression_lock_warning_sid = _lock_sid
@@ -1569,19 +1174,15 @@ def compress_context(
split_status="aborted",
failure_class="lock_contended",
)
_complete_compaction_lifecycle()
return messages, _existing_sp
_lock_released = False
def _release_lock() -> None:
"""Release the lock keyed on the OLD session_id (before rotation)."""
nonlocal _lock_released
_complete_compaction_lifecycle()
if _lock_released:
return
_lock_released = True
if getattr(agent, "_active_compression_lock_holder", None) == _lock_holder:
agent._active_compression_lock_holder = None
if _lock_refresher is not None:
try:
_lock_refresher.stop()
@@ -1593,9 +1194,6 @@ def compress_context(
except Exception as _rel_err:
logger.debug("compression lock release failed: %s", _rel_err)
if _lock_holder is not None:
agent._active_compression_lock_holder = _lock_holder
# A delayed contender can acquire the parent lock after the winning path
# has released it and completed rotation. The lock serializes work but does
# not by itself prove that this stale agent still owns a live parent.
@@ -1618,25 +1216,15 @@ def compress_context(
_existing_sp = agent._build_system_prompt(system_message)
return messages, _existing_sp
if _parent_already_rotated:
recovered_messages = _adopt_live_compression_child(
agent, _lock_db, _lock_sid
logger.info(
"compression skipped: session=%s was already rotated by "
"another compression path",
_lock_sid,
)
_release_lock()
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
if recovered_messages is not None:
logger.warning(
"compression recovery: stale session=%s adopted live child=%s",
_lock_sid,
agent.session_id,
)
return recovered_messages, _existing_sp
logger.warning(
"compression skipped: session=%s was already rotated by "
"another compression path, but no unique live child could be adopted",
_lock_sid,
)
return messages, _existing_sp
# The agent may have been constructed before another path completed an
@@ -1670,37 +1258,6 @@ def compress_context(
)
_lock_refresher.start()
# The caller's history snapshot predates lease acquisition. Reload the
# durable parent after the lease is live; MORE durable rows than the
# snapshot carries means a frontend/background writer committed a turn
# in that window, so publishing from this snapshot would omit it.
# Deliberately a LENGTH check, not content equality: in-memory
# mutation of past turns is legal (multimodal compression, retry
# history replacement, think-tag stripping), and a content-equality
# abort would permanently wedge compression on such sessions — the
# #14694 failure shape.
# Rotation-only: in-place compaction (archive_and_compact) is
# non-destructive — pre-compaction rows are soft-archived (active=0,
# compacted=1), stay searchable and recoverable, so snapshot/durable
# drift cannot lose data there and must not abort compaction.
if not in_place and _lock_db is not None and _lock_sid:
durable_loader = getattr(
type(_lock_db), "get_messages_as_conversation", None
)
if callable(durable_loader):
durable_parent = durable_loader(_lock_db, _lock_sid)
if isinstance(durable_parent, list) and len(durable_parent) > len(messages):
logger.warning(
"compression aborted: session=%s changed before lease "
"acquisition; preserving newer durable messages",
_lock_sid,
)
_release_lock()
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
# Notify external memory provider before compression discards context.
# The provider's on_pre_compress() may return a string of insights it
# wants surfaced inside the compression summary; capture and forward it
@@ -1762,7 +1319,6 @@ def compress_context(
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression completed")
_commit_fence_entered = False
try:
# Capture boundary quality before session-rotation callbacks run. Built-in
# and plugin lifecycle hooks may reset per-session compressor fields while
@@ -1850,28 +1406,6 @@ def compress_context(
_release_lock()
return messages, _existing_sp
if commit_fence is not None:
_commit_fence_entered = commit_fence.begin_commit()
if not _commit_fence_entered:
logger.info(
"Compression commit cancelled before session mutation "
"(session=%s).",
agent.session_id or "none",
)
agent._last_compaction_in_place = False
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="commit_fence_cancelled",
)
_release_lock()
return messages, _existing_sp
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
if summary_error:
if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
@@ -1900,54 +1434,11 @@ def compress_context(
todo_snapshot = agent._todo_store.format_for_injection()
if todo_snapshot:
# Fold the snapshot into a trailing REAL user message so
# compression never introduces a synthetic user/user pair. Any
# snapshot merged at an earlier boundary is stripped first so
# repeated compactions refresh rather than accumulate todo state
# (#26981). Scaffolding tails (continuation marker, summary
# handoff, a bare stale snapshot row) must never absorb the
# snapshot: merging would upgrade them to "real user" evidence
# and break zero-user provenance (#69292), so those keep the
# flagged standalone append and the real-user preservation pass
# continues to see todo scaffolding, not human intent.
from agent.context_compressor import _append_text_to_content
merged = False
_tail = (
compressed[-1]
if compressed and isinstance(compressed[-1], dict)
else None
)
if _tail is not None and _tail.get("role") == "user":
_stripped = _strip_stale_todo_snapshot(_tail.get("content"))
_probe = {
key: value for key, value in _tail.items() if key != "content"
}
_probe["content"] = _stripped
if _is_real_user_message(_probe):
_snapshot_text = (
f"\n\n{todo_snapshot}"
if isinstance(_stripped, str) and _stripped
else todo_snapshot
)
_tail["content"] = _append_text_to_content(
_stripped, _snapshot_text
)
merged = True
elif _stripped != _tail.get("content") and not _message_text(
{"role": "user", "content": _stripped}
).strip():
# The tail was nothing but an earlier snapshot row —
# refresh it in place instead of stacking a duplicate.
_tail["content"] = todo_snapshot
_tail["_todo_snapshot_synthetic"] = True
merged = True
if not merged:
compressed.append({
"role": "user",
"content": todo_snapshot,
"_todo_snapshot_synthetic": True,
})
compressed.append({
"role": "user",
"content": todo_snapshot,
"_todo_snapshot_synthetic": True,
})
_ensure_compressed_has_user_turn(messages, compressed)
cached_system_prompt = agent._cached_system_prompt
@@ -1969,21 +1460,6 @@ def compress_context(
):
new_system_prompt = cached_system_prompt
agent._cached_system_prompt = cached_system_prompt
# _invalidate_system_prompt() above also cleared the
# cross-session-stable prefix marker boundary. The kept prompt
# is byte-identical, so reconstruct the stable tier and reuse
# it ONLY when the kept prompt still literally starts with it
# (same startswith gate as the restore path); otherwise the
# request layer falls back to the legacy single-breakpoint
# layout with the prompt bytes untouched.
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
_static = _build_parts(agent, system_message=system_message)["stable"]
if _static and cached_system_prompt.startswith(_static):
agent._cached_system_prompt_static = _static
except Exception:
pass
else:
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt
@@ -2060,54 +1536,81 @@ def compress_context(
)
except Exception:
pass # best-effort — don't block compression on a flush error
# Publish parent closure + child row + compacted handoff in
# one transaction. No reader can observe a missing/empty child.
# The rotation child must stay on the parent's profile —
# mirror _ensure_db_session's stamp ("default" persists as
# NULL). publish_compression_child additionally COALESCEs
# from the parent row, covering app-global remote sessions
# whose thread lacks the HERMES_HOME context.
try:
from hermes_cli.profiles import get_active_profile_name
_profile_for_child = get_active_profile_name()
if _profile_for_child == "default":
_profile_for_child = None
except Exception:
_profile_for_child = None
# Propagate title to the new session with auto-numbering
old_title = agent._session_db.get_session_title(agent.session_id)
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
new_session_id = (
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_"
f"{uuid.uuid4().hex[:6]}"
)
agent._session_db.publish_compression_child(
parent_session_id=old_session_id,
child_session_id=new_session_id,
source=agent.platform
or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=agent.model,
model_config=agent._session_init_model_config,
system_prompt=new_system_prompt,
messages=compressed,
cwd=getattr(agent, "working_directory", None),
profile_name=_profile_for_child,
compression_lock_holder=_lock_holder,
require_compression_lease=_lock_holder is not None,
)
agent.session_id = new_session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
# Ordering contract: the agent thread updates the contextvar here;
# the gateway propagates to SessionEntry after run_in_executor returns.
try:
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id
# The gateway/tools session context (ContextVar + env) and the
# logging session context are SEPARATE mechanisms. The call above
# moves the former; the ``[session_id]`` tag on log lines comes
# from ``hermes_logging._session_context`` (set once per turn in
# conversation_loop.py). Without this, post-rotation log lines in
# the same turn keep the STALE old id while the message/DB/gateway
# state carry the new one — breaking log correlation exactly at the
# compaction boundary (see #34089). Guarded separately so a logging
# failure can never regress the routing update above.
try:
from hermes_logging import set_session_context
set_session_context(agent.session_id)
except Exception:
pass
agent._session_db_created = False
try:
agent._session_db.create_session(
session_id=agent.session_id,
source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=agent.model,
model_config=agent._session_init_model_config,
parent_session_id=old_session_id,
)
except Exception as _cs_err:
# The child row could not be created (e.g. FK constraint,
# contended write). Previously the outer handler simply
# warned and let the agent continue on the NEW id — which
# has no row in state.db, producing an orphan: the parent
# is ended, the child is never indexed, and every
# subsequent message is attributed to a session that
# doesn't exist (#33906/#33907). Roll the live id back to
# the parent so the conversation stays attached to a real,
# indexed session instead of a phantom.
logger.warning(
"Compression child session create failed (%s) — "
"rolling back to parent session %s to avoid an orphan.",
_cs_err, old_session_id,
)
agent.session_id = old_session_id
try:
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id
try:
from hermes_logging import set_session_context
set_session_context(agent.session_id)
except Exception:
pass
# Re-open the parent: it was ended above, but we're
# continuing on it, so it must not stay closed.
try:
agent._session_db.reopen_session(old_session_id)
except Exception:
pass
old_session_id = None # no rotation happened
# The parent row already exists in state.db, so mark the
# session as created — _ensure_db_session would otherwise
# retry a (harmless INSERT OR IGNORE) create next turn.
agent._session_db_created = True
raise
agent._session_db_created = True
split_status = "rotated_committed"
# Carry a persistent /goal onto the continuation session.
@@ -2127,14 +1630,18 @@ def compress_context(
except (ValueError, Exception) as e:
logger.debug("Could not propagate title on compression: %s", e)
# In-place mode still updates/replaces the current row here.
# Rotation already published prompt + compacted handoff atomically.
# Shared post-write steps (both modes target agent.session_id, which
# in-place keeps and rotation has already reassigned to the new id):
# refresh the stored system prompt and reset the flush cursor so the
# next turn re-bases its append diff.
agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
if in_place:
agent._session_db.update_system_prompt(
agent.session_id, new_system_prompt
)
agent._last_flushed_db_idx = 0
else:
# A headless turn can be killed before its finalizer. Persist
# the rotated child's compacted handoff at the boundary so
# the new session is immediately resumable.
agent._session_db.replace_messages(agent.session_id, compressed)
agent._last_flushed_db_idx = len(compressed)
agent._flushed_db_message_session_id = agent.session_id
agent._flushed_db_message_ids = {
@@ -2144,22 +1651,7 @@ def compress_context(
}
_session_commit_succeeded = True
except Exception as e:
if (
not in_place
and locals().get("old_session_id")
and agent.session_id == old_session_id
):
# Atomic publication failed (including lease loss): keep the
# parent live and discard the stale compacted snapshot.
old_session_id = None
messages[:] = copy.deepcopy(messages_before_compression)
compressed = messages
_compression_made_progress = False
split_status = (
"aborted"
if locals().get("old_session_id") is None and not in_place
else "failed_not_indexed"
)
split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed"
# If the rotation rolled back to the parent (orphan-avoidance
# above), agent.session_id is the still-indexed parent and
# old_session_id was cleared — so this is recovery, not an
@@ -2256,7 +1748,6 @@ def compress_context(
# via a rotation-independent flag. The gateway uses this — NOT an
# id-change diff — to re-baseline transcript handling (history_offset=0 +
# rewrite on the same id) when compaction happened in place. See #38763.
agent._last_compression_attempt_in_place = compacted_in_place
agent._last_compaction_in_place = compacted_in_place
# Keep the post-compression rough estimate for diagnostics, but do not
@@ -2322,11 +1813,7 @@ def compress_context(
# file dedup) ran. A concurrent path that wakes up the moment we
# release will see the NEW session_id in state.db / SessionEntry and
# acquire on that — no race against our just-finished work.
try:
_release_lock()
finally:
if _commit_fence_entered:
commit_fence.finish_commit()
_release_lock()
def _compress_context_via_codex_app_server(
@@ -2389,15 +1876,6 @@ def _compress_context_via_codex_app_server(
except Exception:
pass
_compaction_done_emitted = False
def _complete_compaction_lifecycle() -> None:
nonlocal _compaction_done_emitted
if _compaction_done_emitted:
return
_compaction_done_emitted = True
_emit_compaction_done(agent)
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
try:
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
@@ -2405,7 +1883,6 @@ def _compress_context_via_codex_app_server(
except BaseException:
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression failed")
_complete_compaction_lifecycle()
raise
if getattr(result, "interrupted", False) or getattr(result, "error", None):
@@ -2430,7 +1907,6 @@ def _compress_context_via_codex_app_server(
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
_complete_compaction_lifecycle()
return messages, existing_prompt
try:
@@ -2470,7 +1946,6 @@ def _compress_context_via_codex_app_server(
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
_complete_compaction_lifecycle()
return messages, existing_prompt
@@ -2731,7 +2206,6 @@ def try_shrink_image_parts_in_messages(
__all__ = [
"COMPACTION_STATUS",
"COMPACTION_DONE_STATUS",
"COMPACTION_STATUS_MARKER",
"check_compression_model_feasibility",
"replay_compression_warning",
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -503,20 +503,15 @@ class CopilotACPClient:
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
try:
# Hide the console the CLI child would otherwise flash on Windows
# (#56747). Hide-only — stdio pipes stay intact for the ACP wire.
from hermes_cli._subprocess_compat import windows_hide_flags
proc = subprocess.Popen(
[self._acp_command] + self._acp_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, encoding='utf-8', errors='replace',
text=True,
bufsize=1,
cwd=self._acp_cwd,
env=_build_subprocess_env(),
creationflags=windows_hide_flags(),
)
except FileNotFoundError as exc:
raise RuntimeError(
@@ -708,7 +703,7 @@ class CopilotACPClient:
if block_error:
raise PermissionError(block_error)
try:
content = path.read_text(encoding="utf-8")
content = path.read_text()
except FileNotFoundError:
content = ""
line = params.get("line")
@@ -736,7 +731,7 @@ class CopilotACPClient:
if denied:
raise PermissionError(denied)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(params.get("content") or ""), encoding="utf-8")
path.write_text(str(params.get("content") or ""))
response = {
"jsonrpc": "2.0",
"id": message_id,
+80 -284
View File
@@ -594,64 +594,22 @@ class CredentialPool:
# Re-armed to None on every successful selection so a recover→re-exhaust
# transition logs promptly instead of being swallowed by a stale window.
self._last_no_entries_log_at: Optional[float] = None
# #70401: consecutive mark_exhausted_and_rotate() calls whose supplied
# credential identity matched no pool entry (OAuth wrappers whose
# runtime key rotates, entries pruned by another process, ...). These
# rotations mark nothing exhausted, so without a cap the pool can
# never converge to "no available entries" and the caller's 401 retry
# loop runs unbounded and non-interruptible. Reset whenever a real
# entry is identified or an escape path returns None.
self._unmatched_rotation_streak: int = 0
def has_credentials(self) -> bool:
with self._lock:
return bool(self._entries)
return bool(self._entries)
def has_available(self) -> bool:
"""True if at least one entry is not currently in exhaustion cooldown."""
# ``_available_entries`` is not read-only: it prunes aged-out DEAD
# manual entries (rebinding ``self._entries``) and persists. It must
# run under ``self._lock`` like every other caller (``select`` etc.),
# otherwise a status probe here can race a concurrent ``select`` /
# rotation and tear ``self._entries`` or double-write auth.json.
with self._lock:
return bool(self._available_entries())
return bool(self._available_entries())
def entries(self) -> List[PooledCredential]:
with self._lock:
return list(self._entries)
return list(self._entries)
def _current_unlocked(self) -> Optional[PooledCredential]:
def current(self) -> Optional[PooledCredential]:
if not self._current_id:
return None
return next((entry for entry in self._entries if entry.id == self._current_id), None)
def current(self) -> Optional[PooledCredential]:
with self._lock:
return self._current_unlocked()
def entry_id_for_api_key(self, api_key_hint: Any = None) -> Optional[str]:
"""Return the stable id for the runtime credential in use.
Prefer the current selection when it still supplies ``api_key_hint``.
If the cursor was cleared, fall back to an unambiguous key match.
"""
with self._lock:
current = self._current_unlocked()
if current is not None and (
api_key_hint is None
or current.runtime_api_key == api_key_hint
):
return current.id
if api_key_hint is None:
return None
matches = [
entry
for entry in self._entries
if entry.runtime_api_key == api_key_hint
]
return matches[0].id if len(matches) == 1 else None
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
"""Swap an entry in-place by id, preserving sort order."""
for idx, entry in enumerate(self._entries):
@@ -694,8 +652,6 @@ class CredentialPool:
entry: PooledCredential,
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
*,
persist: bool = True,
) -> PooledCredential:
normalized_error = _normalize_error_context(error_context)
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
@@ -719,8 +675,7 @@ class CredentialPool:
last_error_reset_at=normalized_error.get("reset_at"),
)
self._replace_entry(entry, updated)
if persist:
self._persist()
self._persist()
return updated
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
@@ -1529,43 +1484,6 @@ 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
@@ -1592,13 +1510,7 @@ class CredentialPool:
def select(self) -> Optional[PooledCredential]:
with self._lock:
entry = self._select_unlocked()
if entry is not None:
# A normal (non-recovery) selection starts a fresh episode —
# don't let a leftover unmatched-rotation streak from an old
# failure trip the #70401 bound early next time.
self._unmatched_rotation_streak = 0
return entry
return self._select_unlocked()
def _available_entries(self, *, clear_expired: bool = False, refresh: bool = False) -> List[PooledCredential]:
"""Return entries not currently in exhaustion cooldown.
@@ -1693,18 +1605,7 @@ class CredentialPool:
if entry.last_status == STATUS_EXHAUSTED:
exhausted_until = _exhausted_until(entry)
if exhausted_until is not None and now < exhausted_until:
# 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
continue
if clear_expired:
cleared = replace(
entry,
@@ -1777,21 +1678,18 @@ class CredentialPool:
self._entries = [replace(candidate, priority=idx) for idx, candidate in enumerate(rotated)]
self._persist()
self._current_id = entry.id
return self._current_unlocked() or entry
return self.current() or entry
entry = available[0]
self._current_id = entry.id
return entry
def peek(self) -> Optional[PooledCredential]:
# Single lock acquisition for the whole read; call the unlocked
# helpers so we don't re-enter the non-reentrant ``self._lock``.
with self._lock:
current = self._current_unlocked()
if current is not None:
return current
available = self._available_entries()
return available[0] if available else None
current = self.current()
if current is not None:
return current
available = self._available_entries()
return available[0] if available else None
def mark_exhausted_and_rotate(
self,
@@ -1799,17 +1697,10 @@ class CredentialPool:
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
) -> Optional[PooledCredential]:
with self._lock:
entry = None
identity_supplied = bool(credential_id or api_key_hint)
if credential_id:
entry = next(
(e for e in self._entries if e.id == credential_id),
None,
)
if entry is None and api_key_hint:
if api_key_hint:
# Prefer the specific entry whose API key matches the one that
# actually failed. When this pool was freshly loaded from disk
# (another process already rotated), current() is None and
@@ -1818,89 +1709,12 @@ class CredentialPool:
(e for e in self._entries if e.runtime_api_key == api_key_hint),
None,
)
if entry is None and identity_supplied:
# The failed credential is identifiable but matches no entry
# (rotated away, or a wrapper whose runtime key differs).
# Falling through to current()/_select_unlocked() would mark an
# innocent healthy key exhausted for the full cooldown TTL.
#
# #70401: this branch must still be BOUNDED. With OAuth-token
# auth the upstream 401's key hint never matches any entry's
# ``runtime_api_key``, so every retry lands here, nothing is
# ever marked exhausted, and the pool can never reach the
# "no available entries" state — the caller retries the same
# dead token forever (~6/sec, starving the event loop so chat
# interrupts are never processed). The single-entry case
# below already escapes; multi-entry pools could still
# ping-pong A→B→A indefinitely without marking anything.
# Cap consecutive no-mark rotations at one full lap of the
# available entries: past that, every candidate has been
# handed back at least once without recovery, so stop
# guessing and surface the error (no cooldown is written for
# anybody — healthy keys stay available for the next turn).
self._unmatched_rotation_streak += 1
available_count = len(self._available_entries())
if self._unmatched_rotation_streak > max(available_count, 1):
logger.warning(
"credential pool: failed credential identity matched no "
"%s entry for %d consecutive rotations (pool size %d) — "
"surfacing the error instead of rotating again",
self.provider,
self._unmatched_rotation_streak,
available_count,
)
self._unmatched_rotation_streak = 0
self._current_id = None
return None
logger.info(
"credential pool: failed credential identity matched no %s "
"entry; rotating without marking any credential exhausted",
self.provider,
)
self._current_id = None
next_entry = self._select_unlocked()
if next_entry is not None and len(self._available_entries()) == 1:
# A single-entry pool cannot rotate. Returning its only
# entry reports a successful recovery without changing
# the credential, so the caller retries the same 401
# indefinitely. Let fallback/error propagation proceed.
self._unmatched_rotation_streak = 0
self._current_id = None
return None
return next_entry
# A real entry was identified — any prior unmatched-rotation
# streak is stale (this mark WILL advance pool state).
self._unmatched_rotation_streak = 0
if entry is None:
entry = self._current_unlocked() or self._select_unlocked()
entry = self.current() or self._select_unlocked()
if entry is None:
return None
_label = entry.label or entry.id[:8]
self._mark_exhausted(entry, status_code, error_context)
# A 402/429/401 is an API-keylevel failure: the account is out of
# balance, rate-limited, or its key is rejected. The same key can
# back more than one pool entry (e.g. an explicit pool entry plus a
# ``model_config`` entry auto-seeded from ``model.api_key`` — both
# carry the identical ``runtime_api_key``). Marking only the first
# match leaves the sibling entries OK, so ``_select_unlocked()``
# keeps handing back the same depleted key and rotation never
# converges — the caller ``continue``s forever until the client
# disconnects (a ~2.5min hang with no error surfaced to the user).
# Mark every entry sharing the failed key so the pool can reach the
# "no available entries" state and let the error propagate.
failed_runtime_key = getattr(entry, "runtime_api_key", None)
if identity_supplied and failed_runtime_key:
siblings_marked = False
for sibling in self._entries:
if sibling.id == entry.id:
continue
if sibling.runtime_api_key == failed_runtime_key:
self._mark_exhausted(
sibling, status_code, error_context, persist=False
)
siblings_marked = True
if siblings_marked:
self._persist()
# Re-read the updated entry to log the correct terminal state.
updated_entry = next(
(e for e in self._entries if e.id == entry.id), entry,
@@ -1968,11 +1782,9 @@ class CredentialPool:
return self._try_refresh_current_unlocked()
def try_refresh_matching(
self,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
self, api_key_hint: Optional[str] = None
) -> Optional[PooledCredential]:
"""Force-refresh the entry that supplied the failed request.
"""Force-refresh the entry that supplied ``api_key_hint``.
Direct provider integrations may reload the pool after a request has
already failed, so they cannot rely on ``current_id`` identifying the
@@ -1982,36 +1794,24 @@ class CredentialPool:
"""
with self._lock:
entry = None
if credential_id:
if api_key_hint:
entry = next(
(
candidate
for candidate in self._entries
if candidate.id == credential_id
if candidate.runtime_api_key == api_key_hint
),
None,
)
if entry is None:
if api_key_hint:
entry = next(
(
candidate
for candidate in self._entries
if candidate.runtime_api_key == api_key_hint
),
None,
)
else:
entry = self._current_unlocked() or self._select_unlocked(
refresh=False
)
else:
entry = self.current() or self._select_unlocked(refresh=False)
if entry is None:
return None
self._current_id = entry.id
return self._try_refresh_current_unlocked()
def _try_refresh_current_unlocked(self) -> Optional[PooledCredential]:
entry = self._current_unlocked()
entry = self.current()
if entry is None:
return None
refreshed = self._refresh_entry(entry, force=True)
@@ -2020,80 +1820,76 @@ class CredentialPool:
return refreshed
def reset_statuses(self) -> int:
with self._lock:
count = 0
new_entries = []
for entry in self._entries:
if entry.last_status or entry.last_status_at or entry.last_error_code:
new_entries.append(
replace(
entry,
last_status=None,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
)
count = 0
new_entries = []
for entry in self._entries:
if entry.last_status or entry.last_status_at or entry.last_error_code:
new_entries.append(
replace(
entry,
last_status=None,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
)
count += 1
else:
new_entries.append(entry)
if count:
self._entries = new_entries
self._persist()
return count
)
count += 1
else:
new_entries.append(entry)
if count:
self._entries = new_entries
self._persist()
return count
def remove_index(self, index: int) -> Optional[PooledCredential]:
with self._lock:
if index < 1 or index > len(self._entries):
return None
removed = self._entries.pop(index - 1)
self._entries = [
replace(entry, priority=new_priority)
for new_priority, entry in enumerate(self._entries)
]
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=[removed.id],
)
if self._current_id == removed.id:
self._current_id = None
return removed
if index < 1 or index > len(self._entries):
return None
removed = self._entries.pop(index - 1)
self._entries = [
replace(entry, priority=new_priority)
for new_priority, entry in enumerate(self._entries)
]
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=[removed.id],
)
if self._current_id == removed.id:
self._current_id = None
return removed
def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]:
raw = str(target or "").strip()
if not raw:
return None, None, "No credential target provided."
with self._lock:
for idx, entry in enumerate(self._entries, start=1):
if entry.id == raw:
return idx, entry, None
for idx, entry in enumerate(self._entries, start=1):
if entry.id == raw:
return idx, entry, None
label_matches = [
(idx, entry)
for idx, entry in enumerate(self._entries, start=1)
if entry.label.strip().lower() == raw.lower()
]
if len(label_matches) == 1:
return label_matches[0][0], label_matches[0][1], None
if len(label_matches) > 1:
return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.'
if raw.isdigit():
index = int(raw)
if 1 <= index <= len(self._entries):
return index, self._entries[index - 1], None
return None, None, f"No credential #{index}."
return None, None, f'No credential matching "{raw}".'
label_matches = [
(idx, entry)
for idx, entry in enumerate(self._entries, start=1)
if entry.label.strip().lower() == raw.lower()
]
if len(label_matches) == 1:
return label_matches[0][0], label_matches[0][1], None
if len(label_matches) > 1:
return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.'
if raw.isdigit():
index = int(raw)
if 1 <= index <= len(self._entries):
return index, self._entries[index - 1], None
return None, None, f"No credential #{index}."
return None, None, f'No credential matching "{raw}".'
def add_entry(self, entry: PooledCredential) -> PooledCredential:
with self._lock:
entry = replace(entry, priority=_next_priority(self._entries))
self._entries.append(entry)
self._persist()
return entry
entry = replace(entry, priority=_next_priority(self._entries))
self._entries.append(entry)
self._persist()
return entry
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
+1 -1
View File
@@ -164,7 +164,7 @@ def _remove_env_source(provider: str, removed) -> RemovalResult:
if env_path.exists():
env_in_dotenv = any(
line.strip().startswith(f"{env_var}=")
for line in env_path.read_text(errors="replace", encoding="utf-8").splitlines()
for line in env_path.read_text(errors="replace").splitlines()
)
except OSError:
pass
+2 -11
View File
@@ -316,21 +316,12 @@ def evaluate_credits_notices(
active.discard(CREDITS_USAGE_KEY)
if target_band is not None:
# Belt-and-suspenders: a producer could set subscription_limit_micros
# without subscription_limit_usd. Render "$?" rather than "$None".
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
_cap_usd = state.subscription_limit_usd or "?"
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
# Report absolute dollars used, not a bare "N% used": the percentage is
# only meaningful against a Nous subscription cap (no cap → never fires),
# so dollars are clearer and don't imply a universal %. Used = cap
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
_lim = state.subscription_limit_micros or 0
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
_glyph = "" if _level == "warn" else ""
to_show.append(
AgentNotice(
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
text=f"{'' if _level == 'warn' else ''} Credits {target_band}% used · ${_cap_usd} cap",
level=_level,
kind=CREDITS_NOTICE_KIND,
key=CREDITS_USAGE_KEY,
+1 -3
View File
@@ -422,9 +422,7 @@ CURATOR_REVIEW_PROMPT = (
"INSTRUCTIONS AND EXPERIENTIAL KNOWLEDGE. A collection of hundreds of "
"narrow skills where each one captures one session's specific bug is "
"a FAILURE of the library — not a feature. An agent searching skills "
"matches on descriptions, not on exact names (note: long descriptions "
"are truncated to 57 chars in the system prompt skill index — keep the "
"trigger class in that window). One broad umbrella "
"matches on descriptions, not on exact names; one broad umbrella "
"skill with labeled subsections beats five narrow siblings for "
"discoverability, not the other way around.\n\n"
"The right target shape is CLASS-LEVEL skills with rich SKILL.md "
-85
View File
@@ -1,85 +0,0 @@
"""Context-local state for delegate_task child execution.
The parent Hermes process may itself be a Kanban dispatcher worker with
HERMES_KANBAN_* variables in process env. delegate_task children run inside the
same Python process, but they are not dispatcher-owned Kanban workers. This
module lets code paths that resolve tool schemas or spawn subprocesses fail
closed for delegated children without mutating global os.environ for the parent.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Iterator, Mapping, MutableMapping
_DELEGATED_CHILD_CONTEXT: ContextVar[bool] = ContextVar(
"hermes_delegated_child_context",
default=False,
)
DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT"
KANBAN_ENV_KEYS: tuple[str, ...] = (
"HERMES_KANBAN_TASK",
"HERMES_KANBAN_RUN_ID",
"HERMES_KANBAN_WORKSPACE",
"HERMES_KANBAN_WORKSPACES_ROOT",
"HERMES_KANBAN_CLAIM_LOCK",
"HERMES_KANBAN_BOARD",
"HERMES_KANBAN_DB",
)
@contextmanager
def delegated_child_context() -> Iterator[None]:
"""Mark the current execution context as a delegate_task child."""
token = _DELEGATED_CHILD_CONTEXT.set(True)
try:
yield
finally:
_DELEGATED_CHILD_CONTEXT.reset(token)
def is_delegated_child_context() -> bool:
"""Return True while code is running for a delegate_task child."""
return bool(_DELEGATED_CHILD_CONTEXT.get())
def is_delegated_child_process_context() -> bool:
"""Return True in this process or a subprocess spawned by a child."""
import os
return bool(_DELEGATED_CHILD_CONTEXT.get()) or bool(
os.environ.get(DELEGATED_CHILD_ENV_MARKER)
)
def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]:
"""Return *env* with dispatcher-only Kanban variables removed."""
cleaned = dict(env)
for key in KANBAN_ENV_KEYS:
cleaned.pop(key, None)
cleaned[DELEGATED_CHILD_ENV_MARKER] = "1"
return cleaned
def delegated_child_subprocess_env(
env: Mapping[str, str] | MutableMapping[str, str] | None = None,
) -> dict[str, str] | None:
"""Return an env override only when delegated-child lineage must cross fork.
Most subprocess call sites historically used ``env=None`` to inherit the
process environment. In a ``delegate_task`` child, inheriting as-is leaks
parent dispatcher ``HERMES_KANBAN_*`` vars while losing the ContextVar in
the new process. This helper preserves normal ``env=None`` semantics for
non-delegated calls, and only materializes a scrubbed env when the lineage
marker must be propagated across a child-process boundary.
"""
if not is_delegated_child_process_context():
return None if env is None else dict(env)
if env is None:
import os
env = os.environ
return scrub_kanban_env(env)
-1
View File
@@ -413,7 +413,6 @@ _CONTENT_POLICY_BLOCKED_PATTERNS = [
_AUTH_PATTERNS = [
"invalid api key",
"invalid_api_key",
"gateway_auth_failed",
"authentication",
"unauthorized",
"forbidden",
+2 -6
View File
@@ -270,12 +270,8 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
}
}
thought_signature = _tool_call_extra_signature(tool_call)
# Fallback sentinel for cross-provider tool_calls (e.g. fallback from
# xAI/Anthropic to Gemini, where the original tool_call carries no
# Gemini thoughtSignature). Mirrors gemini_cloudcode_adapter.py:106.
# Without this, Gemini 3 thinking models reject replayed history with
# 400 INVALID_ARGUMENT on the missing thoughtSignature.
part["thoughtSignature"] = thought_signature or "skip_thought_signature_validator"
if thought_signature:
part["thoughtSignature"] = thought_signature
return part
+27 -7
View File
@@ -25,14 +25,14 @@ Language resolution order:
3. ``display.language`` from config.yaml
4. ``"en"`` (baseline)
Supported languages: en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga,
pt, ru, hu, ar. Unknown values fall back to en.
Supported languages: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
"""
from __future__ import annotations
import logging
import os
import sysconfig
import threading
from functools import lru_cache
from pathlib import Path
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
SUPPORTED_LANGUAGES: tuple[str, ...] = (
"en", "zh", "zh-hant", "ja", "de", "es", "fr", "tr", "uk",
"af", "ko", "it", "ga", "pt", "ru", "hu", "ar",
"af", "ko", "it", "ga", "pt", "ru", "hu",
)
DEFAULT_LANGUAGE = "en"
@@ -79,9 +79,6 @@ _LANGUAGE_ALIASES: dict[str, str] = {
"russian": "ru", "русский": "ru", "ru-ru": "ru",
# Hungarian
"hungarian": "hu", "magyar": "hu", "hu-hu": "hu",
# Arabic — bare "arabic"/endonym plus the common regional BCP-47 tags.
"arabic": "ar", "العربية": "ar",
"ar-sa": "ar", "ar-eg": "ar", "ar-ae": "ar", "ar-ma": "ar", "ar-dz": "ar",
}
_catalog_cache: dict[str, dict[str, str]] = {}
@@ -95,8 +92,12 @@ 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 editable installs,
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
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
@@ -115,6 +116,25 @@ 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
+35 -89
View File
@@ -181,8 +181,6 @@ def _supports_vision_override(
cfg: Optional[Dict[str, Any]],
provider: str,
model: str,
*,
requested_provider: str = "",
) -> Optional[bool]:
"""Resolve user-declared vision capability from config.yaml.
@@ -190,14 +188,9 @@ def _supports_vision_override(
1. ``model.supports_vision`` (top-level shortcut for the active model)
2. ``providers.<provider>.models.<model>.supports_vision``
(named custom providers ``provider`` may be the runtime-resolved
value ``"custom"``, the runtime's originally requested provider,
and/or the user-declared name under ``model.provider``; all are
tried. For ``custom:<name>`` syntax, the stripped ``<name>`` is also
tried as a provider key.)
2b. ``custom_providers`` (legacy list form) ``.models.<model>``
Under (2) and (2b), the per-model capability key may be written as
either ``supports_vision`` or the shorter ``vision`` alias; both work.
value ``"custom"`` and/or the user-declared name under
``model.provider``; both are tried. For ``custom:<name>`` syntax,
the stripped ``<name>`` is also tried as a provider key.)
Returns None when no override is set, so the caller falls through to
models.dev. Returns False explicitly only when the user wrote a
@@ -217,30 +210,23 @@ def _supports_vision_override(
# get rewritten to provider="custom" at runtime
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
# config still holds the user-declared name under model.provider. Try
# both as candidate provider keys. Either identity may use the
# "custom:<name>" form while providers: is keyed by bare <name>.
# both as candidate provider keys, plus the stripped suffix from
# "custom:<name>" (where <name> is the key under providers:).
config_provider = str(model_cfg.get("provider") or "").strip()
provider_candidates: List[str] = []
for candidate in (requested_provider, provider, config_provider):
if not candidate:
continue
provider_candidates.append(candidate)
if candidate.startswith("custom:"):
stripped_candidate = candidate[len("custom:"):]
if stripped_candidate:
provider_candidates.append(stripped_candidate)
# Extract the stripped name from "custom:<name>" if present
stripped_suffix = ""
if config_provider.startswith("custom:"):
stripped_suffix = config_provider[len("custom:"):]
providers_raw = cfg.get("providers")
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
for p in dict.fromkeys(provider_candidates):
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
entry_raw = providers_cfg.get(p)
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
models_raw = entry.get("models")
models_cfg: Dict[str, Any] = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model: Dict[str, Any] = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(
per_model.get("supports_vision", per_model.get("vision"))
)
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
@@ -249,26 +235,28 @@ def _supports_vision_override(
# may appear as the raw name or "custom:<name>" at runtime).
custom_providers = cfg.get("custom_providers")
if isinstance(custom_providers, list):
# Candidate priority matters when the CLI-selected provider differs
# from model.provider. Walk identities first, then config entries, so
# list order cannot let the persisted default shadow the live route.
for candidate in dict.fromkeys(provider_candidates):
candidate_name = candidate.strip().lower()
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip().lower()
if entry_name != candidate_name:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(
per_model.get("supports_vision", per_model.get("vision"))
)
if coerced is not None:
return coerced
# Build candidate names: the provider value and the config provider
# value, both raw and with "custom:" prefix stripped/added.
candidate_names: set = set()
for p in filter(None, (provider, config_provider)):
candidate_names.add(p)
if p.startswith("custom:"):
candidate_names.add(p[len("custom:"):])
else:
candidate_names.add(f"custom:{p}")
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip()
if entry_name not in candidate_names:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
return None
@@ -388,8 +376,6 @@ def _lookup_supports_vision(
provider: str,
model: str,
cfg: Optional[Dict[str, Any]] = None,
*,
requested_provider: str = "",
) -> Optional[bool]:
"""Return True/False if we can resolve caps, None if unknown.
@@ -397,34 +383,7 @@ def _lookup_supports_vision(
(so custom/local models declared as vision-capable don't fall through to
text routing in ``auto`` mode), then falls back to models.dev.
"""
# Named custom providers are canonicalized to ``provider="custom"`` by
# runtime resolution. The original CLI/config name is carried in the
# context-local main runtime so capability lookup can still select the
# exact custom_providers entry. Require an exact provider+model match:
# background/auxiliary lookups must never borrow another turn's identity.
if not requested_provider:
try:
from agent.auxiliary_client import _runtime_main_value
runtime_provider = str(
_runtime_main_value("provider") or ""
).strip().lower()
runtime_model = str(_runtime_main_value("model") or "").strip()
lookup_provider = str(provider or "").strip().lower()
lookup_model = str(model or "").strip()
if runtime_provider == lookup_provider and runtime_model == lookup_model:
requested_provider = str(
_runtime_main_value("requested_provider") or ""
).strip()
except Exception:
pass
override = _supports_vision_override(
cfg,
provider,
model,
requested_provider=requested_provider,
)
override = _supports_vision_override(cfg, provider, model)
if override is not None:
return override
if not provider or not model:
@@ -462,8 +421,6 @@ def decide_image_input_mode(
provider: str,
model: str,
cfg: Optional[Dict[str, Any]],
*,
requested_provider: str = "",
) -> str:
"""Return ``"native"`` or ``"text"`` for the given turn.
@@ -471,7 +428,6 @@ def decide_image_input_mode(
provider: active inference provider ID (e.g. ``"anthropic"``, ``"openrouter"``).
model: active model slug as it would be sent to the provider.
cfg: loaded config.yaml dict, or None. When None, behaves as auto.
requested_provider: provider identity before runtime canonicalization.
"""
mode_cfg = "auto"
if isinstance(cfg, dict):
@@ -488,17 +444,7 @@ def decide_image_input_mode(
# explicit auxiliary.vision config acts as a *fallback* for text-only
# main models — it should not preempt native vision on a model that
# can natively inspect the pixels (issue #29135).
if requested_provider:
supports = _lookup_supports_vision(
provider,
model,
cfg,
requested_provider=requested_provider,
)
else:
# Keep the long-standing three-argument call contract for callers and
# tests that replace the capability lookup hook.
supports = _lookup_supports_vision(provider, model, cfg)
supports = _lookup_supports_vision(provider, model, cfg)
if supports is True:
return "native"
if _explicit_aux_vision_override(cfg):
-9
View File
@@ -56,8 +56,6 @@ from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote
from hermes_cli._subprocess_compat import windows_hide_flags
from agent.lsp.protocol import (
ERROR_CONTENT_MODIFIED,
ERROR_METHOD_NOT_FOUND,
@@ -296,12 +294,6 @@ class LSPClient:
cmd = self._command
if sys.platform == "win32":
cmd = self._win_wrap_cmd(cmd)
# Suppress the cmd.exe console window that would otherwise flash
# every time we launch a ``.cmd``-wrapped language server
# (e.g. pyright-langserver.CMD) from a console-less host such as
# a VS Code/Zed extension running the ACP adapter.
# windows_hide_flags() is CREATE_NO_WINDOW on Windows, 0 on POSIX.
creationflags = windows_hide_flags()
try:
# start_new_session=True detaches the LSP server into its own
@@ -320,7 +312,6 @@ class LSPClient:
env=env,
cwd=self._cwd,
start_new_session=True,
creationflags=creationflags,
)
except FileNotFoundError as e:
raise LSPProtocolError(
+2 -6
View File
@@ -35,8 +35,6 @@ import threading
from pathlib import Path
from typing import Any, Dict, Optional
from hermes_cli._subprocess_compat import windows_hide_flags
logger = logging.getLogger("agent.lsp.install")
# Package-name → install-strategy hint registry. Each entry is a
@@ -267,10 +265,9 @@ def _install_npm(
[npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets],
check=False,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
if proc.returncode != 0:
logger.warning(
@@ -316,11 +313,10 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
[go, "install", pkg],
check=False,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
if proc.returncode != 0:
logger.warning(
-30
View File
@@ -7,36 +7,6 @@ from typing import Any, Sequence
from agent.redact import redact_sensitive_text
def describe_compression_lock_skip(lock_signal: Any) -> str:
"""User-facing text for a manual /compress skipped by the compression lock.
``lock_signal`` is ``agent._compression_skipped_due_to_lock`` (or the
``holder`` carried by the TUI's ``CompressionLockHeld``): a descriptive
holder string when another compressor CONFIRMED holds the lock, or
``True``/``None`` when acquisition failed without a confirmed holder
(``hermes_state.try_acquire_compression_lock`` catches ``sqlite3.Error``
internally and returns ``False``, so a failed acquire is NOT proof that
another compression is running). The two cases must be worded
differently: claiming "already in progress" on an unconfirmed failure
misdirects the user when the real problem is a broken lock subsystem.
"""
holder = (
lock_signal
if isinstance(lock_signal, str) and lock_signal.strip()
else None
)
if holder:
return (
f"⏳ Compression already in progress for this session "
f"(holder: {holder}). Please wait for it to finish."
)
return (
"⏳ Compression skipped: could not acquire this session's "
"compression lock. Another compression may still be running, or "
"the lock check failed — try again shortly."
)
def summarize_manual_compression(
before_messages: Sequence[dict[str, Any]],
after_messages: Sequence[dict[str, Any]],
+4 -14
View File
@@ -80,17 +80,8 @@ def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
return schema
def memory_provider_tools_enabled(
enabled_toolsets: Optional[List[str]],
disabled_toolsets: Optional[List[str]] = None,
*,
memory_tool_present: bool = False,
) -> bool:
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if disabled_toolsets and "memory" in disabled_toolsets:
return False
if memory_tool_present:
return True
if enabled_toolsets is None:
return True
if not enabled_toolsets:
@@ -119,10 +110,9 @@ def inject_memory_provider_tools(agent: Any) -> int:
for tool in tools
if isinstance(tool, dict)
}
if not memory_provider_tools_enabled(
getattr(agent, "enabled_toolsets", None),
getattr(agent, "disabled_toolsets", None),
memory_tool_present="memory" in existing_tool_names,
if (
"memory" not in existing_tool_names
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
):
return 0
+86 -976
View File
File diff suppressed because it is too large Load Diff
+3 -12
View File
@@ -215,7 +215,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-5": 1000000,
"claude-sonnet-5": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
@@ -2202,18 +2201,11 @@ def get_model_context_length(
# acting context, so they're ignored here.
if (provider or "").strip().lower() == "moa":
try:
from hermes_cli.config import (
get_compatible_custom_providers,
load_config,
)
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
from hermes_cli.runtime_provider import resolve_runtime_provider
config = load_config()
effective_custom_providers = custom_providers
if effective_custom_providers is None:
effective_custom_providers = get_compatible_custom_providers(config)
preset = resolve_moa_preset(config.get("moa") or {}, model)
preset = resolve_moa_preset(load_config().get("moa") or {}, model)
agg = preset.get("aggregator") or {}
agg_provider = str(agg.get("provider") or "").strip()
agg_model = str(agg.get("model") or "").strip()
@@ -2223,8 +2215,7 @@ def get_model_context_length(
agg_model,
base_url=rt.get("base_url", "") or "",
api_key=rt.get("api_key", "") or "",
provider=rt.get("provider") or agg_provider,
custom_providers=effective_custom_providers,
provider=agg_provider,
)
except Exception:
logger.debug("MoA aggregator context-length resolution failed", exc_info=True)
+1 -1
View File
@@ -117,7 +117,7 @@ def record_nous_rate_limit(
# Atomic write: write to temp file + rename
fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
with os.fdopen(fd, "w") as f:
json.dump(state, f)
atomic_replace(tmp_path, path)
except Exception:
-13
View File
@@ -52,13 +52,6 @@ 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, "
@@ -81,12 +74,6 @@ 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, "
+2 -15
View File
@@ -192,13 +192,7 @@ SKILLS_GUIDANCE = (
"skill with skill_manage so you can reuse it next time.\n"
"When using a skill and finding it outdated, incomplete, or wrong, "
"patch it immediately with skill_manage(action='patch') — don't wait to be asked. "
"Skills that aren't maintained become liabilities.\n"
"\n"
"## Skill Safety Rule\n"
"1. **UNAVAILABLE** — If a skill placeholder contains `[SKILL_PRUNED]`, the skill content was lost in compression and is inaccessible.\n"
"2. **RELOAD** — Before performing any action that depends on a skill, re-check its content with `skill_view(name='...')` if it shows `[SKILL_PRUNED]`.\n"
"3. **WAIT** — If a skill is loading or was just pruned, wait for the reload confirmation before proceeding.\n"
"4. **DEDUP** — After reloading a pruned skill, **ignore any remaining `[SKILL_PRUNED]` markers for that same skill** — they are historical artifacts from previous compactions and do not need further action."
"Skills that aren't maintained become liabilities."
)
KANBAN_GUIDANCE = (
@@ -890,14 +884,7 @@ PLATFORM_HINTS = {
"You're responding through an API server. The rendering layer is unknown — "
"assume plain text. No markdown formatting (no asterisks, bullets, headers, "
"code fences). Treat this like a conversation, not a document. Keep responses "
"brief and natural. "
"File/media delivery: images referenced as MEDIA:/absolute/path tags "
"(.png/.jpg/.jpeg/.gif/.webp/.bmp, up to 5MB) are inlined as base64 data "
"URLs in responses on the chat, completions, and responses endpoints. "
"Non-image files are NOT intercepted anywhere, and the runs endpoint "
"intercepts nothing — a MEDIA: tag there renders as literal text exposing "
"a raw host filesystem path. For those cases, state the plain file path "
"in your response text instead of a MEDIA: tag."
"brief and natural."
),
"webui": (
"You are in the Hermes WebUI, a browser-based chat interface. "
+9 -55
View File
@@ -1,11 +1,9 @@
"""Anthropic prompt caching strategy.
The default layout uses 4 cache_control breakpoints: the static system
prefix, the end of the system prompt, and the last 2 non-system messages.
When a static system prefix is unavailable, it falls back to one system
breakpoint plus the last 3 messages. All markers use the same TTL (5m or 1h).
This preserves intra-session caching while allowing new sessions to reuse the
stable system-prompt prefix.
Single layout: ``system_and_3``. 4 cache_control breakpoints system
prompt + last 3 non-system messages, all at the same TTL (5m or 1h).
Reduces input token costs by ~75% on multi-turn conversations within a
single session.
Pure functions -- no class state, no AIAgent dependency.
"""
@@ -83,55 +81,15 @@ def _build_marker(ttl: str) -> Dict[str, str]:
return marker
def _apply_system_cache_markers(
message: dict,
cache_marker: dict,
static_system_prefix: str | None,
*,
native_anthropic: bool,
) -> int:
"""Mark the static system prefix and full prompt when they can be split.
The system prompt remains one stored string. Splitting it only in the
outgoing request keeps session persistence and non-Anthropic transports
unchanged while making the stable prefix independently cacheable.
"""
content = message.get("content")
if (
isinstance(static_system_prefix, str)
and static_system_prefix
and isinstance(content, str)
and content.startswith(static_system_prefix)
):
suffix = content[len(static_system_prefix):]
if suffix:
message["content"] = [
{
"type": "text",
"text": static_system_prefix,
"cache_control": cache_marker,
},
{"type": "text", "text": suffix, "cache_control": cache_marker},
]
return 2
_apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic)
return 1
def apply_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
cache_ttl: str = "5m",
native_anthropic: bool = False,
static_system_prefix: str | None = None,
) -> List[Dict[str, Any]]:
"""Apply Anthropic cache-control markers to API messages.
"""Apply system_and_3 caching strategy to messages for Anthropic models.
When ``static_system_prefix`` exactly matches the beginning of a string
system prompt, it receives an early marker and the full system prompt gets
a trailing marker. The remaining two markers target the latest cacheable
non-system messages. Without that prefix, the legacy system-and-3 layout
is retained.
Places up to 4 cache_control breakpoints: system prompt + last 3 non-system
messages, all at the same TTL.
Returns:
Deep copy of messages with cache_control breakpoints injected.
@@ -145,12 +103,8 @@ def apply_anthropic_cache_control(
breakpoints_used = 0
if messages[0].get("role") == "system":
breakpoints_used = _apply_system_cache_markers(
messages[0],
marker,
static_system_prefix,
native_anthropic=native_anthropic,
)
_apply_cache_marker(messages[0], marker, native_anthropic=native_anthropic)
breakpoints_used += 1
remaining = 4 - breakpoints_used
non_sys = [
-8
View File
@@ -1,8 +0,0 @@
"""Egress proxy integrations.
Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts
outbound traffic from remote terminal sandboxes and swaps proxy tokens
for real upstream credentials at the network edge.
Design notes live in :mod:`agent.proxy_sources.iron_proxy`.
"""
File diff suppressed because it is too large Load Diff
-1
View File
@@ -102,7 +102,6 @@ _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-opus-5", 240),
("claude-sonnet-5", 180),
("claude-sonnet-4.5", 180),
("claude-sonnet-4.6", 180),
+1 -1
View File
@@ -295,7 +295,7 @@ def run_secret_cli(
list(argv),
env=env,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
+2 -2
View File
@@ -200,7 +200,7 @@ def _platform_asset_name() -> str:
res = subprocess.run(
["ldd", "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
@@ -684,7 +684,7 @@ def _run_bws_list(
cmd,
env=env,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
+2 -2
View File
@@ -464,7 +464,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
input=stdin_json,
capture_output=True,
timeout=spec.timeout,
text=True, encoding='utf-8', errors='replace',
text=True,
shell=False,
**_popen_kwargs,
)
@@ -632,7 +632,7 @@ def allowlist_path() -> Path:
def load_allowlist() -> Dict[str, Any]:
"""Return the parsed allowlist, or an empty skeleton if absent."""
try:
raw = json.loads(allowlist_path().read_text(encoding="utf-8"))
raw = json.loads(allowlist_path().read_text())
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {"approvals": []}
if not isinstance(raw, dict):
+2 -2
View File
@@ -453,8 +453,8 @@ def reload_skills() -> Dict[str, Any]:
}
``description`` is the skill's full SKILL.md frontmatter
``description:`` field. Note: the system prompt skill index
truncates this to the first 57 chars; see ``extract_skill_description``.
``description:`` field the same string the system prompt renders
as `` - name: description`` for pre-existing skills.
"""
# Snapshot pre-reload state (name -> description) from the current
# slash-command cache. Using dicts lets the post-rescan diff carry
+1 -1
View File
@@ -74,7 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
["bash", "-c", command],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
+6 -19
View File
@@ -779,31 +779,18 @@ def resolve_skill_config_values(
# ── Description extraction ────────────────────────────────────────────────
SKILL_PROMPT_DESC_LIMIT = 60
def _normalize_skill_description(frontmatter: Dict[str, Any]) -> str:
"""Normalize a skill's description field for comparison/truncation."""
raw_desc = frontmatter.get("description", "")
return str(raw_desc).strip().strip("'\"") if raw_desc else ""
def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
"""Extract a system-prompt-length description from parsed frontmatter."""
desc = _normalize_skill_description(frontmatter)
if not desc:
"""Extract a truncated description from parsed frontmatter."""
raw_desc = frontmatter.get("description", "")
if not raw_desc:
return ""
if len(desc) > SKILL_PROMPT_DESC_LIMIT:
return desc[:SKILL_PROMPT_DESC_LIMIT - 3] + "..."
desc = str(raw_desc).strip().strip("'\"")
if len(desc) > 60:
return desc[:57] + "..."
return desc
def is_skill_description_truncated_for_prompt(frontmatter: Dict[str, Any]) -> bool:
"""True when the description will be truncated in the system prompt skill index."""
desc = _normalize_skill_description(frontmatter)
return len(desc) > SKILL_PROMPT_DESC_LIMIT
# ── File iteration ────────────────────────────────────────────────────────
+1 -2
View File
@@ -31,8 +31,7 @@ def _skip_ssl_guard_enabled() -> bool:
def _repair_hint() -> str:
return (
"Repair: run `hermes doctor --fix` (auto-reinstalls certifi), or "
"manually: python -m pip install --force-reinstall certifi openai httpx\n"
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
"If you configured a custom corporate CA bundle, fix or unset the "
"broken CA bundle environment variable."
)
+23 -42
View File
@@ -12,11 +12,9 @@ Three tiers are joined with ``\\n\\n``:
* ``stable`` identity (SOUL.md or DEFAULT_AGENT_IDENTITY), tool
guidance, computer-use guidance, nous subscription block, tool-use
enforcement guidance + per-model operational guidance, skills prompt,
alibaba model-name workaround, environment hints, coding guidance,
platform hints.
alibaba model-name workaround, environment hints, platform hints.
* ``context`` caller-supplied ``system_message`` plus context files
(AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``,
plus the session's coding-workspace snapshot.
(AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``.
* ``volatile`` memory snapshot, USER.md profile, external memory
provider block, timestamp/session/model/provider line.
@@ -147,14 +145,14 @@ def _tui_embedded_pane_clarifier(hint: str) -> str:
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered cache tiers.
"""Assemble the system prompt as three ordered parts.
Returns a dict with three keys:
* ``stable`` the cross-session-stable prefix, through the coding
operating brief when a workspace snapshot follows.
* ``context`` the workspace snapshot followed by the remaining
session-stable guidance, context files, and caller-supplied
system_message.
* ``stable`` identity, tool guidance, skills prompt,
environment hints, platform hints, model-family operational
guidance.
* ``context`` context files (AGENTS.md, .cursorrules, etc.)
and caller-supplied system_message.
* ``volatile`` memory snapshot, user profile, external
memory provider block, timestamp line.
@@ -347,35 +345,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
stable_parts.append(_env_hints)
# Coding posture (base Hermes, any interactive coding surface in a code
# workspace — see agent/coding_context.py). Keep the operating brief in
# the cross-session-stable prefix, while placing the live git/workspace
# snapshot behind its own cache boundary. The post-snapshot blocks must
# stay in their historical position after the workspace snapshot.
coding_workspace_parts: List[str] = []
coding_trailing_parts: List[str] = []
# workspace — see agent/coding_context.py). The operating brief + the live
# git/workspace snapshot are built once here and cached for the session;
# the snapshot is never re-probed per turn (that would break the prompt
# cache), so the brief tells the model to re-check git before relying on it.
if agent.valid_tool_names:
try:
from agent.coding_context import coding_system_prompt_parts
from agent.coding_context import coding_system_blocks
coding_prefix_parts, coding_workspace_parts, coding_trailing_parts = coding_system_prompt_parts(
platform=agent.platform,
cwd=resolve_context_cwd(),
model=agent.model,
stable_parts.extend(
coding_system_blocks(
platform=agent.platform,
cwd=resolve_context_cwd(),
model=agent.model,
)
)
stable_parts.extend(coding_prefix_parts)
except Exception:
# Coding-context probing must never block prompt build.
pass
# Guidance assembled after the coding posture historically followed the
# workspace snapshot. With no snapshot, the coding tail instead remains
# directly after the coding prefix in the cacheable prefix.
if coding_workspace_parts:
post_workspace_parts: List[str] = []
else:
stable_parts.extend(coding_trailing_parts)
post_workspace_parts = stable_parts
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
# something is non-default so the model can pick the right install
# strategy without discovering by failure. Emits a single line; emits
@@ -388,7 +376,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
from tools.env_probe import get_environment_probe_line
_probe_line = get_environment_probe_line()
if _probe_line:
post_workspace_parts.append(_probe_line)
stable_parts.append(_probe_line)
except Exception:
# Probe failure must never block prompt build.
pass
@@ -406,7 +394,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
except Exception:
active_profile = "default"
if active_profile == "default":
post_workspace_parts.append(
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
@@ -415,7 +403,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
"you to."
)
else:
post_workspace_parts.append(
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
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/, "
@@ -461,16 +449,11 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
if _effective_hint:
post_workspace_parts.append(_effective_hint)
stable_parts.append(_effective_hint)
# ── Context tier (cwd-dependent, may change between sessions) ─
context_parts: List[str] = []
if coding_workspace_parts:
context_parts.extend(coding_workspace_parts)
context_parts.extend(coding_trailing_parts)
context_parts.extend(post_workspace_parts)
# Note: ephemeral_system_prompt is NOT included here. It's injected at
# API-call time only so it stays out of the cached/stored system prompt.
if system_message is not None:
@@ -558,7 +541,6 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
"""
parts = build_system_prompt_parts(agent, system_message=system_message)
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
agent._cached_system_prompt_static = parts["stable"]
# Surface context-file truncation warnings through the normal agent status
# channel so gateway/CLI users see them in chat instead of only in logs.
@@ -575,7 +557,6 @@ def invalidate_system_prompt(agent: Any) -> None:
so the rebuilt prompt captures any writes from this session.
"""
agent._cached_system_prompt = None
agent._cached_system_prompt_static = None
if agent._memory_store:
agent._memory_store.load_from_disk()
+2 -4
View File
@@ -964,8 +964,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}")
agent._current_tool = None
_status_suffix = " (error)" if is_error else ""
agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s){_status_suffix}")
agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s)")
if not blocked and agent.tool_complete_callback:
try:
@@ -1656,8 +1655,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
logging.debug(f"Tool progress callback error: {cb_err}")
agent._current_tool = None
_status_suffix = " (error)" if _is_error_result else ""
agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s){_status_suffix}")
agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s)")
if agent.verbose_logging:
logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s")
+1 -1
View File
@@ -162,7 +162,7 @@ def build_trace_jsonl(
if cwd:
r = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=cwd,
capture_output=True, text=True, timeout=3, cwd=cwd,
)
if r.returncode == 0:
git_branch = r.stdout.strip()
-51
View File
@@ -7,7 +7,6 @@ streaming, or the _run_codex_stream() call path.
import hashlib
import json
import re
from typing import Any, Dict, List, Optional
from agent.transports.base import ProviderTransport
@@ -28,49 +27,6 @@ def _bounded_prompt_cache_key(value: Any) -> Optional[str]:
return f"pck_{digest}"
_EXTENDED_PROMPT_CACHE_MODELS = (
"gpt-5.5-pro",
"gpt-5.5",
"gpt-5.4",
"gpt-5.2",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.1-chat-latest",
"gpt-5.1-codex",
"gpt-5.1",
"gpt-5-codex",
"gpt-5",
"gpt-4.1",
)
_EXTENDED_PROMPT_CACHE_MODEL_RE = re.compile(
rf"(?:^|[./:])(?:{'|'.join(re.escape(name) for name in _EXTENDED_PROMPT_CACHE_MODELS)})"
r"(?:-\d{4}-\d{2}-\d{2})?$"
)
def _default_prompt_cache_retention_for_request(
model: str,
base_url: Any,
) -> Optional[str]:
"""Return ``24h`` for supported models on Amazon Bedrock Mantle."""
from utils import base_url_hostname
hostname_parts = base_url_hostname(str(base_url or "")).split(".")
is_bedrock_mantle = (
len(hostname_parts) == 4
and hostname_parts[0] == "bedrock-mantle"
and bool(hostname_parts[1])
and hostname_parts[2:] == ["api", "aws"]
)
if not is_bedrock_mantle:
return None
normalized = str(model or "").strip().lower().replace("_", "-")
if _EXTENDED_PROMPT_CACHE_MODEL_RE.search(normalized):
return "24h"
return None
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
"""Content-address the prompt cache key from the static request prefix.
@@ -328,13 +284,6 @@ class ResponsesApiTransport(ProviderTransport):
if not is_github_responses and not is_xai_responses and cache_key:
kwargs["prompt_cache_key"] = cache_key
cache_retention = _default_prompt_cache_retention_for_request(
model,
params.get("base_url"),
)
if cache_retention:
kwargs.setdefault("prompt_cache_retention", cache_retention)
if reasoning_enabled and is_xai_responses:
from agent.model_metadata import grok_supports_reasoning_effort
+1 -6
View File
@@ -127,10 +127,6 @@ class CodexAppServerClient:
# Codex emits tracing to stderr; default WARN keeps it quiet for users.
spawn_env.setdefault("RUST_LOG", "warn")
# Hide the console the codex child would otherwise flash on Windows
# (#56747). Hide-only — stdio pipes stay intact for the app-server wire.
from hermes_cli._subprocess_compat import windows_hide_flags
self._proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
@@ -138,7 +134,6 @@ class CodexAppServerClient:
stderr=subprocess.PIPE,
bufsize=0,
env=spawn_env,
creationflags=windows_hide_flags(),
)
self._next_id = 1
self._pending: dict[int, _Pending] = {}
@@ -394,7 +389,7 @@ def check_codex_binary(
proc = subprocess.run(
[codex_bin, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
+1 -46
View File
@@ -300,8 +300,6 @@ 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
@@ -376,8 +374,6 @@ 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()
@@ -399,33 +395,6 @@ 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(
@@ -500,18 +469,11 @@ 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
# 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
self._interrupt_event.clear()
projector = CodexEventProjector()
user_input_text = _coerce_turn_input_text(user_input)
@@ -543,7 +505,6 @@ 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.
@@ -553,12 +514,9 @@ 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
@@ -783,9 +741,6 @@ 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(
+7 -305
View File
@@ -26,19 +26,11 @@ from __future__ import annotations
import logging
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional
from agent.conversation_compression import (
IDLE_COMPACTION_STATUS_TEMPLATE,
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
compression_skipped_due_to_lock,
conversation_history_after_compression,
recover_rotated_compression_session,
)
from agent.context_engine import automatic_compaction_status_message
from agent.conversation_compression import conversation_history_after_compression
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.model_metadata import (
@@ -263,40 +255,6 @@ 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."""
@@ -354,13 +312,6 @@ def build_turn_context(
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
install_safe_stdio()
# Recover a session rotated by another path before binding log/turn ids or
# copying client-supplied history. Everything in this turn must consistently
# belong to the canonical child, including observability metadata.
recovered_history = recover_rotated_compression_session(agent)
if recovered_history is not None:
conversation_history = recovered_history
# NOTE: the DB session row is created later, AFTER the system prompt is
# restored/built (see _ensure_db_session() below the system-prompt block).
# Creating it here — before _cached_system_prompt is populated — inserts a
@@ -385,7 +336,6 @@ def build_turn_context(
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
requested_provider=getattr(agent, "requested_provider", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
@@ -629,93 +579,12 @@ def build_turn_context(
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",
)
_idle_status = automatic_compaction_status_message(
_compressor,
phase="idle",
default_message=IDLE_COMPACTION_STATUS_TEMPLATE.format(
idle_seconds=int(_idle_gap), tokens=_idle_tokens
),
approx_tokens=_idle_tokens,
idle_seconds=int(_idle_gap),
model=agent.model,
)
if _idle_status:
agent._emit_status(_idle_status)
_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, conversation_history
)
# 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
agent._turn_received_provider_response = False
agent._turn_preflight_display_snapshot = None
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
@@ -728,21 +597,6 @@ def build_turn_context(
tools=agent.tools or None,
)
_compressor = agent.context_compressor
# getattr guard: minimal compressor doubles (SimpleNamespace in the
# engine-preflight tests) and plugin context engines lack this
# ContextCompressor-only method — absence means no snapshot, and the
# finalizer's rollback stays disarmed for the turn (display-only).
_snapshot_fn = getattr(
_compressor, "snapshot_preflight_display_tokens", None
)
if callable(_snapshot_fn):
_snapshot_val = _snapshot_fn()
# Type pin: MagicMock compressors return truthy Mock objects —
# only a real int snapshot may arm the interrupted-turn rollback.
if isinstance(_snapshot_val, int) and not isinstance(
_snapshot_val, bool
):
agent._turn_preflight_display_snapshot = _snapshot_val
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
@@ -776,8 +630,6 @@ def build_turn_context(
lambda: None,
)()
_should_compress_now = False
_compress_block_reason = None
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
@@ -793,42 +645,14 @@ def build_turn_context(
int(_compression_cooldown.get("remaining_seconds", 0.0)),
agent.session_id or "none",
)
if _preflight_tokens >= _compressor.threshold_tokens:
# Context is over threshold but compression is blocked by the
# summary-LLM cooldown — surface a warning (see block below).
_cooldown_secs = _compression_cooldown.get("remaining_seconds", 0.0)
_compress_block_reason = f"cooldown:{_cooldown_secs:.0f}"
elif _codex_native_auto:
logger.info(
"Skipping Hermes preflight compression for codex app-server "
"(mode=%s); Hermes will not start thread compaction here.",
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
else:
_should_compress_now = _compressor.should_compress(_preflight_tokens)
if not _should_compress_now:
# Context is over threshold but compression is blocked
# (summary-LLM cooldown or anti-thrashing). Ask should_compress_info
# for the human-readable reason so we can surface a warning below.
# getattr guard: minimal compressor doubles (SimpleNamespace in
# the engine-preflight tests) and older plugin engines lack the
# method — absence means no block reason, no warning.
_info = getattr(_compressor, "should_compress_info", None)
if callable(_info):
try:
_compress_block_reason = _info(_preflight_tokens)[1]
except Exception:
_compress_block_reason = None
if _should_compress_now:
elif _compressor.should_compress(_preflight_tokens):
_preflight_compressed = True
# Compression is actually running (block cleared / was never
# blocked) — reset the dedup so a future blocked-over-threshold
# turn can warn again. Real session boundary.
# getattr guard: test doubles built via object.__new__ lack the
# method (gateway test-double pitfall) — treat absence as no-op.
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
@@ -836,20 +660,11 @@ def build_turn_context(
agent.model,
f"{_compressor.context_length:,}",
)
_preflight_status = automatic_compaction_status_message(
_compressor,
phase="preflight",
default_message=PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
tokens=_preflight_tokens,
threshold=_compressor.threshold_tokens,
),
approx_tokens=_preflight_tokens,
threshold_tokens=_compressor.threshold_tokens,
context_length=_compressor.context_length,
model=agent.model,
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
if _preflight_status:
agent._emit_status(_preflight_status)
# 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.
@@ -859,29 +674,10 @@ def build_turn_context(
for _pass in range(_max_preflight_passes):
_orig_len = len(messages)
_orig_tokens = _preflight_tokens
_preflight_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if (
messages is _preflight_input
and compression_skipped_due_to_lock(agent)
):
# #69870 lock-skip: another path holds this session's
# compression lock, so the pass no-oped. That is a
# temporary DEFER, not proof the transcript cannot
# compress — do NOT arm the insufficient-progress
# blocker (the loop's error handlers must keep their
# provider-proven retry budget) and stop preflight
# passes for this turn; the lock winner is shrinking
# the same session concurrently.
logger.info(
"Preflight compression deferred: compression lock "
"held by another path (session %s)",
agent.session_id or "none",
)
break
# Re-estimate now so size-only compression (same row count,
# lower token count — e.g. summarising tool outputs) is
# recognised as progress instead of being misread as
@@ -897,7 +693,7 @@ def build_turn_context(
_preflight_compression_blocked = True
break # Cannot compress further: neither rows nor tokens moved
conversation_history = conversation_history_after_compression(
agent, messages, conversation_history
agent, messages
)
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
@@ -919,100 +715,6 @@ def build_turn_context(
f"{_preflight_tokens:,}",
)
break
elif _compress_block_reason:
# Context is already over the compression threshold, but compression
# is blocked (summary LLM cooldown or anti-thrashing). Without a
# signal the session keeps growing until the model silently stops
# answering — the conversation hits the hard provider token limit
# with no explanation. Surface a deduped warning so the user can
# take action (/new or /compress) instead of hitting a silent hang.
agent._warn_context_overflow_blocked(
_compress_block_reason,
_preflight_tokens,
_compressor.threshold_tokens,
)
else:
# Sub-threshold and unblocked — allow the overflow warning to fire
# again next time the context is over threshold but blocked.
# getattr guard: test doubles built via object.__new__ lack the
# method (gateway test-double pitfall) — treat absence as no-op.
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
# Engine maintenance only when NO skip-branch fired: a failure
# cooldown, deferred estimate, or codex-native route must keep
# the engine hook un-consulted (#20316 contract — the cooldown
# exists precisely because compression recently failed).
if _compression_cooldown or _preflight_deferred or _codex_native_auto:
_engine_preflight = None
else:
_engine_preflight = getattr(
_compressor, "should_compress_preflight", None
)
# ── Engine-driven sub-threshold preflight maintenance (#20316) ──
# None of the threshold-path branches fired (not deferred, no
# failure cooldown, not codex-native, and should_compress() said
# the request is under pressure). Context engines that override
# ``should_compress_preflight()`` (e.g. LCM-style incremental
# leaf-chunk compaction) can still request deferred maintenance
# below the token threshold. The default
# ``ContextEngine.should_compress_preflight()`` returns False, so
# the built-in ``ContextCompressor`` path is byte-identical.
#
# Attempt-cap integration: the engine gets exactly ONE
# ``compress()`` pass per turn. It is mutually exclusive with the
# threshold multi-pass loop above (if/elif), so turn-start
# preflight passes stay bounded by the resolved
# ``compression.max_attempts`` cap (floor 1) in every case.
#
# No-op-blocking integration: a sub-threshold engine pass that
# no-ops says nothing about over-threshold compressibility, so it
# must neither set nor clear ``_preflight_compression_blocked``
# (#64382) — and being in the ``else`` arm it can never run after
# the threshold loop has proven a retry ineffective.
# (resolved above, gated on no skip-branch having fired)
_wants_engine_preflight = False
if callable(_engine_preflight):
try:
_wants_engine_preflight = bool(_engine_preflight(messages))
except Exception as _preflight_exc:
# A buggy engine must never break an otherwise-healthy
# turn: swallow at debug level and skip maintenance.
logger.debug(
"should_compress_preflight raised %s; skipping "
"engine-driven preflight maintenance",
_preflight_exc,
)
_wants_engine_preflight = False
if _wants_engine_preflight:
logger.info(
"Engine-driven preflight maintenance: %s requested "
"compress() at ~%s tokens (below %s threshold)",
getattr(_compressor, "name", type(_compressor).__name__),
f"{_preflight_tokens:,}",
f"{getattr(_compressor, 'threshold_tokens', 0):,}",
)
_engine_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
# ``_compress_context`` returns the INPUT list object on every
# skip path (per-session lock held elsewhere, cooldown,
# anti-thrash breaker, codex-native routing) and an engine may
# legitimately no-op. Only re-baseline the flush history and
# re-anchor the user row after a REAL compaction — a skip must
# leave the turn's bookkeeping untouched.
if messages is not _engine_input:
_preflight_compressed = True
conversation_history = conversation_history_after_compression(
agent, messages
)
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
if _preflight_compressed:
# Compression rebuilt the list (tail messages are fresh compaction
-61
View File
@@ -201,36 +201,6 @@ def finalize_turn(
)
)
# Preflight can seed the display count before the provider receives the
# request. Roll that estimate back only when an interrupt wins the race
# before any successful provider response. Compaction state remains owned
# by the real-usage/post-compaction path, including its ``-1`` sentinel.
# Guard rules (test-double density on this path is high):
# - snapshot is type-pinned to a real int — MagicMock agents auto-create
# truthy Mock attributes that must never arm the rollback;
# - the received-response flag is pinned to ``is not True`` — its real
# domain is True/False, and only a literal True means a provider
# response completed;
# - the compressor method gets a getattr+callable guard — SimpleNamespace
# compressor doubles and plugin context engines lack it.
_preflight_snapshot = getattr(
agent, "_turn_preflight_display_snapshot", None
)
if (
interrupted is True
and isinstance(_preflight_snapshot, int)
and not isinstance(_preflight_snapshot, bool)
and getattr(agent, "_turn_received_provider_response", False) is not True
and getattr(agent, "context_compressor", None) is not None
):
_rollback_fn = getattr(
agent.context_compressor,
"rollback_interrupted_preflight_display_tokens",
None,
)
if callable(_rollback_fn):
_rollback_fn(_preflight_snapshot)
# Post-loop cleanup must never lose the response. Trajectory save,
# resource teardown, and session persistence all touch fallible
# surfaces — file I/O / JSON serialization (_save_trajectory), remote
@@ -525,34 +495,6 @@ def finalize_turn(
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
# Context engine observation hook: notify the active engine that this
# turn has finished, with the finalized transcript. Complements the
# per-request select_context() hook (selection before the request;
# observation after the turn). No-op default, fail-open.
try:
from agent.conversation_loop import _notify_context_engine_turn_complete
# Forward the turn's canonical usage when the host has it. The loop
# stashes the most recent API response's usage dict (the same
# canonical buckets fed to ``update_from_response``) on the agent as
# ``_last_turn_usage``. It is ``None`` on turns that never reached a
# provider response (early failure / interrupt), which is exactly the
# contract: real usage when available, ``None`` otherwise.
_turn_usage = getattr(agent, "_last_turn_usage", None)
_notify_context_engine_turn_complete(
agent,
messages,
usage=_turn_usage,
logger=logger,
turn_id=turn_id,
task_id=effective_task_id,
api_call_count=api_call_count,
interrupted=interrupted,
failed=failed,
turn_exit_reason=_turn_exit_reason,
)
except Exception as exc:
logger.warning("on_turn_complete notification failed: %s", exc)
# Extract reasoning from the CURRENT turn only. Walk backwards
# but stop at the user message that started this turn — anything
# earlier is from a prior turn and must not leak into the reasoning
@@ -683,7 +625,4 @@ def finalize_turn(
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
agent._turn_preflight_display_snapshot = None
agent._turn_received_provider_response = False
return result
-4
View File
@@ -73,10 +73,6 @@ 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.
+7 -35
View File
@@ -13,11 +13,10 @@ import shlex
import sqlite3
import tempfile
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator, Optional
from typing import Any, Optional
from hermes_constants import get_hermes_home
@@ -61,43 +60,16 @@ def _db_path() -> Path:
def _connect() -> sqlite3.Connection:
from hermes_state import apply_wal_with_fallback
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.row_factory = sqlite3.Row
try:
apply_wal_with_fallback(conn, db_label="verification_evidence.db")
conn.execute("PRAGMA busy_timeout=5000")
_ensure_schema(conn)
except Exception:
# A PRAGMA/DDL failure after a successful connect() must not leak the
# just-opened connection back to the caller.
conn.close()
raise
_ensure_schema(conn)
return conn
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, and ALWAYS close it.
``sqlite3.Connection.__enter__``/``__exit__`` only commit or roll back the
transaction; they do not close the connection. Using ``with _connect()``
alone therefore leaks a connection and its WAL/SHM file descriptors on
every call, deferring the close to the garbage collector, which over a
long-running process can exhaust ``RLIMIT_NOFILE`` (the cron-ledger sibling
of this bug was #69567 / PR #69594).
"""
conn = _connect()
try:
with conn:
yield conn
finally:
conn.close()
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
@@ -480,7 +452,7 @@ def record_terminal_result(
created_at = _utc_now()
with _DB_LOCK:
with _transaction() as conn:
with _connect() as conn:
cur = conn.execute(
"""
INSERT INTO verification_events(
@@ -546,7 +518,7 @@ def mark_workspace_edited(
edited_at = _utc_now()
with _DB_LOCK:
with _transaction() as conn:
with _connect() as conn:
row = conn.execute(
"""
SELECT changed_paths_json FROM verification_state
@@ -596,7 +568,7 @@ def verification_status(
sid = str(session_id or "default")
root = str(facts.get("root") or Path(cwd or ".").resolve())
with _DB_LOCK:
with _transaction() as conn:
with _connect() as conn:
state = conn.execute(
"""
SELECT last_event_id, last_edit_at, changed_paths_json
+1 -1
View File
@@ -13,7 +13,7 @@
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit",
"check": "npm run typecheck && npm run lint",
"check": "npm run typecheck",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fix": "npm run lint:fix"
+1 -14
View File
@@ -30,7 +30,7 @@ Already have the Hermes CLI? Just run:
hermes desktop
```
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model.
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.
### Prebuilt installers
@@ -134,19 +134,6 @@ Desktop supports a managed local backend, explicit remote gateways, and Hermes
Cloud connections. Remote and cloud modes use the same remote-capability path;
authentication and discovery differ, not the renderer feature model.
When no usable local runtime or saved remote connection exists, the first-run
screen offers **Connect to existing Hermes** before starting the local installer.
Desktop probes the gateway to discover token or OAuth authentication, requires a
successful HTTP and WebSocket connection test, and saves the connection using
the same encrypted Desktop configuration used by Settings. A saved remote
connection bypasses this choice on later launches. The regular Desktop build
still includes the local-install option; this is a remote operating mode, not a
separate client-only application.
In remote mode the gateway host is the execution boundary: agent tools,
terminal commands, and file operations run against the remote Hermes host, not
the computer displaying the Desktop UI.
Projects are the workspace abstraction. A project may own multiple folders,
repositories, worktrees, and sessions; a bare new chat remains detached unless
the user enters a project or configures a default project directory. Use the
+8 -55
View File
@@ -8,10 +8,13 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import { test } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
@@ -58,7 +61,7 @@ test.describe('chat interaction with mock backend', () => {
return (body.textContent ?? '').includes('Hello, can you hear me?')
},
undefined,
{ timeout: 15_000 }
{ timeout: 15_000 },
)
// Wait for the mock response to appear. The canned reply is:
@@ -78,61 +81,11 @@ test.describe('chat interaction with mock backend', () => {
return text.includes('mock inference server') || text.includes('boot chain is working')
},
undefined,
{ timeout: 60_000 }
{ 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') })
})
})
@@ -1,210 +0,0 @@
/**
* 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)
})
})
+7 -47
View File
@@ -27,7 +27,7 @@ import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer, type MockServerOptions } from './mock-server'
import { startMockServer } from './mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
@@ -140,30 +140,15 @@ export function createSandbox(prefix: string): Sandbox {
* 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 extraDisplayConfig optional YAML lines appended to the `display:`
* section, used by the interim-message e2e test.
* @param extraConfig optional top-level YAML sections for a test scenario.
* @param modelContextLength optional primary-model context limit.
*/
export function writeMockProviderConfig(
hermesHome: string,
mockUrl: string,
extraDisplayConfig?: string,
extraConfig?: string,
modelContextLength?: number,
): void {
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const displaySection = extraDisplayConfig
? `\ndisplay:\n${extraDisplayConfig}\n`
: ''
const config = `# Auto-generated by E2E test fixtures
model:
default: mock-model
provider: mock
${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers:
providers:
mock:
api: ${mockUrl}/v1
name: Mock
@@ -172,7 +157,7 @@ ${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}provider
models:
mock-model: {}
context_length: 4096
${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}`
`
fs.writeFileSync(configPath, config, 'utf8')
}
@@ -333,25 +318,11 @@ export async function launchDesktop(
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
/** Additional top-level config.yaml sections for an E2E scenario. */
extraConfig?: string
/** Override the mock model's context window for compression scenarios. */
modelContextLength?: number
}
/**
* Set up a full mock-backend E2E environment:
* 1. Start the mock inference server
@@ -359,23 +330,13 @@ export interface MockBackendOptions {
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export interface MockBackendOptions {
mockServer?: MockServerOptions
}
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
export async function setupMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer(options.mockServer)
const mock = await startMockServer()
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(
sandbox.hermesHome,
mock.url,
options.extraDisplayConfig,
options.extraConfig,
options.modelContextLength,
)
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
@@ -385,7 +346,6 @@ export async function setupMockBackend(options: MockBackendOptions = {}): Promis
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
@@ -1,147 +0,0 @@
/**
* E2E regression: desktop resume must hide agent-only transcript rows.
*
* Compaction handoffs are active user rows because the model needs them for
* context continuity. They are not authored chat content, so the desktop
* transcript must never display them after a real compressor-generated resume.
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { expect, test } from './test'
import {
type MockBackendFixture,
buildAppEnv,
createSandbox,
launchDesktop,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import {
MOCK_REPLY,
startMockServer,
VERIFICATION_STOP_TEXT,
VERIFICATION_STOP_TRIGGER,
} from './mock-server'
import { RealSessionBuilder } from './real-session-builder'
const SESSION_TITLE = 'E2E Hidden History Messages'
const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY'
const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY'
const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600)
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
const mock = await startMockServer()
const sandbox = createSandbox('hidden-history')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
fs.appendFileSync(
path.join(sandbox.hermesHome, 'config.yaml'),
'\ncompression:\n threshold_tokens: 1\n',
'utf8',
)
writeEnvFile(sandbox.hermesHome)
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({
title: SESSION_TITLE,
turns: [
`${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`,
VISIBLE_POST_COMPACTION_TEXT,
],
})
} finally {
await builder.close()
}
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
test('resume hides real context-compaction handoffs', async ({}, testInfo) => {
const fixture = await setupSeededMockBackend()
try {
const { page } = fixture
await waitForAppReady(fixture, 120_000)
const sessionRow = page
.locator('[data-slot="sidebar"] button')
.filter({ hasText: SESSION_TITLE })
.first()
await sessionRow.click()
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
await expect(transcript).toContainText(VISIBLE_USER_TEXT)
await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT)
await expect(transcript).toContainText(MOCK_REPLY)
await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]')
await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') })
} finally {
await fixture.cleanup()
}
})
test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => {
const sandbox = createSandbox('live-verification-nudge')
const projectRoot = path.join(sandbox.root, 'project')
const changedFile = path.join(projectRoot, 'e2e-verification-target.py')
fs.mkdirSync(projectRoot)
fs.writeFileSync(
path.join(projectRoot, 'pyproject.toml'),
'[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n',
'utf8',
)
const mock = await startMockServer({ verificationWritePath: changedFile })
writeMockProviderConfig(sandbox.hermesHome, mock.url)
fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8')
writeEnvFile(sandbox.hermesHome)
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
const fixture: MockBackendFixture = {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
try {
await waitForAppReady(fixture, 120_000)
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(VERIFICATION_STOP_TRIGGER)
await page.keyboard.press('Enter')
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 })
await expect.poll(
() => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')),
{ timeout: 30_000 },
).toBe(true)
expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true)
await expect(transcript).not.toContainText('[System: You edited code in this turn')
await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') })
} finally {
await fixture.cleanup()
}
})
-215
View File
@@ -1,215 +0,0 @@
/**
* 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)
}
})
})
@@ -1,239 +0,0 @@
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'
import { RealSessionBuilder } from './real-session-builder'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
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'
const HISTORY_TURNS = Array.from(
{ length: 27 },
(_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`,
)
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 builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
} finally {
await builder.close()
}
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)
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
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 add exactly one transcript row',
).toBe(initialMockReplyCount + 1)
})
}
})
+79 -639
View File
@@ -15,282 +15,17 @@
*/
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
/** Pause the first completion whose request JSON contains this text. */
holdFirstCompletionContaining?: string
/** Absolute sandbox path written by the verify-on-stop scripted tool call. */
verificationWritePath?: string
}
export interface MockServer {
port: number
url: string
receivedPrompts: string[]
waitForHeldStream: () => Promise<void>
waitForHeldCompletion: () => Promise<void>
releaseHeldStream: () => void
heldCompletionCount: () => number
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
/** Per-server counter for the verify-on-stop script. */
let _verificationStopIndex = 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
_verificationStopIndex = 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'
/**
* Drives a real code edit followed by two finish attempts. Hermes should add
* its synthetic verify-on-stop continuation after each finish attempt until
* the bounded verifier gives up. The mock's request capture proves the nudge
* reached the model; desktop must never render it as chat content.
*/
function verificationStopScript(writePath: string): ScriptedTurn[] {
return [
{
text: 'I will make the requested code change.',
toolCalls: [{
name: 'write_file',
args: {
path: writePath,
content: 'def changed_by_e2e():\n return "changed"\n',
},
}],
},
{ text: 'The code edit is complete.' },
{ text: 'I cannot provide fresh verification evidence for that edit.' },
]
}
export const VERIFICATION_STOP_TRIGGER = 'E2E_VERIFY_ON_STOP_TRIGGER'
export const VERIFICATION_STOP_TEXT = 'I cannot provide fresh verification evidence for that edit.'
/**
* 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
}
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
* @returns a handle with `port`, `url`, and `close()`.
*/
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const receivedPrompts: string[] = []
let resolveHeldStreamStarted: (() => void) | null = null
let releaseHeldStream: (() => void) | null = null
let heldCompletionCount = 0
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.
@@ -340,143 +75,88 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
// 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'
const holdThisCompletion = Boolean(
options.holdFirstCompletionContaining &&
heldCompletionCount === 0 &&
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
)
// 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 isVerificationStopTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(VERIFICATION_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 (isVerificationStopTrigger) {
const script = verificationStopScript(options.verificationWritePath ?? 'e2e-verification-target.py')
const turn = script[_verificationStopIndex] ?? script[script.length - 1]
_verificationStopIndex++
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 || holdThisCompletion ? () => {
if (holdThisCompletion) {
heldCompletionCount++
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// Send the content in a few chunks to simulate streaming.
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
// Final chunk with finish_reason
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
},
],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
resolveHeldStreamStarted?.()
return heldStreamReleased
} : undefined)
} else {
if (holdThisCompletion) {
heldCompletionCount++
resolveHeldStreamStarted?.()
void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY))
} else {
nonStreamingTextResponse(res, model, MOCK_REPLY)
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: { content: word },
finish_reason: null,
},
],
})}\n\n`,
)
i++
// Small delay between chunks to simulate real streaming.
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
// Non-streaming response
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: CANNED_REPLY },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
)
}
})
@@ -507,11 +187,6 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
resolve({
port,
url,
receivedPrompts,
waitForHeldStream: () => heldStreamStarted,
waitForHeldCompletion: () => heldStreamStarted,
releaseHeldStream: () => releaseHeldStream?.(),
heldCompletionCount: () => heldCompletionCount,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
@@ -526,238 +201,3 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
})
})
}
// ─── 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
@@ -1,119 +0,0 @@
/**
* 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()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function queue(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Control+Enter')
}
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!
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await queue(page, QUEUED_PROMPT)
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])
})
})
-226
View File
@@ -1,226 +0,0 @@
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
import * as path from 'node:path'
import { createInterface } from 'node:readline'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const DEFAULT_TIMEOUT_MS = 60_000
interface JsonRpcError {
code?: number
message?: string
}
interface JsonRpcFrame {
error?: JsonRpcError
id?: number
method?: string
params?: {
payload?: unknown
session_id?: string
type?: string
}
result?: unknown
}
interface CreatedSession {
session_id: string
stored_session_id: string
}
export interface RealSessionSpec {
/** Human-visible sidebar title, persisted by the first completed turn. */
title: string
/** Each item becomes one real user prompt followed by the mock provider's reply. */
turns: readonly string[]
}
export interface RealSession {
/** Runtime-only TUI session id, valid only while the builder process is alive. */
runtimeId: string
/** Durable SessionDB id that desktop resumes after the builder exits. */
sessionId: string
}
/**
* Creates durable desktop session history through the real TUI gateway and
* AIAgent loop, using the E2E mock provider configured in `hermesHome`.
*
* This intentionally uses the shipped stdio JSON-RPC transport instead of
* importing SessionDB or launching Electron. The desktop's WebSocket backend
* dispatches the same `tui_gateway.server` methods.
*/
export class RealSessionBuilder {
private readonly child: ChildProcessWithoutNullStreams
private nextRequestId = 0
private readonly pending = new Map<number, { reject: (reason: Error) => void; resolve: (value: unknown) => void }>()
private readonly events: JsonRpcFrame[] = []
private readonly eventWaiters: Array<{
predicate: (frame: JsonRpcFrame) => boolean
reject: (reason: Error) => void
resolve: (frame: JsonRpcFrame) => void
}> = []
private readonly stderr: string[] = []
private closed = false
private constructor(hermesHome: string) {
this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], {
cwd: REPO_ROOT,
env: {
...process.env,
HERMES_HOME: hermesHome,
PYTHONPATH: REPO_ROOT,
},
stdio: 'pipe',
})
createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line))
createInterface({ input: this.child.stderr }).on('line', line => {
this.stderr.push(line)
if (this.stderr.length > 80) this.stderr.shift()
})
this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`)))
this.child.once('exit', (code, signal) => {
if (!this.closed) {
this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`))
}
})
}
static async start(hermesHome: string): Promise<RealSessionBuilder> {
const builder = new RealSessionBuilder(hermesHome)
await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready')
return builder
}
async createSession(spec: RealSessionSpec): Promise<RealSession> {
if (spec.turns.length === 0) {
throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row')
}
const created = await this.request<CreatedSession>('session.create', {
cols: 120,
cwd: REPO_ROOT,
source: 'desktop',
title: spec.title,
})
const runtimeId = requireString(created, 'session_id')
const sessionId = requireString(created, 'stored_session_id')
for (const text of spec.turns) {
const completion = this.waitForEvent(
frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId,
)
await this.request('prompt.submit', { session_id: runtimeId, text })
const frame = await completion
const status = readString(frame.params?.payload, 'status')
if (status !== 'complete') {
throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`)
}
}
await this.request('session.close', { session_id: runtimeId })
return { runtimeId, sessionId }
}
async close(): Promise<void> {
if (this.closed) return
this.closed = true
this.child.stdin.end()
await new Promise<void>(resolve => {
const timeout = setTimeout(() => {
this.child.kill('SIGTERM')
resolve()
}, 5_000)
this.child.once('exit', () => {
clearTimeout(timeout)
resolve()
})
})
}
private request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
const id = ++this.nextRequestId
return this.withTimeout(new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: value => resolve(value as T), reject })
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => {
if (error) {
this.pending.delete(id)
reject(error)
}
})
}), `request ${method}`)
}
private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise<JsonRpcFrame> {
const index = this.events.findIndex(predicate)
if (index >= 0) {
return Promise.resolve(this.events.splice(index, 1)[0])
}
return this.withTimeout(new Promise<JsonRpcFrame>((resolve, reject) => {
this.eventWaiters.push({ predicate, resolve, reject })
}), 'gateway event')
}
private handleLine(line: string): void {
let frame: JsonRpcFrame
try {
frame = JSON.parse(line) as JsonRpcFrame
} catch {
return
}
if (typeof frame.id === 'number') {
const pending = this.pending.get(frame.id)
if (!pending) return
this.pending.delete(frame.id)
if (frame.error) {
pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`))
} else {
pending.resolve(frame.result)
}
return
}
if (frame.method !== 'event') return
const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame))
if (!waiter) {
this.events.push(frame)
return
}
this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1)
waiter.resolve(frame)
}
private withTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timed out after ${DEFAULT_TIMEOUT_MS / 1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)), DEFAULT_TIMEOUT_MS)
promise.then(value => {
clearTimeout(timer)
resolve(value)
}, error => {
clearTimeout(timer)
reject(error)
})
})
}
private failAll(error: Error): void {
for (const pending of this.pending.values()) pending.reject(error)
this.pending.clear()
for (const waiter of this.eventWaiters) waiter.reject(error)
this.eventWaiters.length = 0
}
}
function readString(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') return undefined
const candidate = (value as Record<string, unknown>)[key]
return typeof candidate === 'string' ? candidate : undefined
}
function requireString(value: unknown, key: string): string {
const candidate = readString(value, key)
if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`)
return candidate
}
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Seed a Hermes state.db with a session exported from a real conversation.
Usage: seed_session_db.py <state_db_path> <fixture_json_path>
Creates the database with the full SessionDB schema (if it doesn't exist)
and imports the session from the JSON fixture. Uses the real
SessionDB.import_sessions() so the data shape matches what the desktop
backend expects.
"""
import json
import sys
from pathlib import Path
# Add the repo root to sys.path so we can import hermes_state.
# The script is invoked from apps/desktop/e2e/ — repo root is ../../..
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
def main():
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <state_db_path> <fixture_json_path>", file=sys.stderr)
sys.exit(1)
db_path = Path(sys.argv[1])
fixture_path = Path(sys.argv[2])
db_path.parent.mkdir(parents=True, exist_ok=True)
with open(fixture_path, "r", encoding="utf-8") as f:
session_data = json.load(f)
db = SessionDB(db_path=db_path)
result = db.import_sessions([session_data])
if not result.get("ok"):
print(f"Import failed: {result}", file=sys.stderr)
sys.exit(1)
imported = result.get("imported", 0)
skipped = result.get("skipped", 0)
errors = result.get("errors", [])
if errors:
print(f"Import had errors: {errors}", file=sys.stderr)
sys.exit(1)
print(f"Seeded {imported} session(s), skipped {skipped}{db_path}")
db.close()
if __name__ == "__main__":
main()
@@ -1,151 +0,0 @@
/**
* E2E coverage for session compression, which rotates a live backend session.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
async function send(page: Page, text: string, delay = 15): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay })
await page.keyboard.press('Enter')
}
async function pasteAndSend(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await page.keyboard.insertText(text)
await page.keyboard.press('Enter')
}
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
await page.waitForFunction(
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
text,
{ timeout },
)
}
test.describe('session compression', () => {
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('compresses an existing session and accepts a follow-up turn on its continuation', async () => {
const { page } = fixture
const reply = 'Hello from the mock inference server! The full boot chain is working.'
// Three completed exchanges leave a compressible middle after the
// compressor's protected head/tail boundaries.
await send(page, 'E2E_COMPRESSION_FIRST')
await waitForTranscript(page, reply)
await send(page, 'E2E_COMPRESSION_SECOND')
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1)
await send(page, 'E2E_COMPRESSION_THIRD')
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
// Commit the command before typing its argument. This waits for the async
// completion request on cold CI workers, then uses the composer's own
// keyboard accept path to replace the `/compress` trigger with a command
// chip. Clicking a later completion after typing the argument can insert a
// second command token (for example `//compress ...`) as plain text.
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('/compress', { delay: 15 })
await page.getByText('/compress').first().waitFor({ state: 'visible' })
await page.keyboard.press('Enter')
await composer.type(' preserve the three test turns', { delay: 15 })
await page.keyboard.press('Enter')
await expect
.poll(
() => page.locator('[data-slot="aui_thread-viewport"]').textContent(),
{ timeout: 90_000 },
)
.toMatch(/Compressed|No changes from compression/)
// Compression rotates the agent's live session id. A post-compression
// ordinary turn proves the desktop's runtime binding followed that child.
await send(page, 'E2E_COMPRESSION_FOLLOW_UP')
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1)
await waitForTranscript(page, reply)
await page.screenshot({ path: 'test-results/session-compression-continuation.png' })
})
})
test.describe('session compression in progress', () => {
let fixture: MockBackendFixture
test.beforeAll(async () => {
fixture = await setupMockBackend({
modelContextLength: 64_000,
extraConfig: `compression:
threshold_tokens: 22000
protect_first_n: 0
protect_last_n: 1
auxiliary:
compression:
provider: custom
model: mock-model`,
mockServer: {
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.',
}
})
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => {
const { page } = fixture
const queued = 'E2E_QUEUED_DURING_COMPACTION'
// A normal message crosses the tiny configured context budget. The mock
// blocks only the resulting summary request, so these assertions run
// during automatic compaction rather than a slash-command path.
// The payload must cross threshold_tokens (22k) on its OWN weight
// (~12k tokens) on top of the system prompt. Do not shrink it: at
// repeat(500) the trigger only worked because the ambient system prompt
// (skills index + tool schemas) happened to carry it over the line, and
// a 160-token skills-index cleanup on main broke the test for a day.
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5))
await waitForTranscript(page, MOCK_REPLY)
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5))
await waitForTranscript(page, MOCK_REPLY)
await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(1500))
await fixture.mock.waitForHeldCompletion()
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await expect(primary).toHaveAttribute('aria-label', 'Queue message')
await send(page, queued)
await expect(page.getByText('1 Queued')).toBeVisible()
expect(fixture.mock.heldCompletionCount()).toBe(1)
expect(receivedUserTexts()).not.toContain(queued)
await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') })
fixture.mock.releaseHeldStream()
await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1)
expect(fixture.mock.heldCompletionCount()).toBe(1)
})
})
-252
View File
@@ -1,252 +0,0 @@
/**
* E2E tests for desktop sidebar states background processes, subagents,
* and session dot transitions.
*
* The mock server returns scripted tool_calls that the agent executes for
* real (trivial commands + real subagent delegations). The tests assert the
* sidebar states driven by real gateway events.
*
* 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 { SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, restartMockServer } from './mock-server'
/** Background-running dot aria-label (from i18n en.ts). */
const BG_DOT_LABEL = 'Background task running'
/** Finished-unread dot aria-label. */
const UNREAD_DOT_LABEL = 'Finished — unread'
/** Send a message and wait for the final response to appear. */
async function sendMessageAndWait(
page: Page,
trigger: string,
finalText: string,
timeout = 90_000,
): 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')
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_'),
undefined,
{ timeout: 15_000 },
)
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
finalText,
{ timeout },
)
}
// ────────────────────────────────────────────────────────────────────────
// Test 1: background process + subagent appear in sidebar during turn
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — background process and subagent', () => {
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('background process dot appears and disappears, subagent runs, final answer visible', async () => {
const page = fixture.page
await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText)
// The background process (sleep 1) should have shown a "Background task
// running" dot at some point during the turn. We try to catch it; if
// the process was too fast, that's OK — the real assertion is that the
// final answer appeared and the dot is gone afterward.
try {
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 15_000, message: 'background dot should appear' },
)
.toBeGreaterThan(0)
} catch {
// sleep 1 may have finished before we polled — not a failure.
}
// After the turn completes and auto-dismiss fires, the background dot
// should be gone.
await page.waitForTimeout(8000)
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0)
// Evidence: capture the final state — no background dot, final answer visible.
await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' })
// The final answer text must be in the transcript.
const viewportText = await page
.locator('[data-slot="aui_thread-viewport"]')
.textContent()
expect(viewportText).toContain(SIDEBAR_TEXTS.finalText)
})
})
// ────────────────────────────────────────────────────────────────────────
// Test 2: subagent running shows background dot too (longer bg process)
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — subagent and background dot coexist', () => {
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('background dot visible while subagent runs', async () => {
const page = fixture.page
// Start the turn but DON'T wait for the final answer yet — we want
// to assert the background dot is visible WHILE the subagent runs.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the user's message to appear.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
undefined,
{ timeout: 15_000 },
)
// The background process (sleep 5) should show a "Background task
// running" dot while the subagent is also running.
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should appear while subagent runs' },
)
.toBeGreaterThan(0)
// Evidence: the background dot is visible while the subagent runs.
await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' })
// Now wait for the final answer to appear.
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
SIDEBAR_CROSS_TEXTS.finalText,
{ timeout: 90_000 },
)
// After the turn + auto-dismiss, the background dot should be gone.
await page.waitForTimeout(8000)
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgCount, 'background dot should be gone after process exits').toBe(0)
})
})
// ────────────────────────────────────────────────────────────────────────
// Test 3: cross-session — dot updates when viewing a different session
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — cross-session dot transition', () => {
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('background dot transitions to finished when viewing another session', async () => {
const page = fixture.page
// Start a turn with a long background process (sleep 5).
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the background dot to appear.
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should appear' },
)
.toBeGreaterThan(0)
// Wait for the final answer (turn completes, but bg process still running).
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
SIDEBAR_CROSS_TEXTS.finalText,
{ timeout: 90_000 },
)
// The background dot should still be visible (sleep 5 hasn't finished yet,
// or auto-dismiss hasn't fired).
const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0)
// Evidence: bg dot visible on session A while its turn is done but the
// background process hasn't exited yet.
await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' })
// Create a new session (click "New session" button).
await page.locator('button:has-text("New session")').first().click()
await page.waitForTimeout(2000)
// Now wait for the background process to finish (sleep 5 + auto-dismiss).
// The session A dot should transition away from "background running".
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
)
.toBe(0)
// The original session should show a "finished unread" indicator (green dot)
// since its turn completed while we were in a different session. This is an
// event-driven transition, so wait for it instead of sampling the DOM right
// after the running dot disappears.
await expect
.poll(
() => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'original session should show finished-unread dot' },
)
.toBeGreaterThan(0)
// Evidence: the green "finished unread" dot on the original session after
// switching to a new session — the cross-session dot transition.
await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' })
})
})
-75
View File
@@ -1,75 +0,0 @@
/**
* Regression coverage for #69578: harmless route-token churn during a send
* must not make the desktop silently drop the prompt before prompt.submit.
*/
import { test, expect } from './test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
const PROMPT = 'E2E route token drift must still submit this prompt.'
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('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
const { page, mock } = fixture!
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(PROMPT, { delay: 10 })
// The submit pipeline snapshots the route synchronously, then awaits session
// creation. Keep changing only the query string of whichever chat route is
// current. Before #69578, comparing the raw route token treated this as a
// user chat switch and aborted before prompt.submit.
await page.evaluate(() => {
let revision = 0
const interval = window.setInterval(() => {
const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
}, 1)
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
window.clearInterval(interval)
}
})
try {
await page.keyboard.press('Enter')
await expect
.poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
.toBe(true)
await page.waitForFunction(
prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
PROMPT,
{ timeout: 15_000 },
)
await page.waitForFunction(
() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
undefined,
{ timeout: 60_000 },
)
} finally {
await page.evaluate(() => {
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
})
}
await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
})
-210
View File
@@ -1,210 +0,0 @@
/**
* E2E tests for the tile-unread bug two scenarios:
*
* 1. TAB (stacked, not visible) a session opened as a tab via -click is
* NOT visible on screen. When it finishes, the green "unread" dot IS
* correct the user isn't looking at it. This test PASSES.
*
* 2. SPLIT (side-by-side, visible) a session dragged to the edge of the
* workspace zone opens as a split tile, visible on screen at the same time
* as the main session. When it finishes, it should NOT get the green
* "unread" dot the user is looking right at it. This test FAILS until
* the fix in session-states.ts:174 lands (the unread check only compares
* against $selectedStoredSessionId and ignores $sessionTiles).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { SIDEBAR_CROSS_TEXTS, restartMockServer } from './mock-server'
/** Finished-unread dot aria-label. */
const UNREAD_DOT_LABEL = 'Finished — unread'
/** Background-running dot aria-label. */
const BG_DOT_LABEL = 'Background task running'
/** Locate a session's sidebar row by its preview text. */
function sessionRow(page: import('@playwright/test').Page, text: string) {
return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first()
}
/** Common setup: start a turn with a sleep 5 bg process + subagent, wait for
* the turn to complete, then switch to a new session so the first session is
* no longer $selectedStoredSessionId (required before opening a tile). */
async function startTurnAndSwitchAway(page: import('@playwright/test').Page) {
// Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the user's message to appear.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
undefined,
{ timeout: 15_000 },
)
// Wait for the background dot — confirms the turn is running.
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should appear' },
)
.toBeGreaterThan(0)
// Wait for the turn to complete (final answer visible).
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
SIDEBAR_CROSS_TEXTS.finalText,
{ timeout: 90_000 },
)
// The background dot should still be visible (sleep 5 hasn't finished).
const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0)
// Switch to a new session — session A is no longer $selectedStoredSessionId.
// This is required: openSessionTile bails if the session is already selected.
await page.locator('button:has-text("New session")').first().click()
await page.waitForTimeout(2000)
}
/** Wait for the background process to finish (sleep 5 + auto-dismiss). */
async function waitForBgProcessToFinish(page: import('@playwright/test').Page) {
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
)
.toBe(0)
}
// ────────────────────────────────────────────────────────────────────────
// Test 1: TAB (not visible) — unread dot IS correct (PASSES)
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — tab (hidden) unread is correct', () => {
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('session opened as a tab (not visible) correctly gets unread dot', async () => {
const page = fixture.page
await startTurnAndSwitchAway(page)
// Evidence: session A is in the background (bg dot in sidebar).
await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' })
// ⌃-click opens the session as a TAB (center dock = stacked, not visible
// unless it's the active tab). The session is NOT on screen.
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
await row.click({ modifiers: ['Control'] })
await page.waitForTimeout(2000)
// Evidence: the tab is open but the session is not visible on screen.
await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' })
await waitForBgProcessToFinish(page)
// A tab that's not the active tab IS hidden — the unread dot is correct.
// The user is NOT looking at it, so marking it "unread" is right.
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
expect(unreadCount, 'hidden tab should be marked unread').toBeGreaterThan(0)
await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' })
})
})
// ────────────────────────────────────────────────────────────────────────
// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix)
// ────────────────────────────────────────────────────────────────────────
test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => {
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('session visible in a split tile does NOT get unread dot when it finishes', async () => {
const page = fixture.page
await startTurnAndSwitchAway(page)
// Evidence: session A is in the background (bg dot in sidebar).
await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' })
// Drag the session row from the sidebar to the right edge of the workspace
// zone to create a SPLIT (side-by-side) tile. This triggers the real
// startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path.
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
const rowBox = await row.boundingBox()
expect(rowBox, 'session row must be visible').not.toBeNull()
// Find the workspace zone — the main chat area. We drop on its right edge.
const workspace = page.locator('[data-session-anchor="workspace"]')
const wsBox = await workspace.boundingBox()
expect(wsBox, 'workspace zone must be visible').not.toBeNull()
// Drag from the session row to the right edge of the workspace.
// The drag-session's subZonePosition resolves a right-edge drop as 'right'
// (a split dock), not 'center' (which would be a composer link).
await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2)
await page.mouse.down()
// Move in steps so the drag-session's pointermove handler tracks the
// position and resolves the drop zone (a single jump can miss the
// threshold/engage logic).
const targetX = wsBox!.x + wsBox!.width - 20
const targetY = wsBox!.y + wsBox!.height / 2
const steps = 10
for (let i = 1; i <= steps; i++) {
const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps)
const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps)
await page.mouse.move(x, y)
await page.waitForTimeout(30)
}
await page.mouse.up()
await page.waitForTimeout(2000)
// Evidence: the split tile is now open side-by-side — both sessions visible.
await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' })
await waitForBgProcessToFinish(page)
// THE BUG: the session visible in the split tile should NOT have the green
// "finished unread" dot — the user is looking right at it. This assertion
// FAILS until the fix in session-states.ts:174 lands.
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0)
// Evidence: the green dot should NOT be here — this screenshot shows the bug.
await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' })
})
})
-434
View File
@@ -1,434 +0,0 @@
/**
* E2E regression: warm-route resume must not re-render the transcript more
* than once.
*
* When a session is already in the runtime-id cache (the "warm" path in
* `resumeSession()`), clicking its sidebar row should paint the transcript
* exactly once. Before the fix, the warm cache painted via
* `syncSessionStateToView`, then the `session.activate` RPC returned a
* reconciled message list with different message object references, causing
* `syncSessionStateToView` to fire a second `setMessages` a visual
* flicker as the transcript DOM was updated.
*
* This test pre-seeds a 32-message session into state.db, boots the app,
* clicks the session (cold resume populates the warm cache), navigates
* away to a new chat, then clicks back (warm resume). Two detectors run:
*
* 1. A MutationObserver counts additive DOM mutation bursts (childList
* additions). More than 1 burst = the transcript was repainted.
*
* 2. A 2ms innerHTML-length poll counts "reconciles" DOM content changes
* that happen AFTER the initial paint, while messages are already on
* screen. This catches the case where React reconciles by key without
* adding/removing nodes (same keys in-place prop update no
* MutationObserver burst), but `$messages` was still set twice.
*
* The test passes when bursts === 1 AND reconciles === 0.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import {
type MockBackendFixture,
waitForAppReady,
createSandbox,
writeMockProviderConfig,
writeEnvFile,
buildAppEnv,
launchDesktop,
} from './fixtures'
import { startMockServer } from './mock-server'
import { RealSessionBuilder } from './real-session-builder'
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
/** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */
const MESSAGE_COUNT = 32
/** Seeded PRNG so the generated content is deterministic across runs. */
const RNG_SEED = 42
/** Mulberry32 — tiny deterministic PRNG. */
function mulberry32(seed: number): () => number {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** Generate ~40 chars of gibberish from a seeded PRNG. */
function gibberish(rng: () => number): string {
const len = 30 + Math.floor(rng() * 20)
let s = ''
for (let i = 0; i < len; i++) {
s += String.fromCharCode(97 + Math.floor(rng() * 26))
}
return s
}
/** First user message — used as a wait target in the test. */
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
/**
* Generate the user turns for a real session. The mock provider produces the
* assistant side of each pair through the normal AIAgent persistence path.
*/
function generateSessionTurns(): string[] {
const rng = mulberry32(RNG_SEED)
const turns: string[] = []
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
turns.push(gibberish(rng))
gibberish(rng)
}
return turns
}
/**
* Set up a mock-backend sandbox with a real persisted session in state.db.
*
* Unlike the shared `setupMockBackend()`, this variant creates the session
* through the real stdio gateway before launching desktop so the session is
* visible in the sidebar on first load.
*/
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer()
// 2. Create sandbox + write config
const sandbox = createSandbox('warm-seed')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Produce all 16 user/assistant pairs through the real TUI gateway,
// AIAgent, mock provider, and SessionDB persistence path before desktop starts.
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
try {
await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() })
} finally {
await builder.close()
}
// 4. 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()
},
}
}
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupSeededMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
/**
* Install a MutationObserver + text-content poll on the thread viewport
* to detect re-renders after the initial paint. Returns nothing call
* `readRenderCount` to stop and collect results.
*
* - MutationObserver: counts additive childList bursts (5ms coalescing).
* - Text-content poll: counts "reconciles" first-message text changes
* after the initial paint, catching key-based reconciles that don't
* add/remove nodes.
*/
async function installRenderCounter(page: import('@playwright/test').Page): Promise<void> {
await page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) {
throw new Error('Thread viewport not found before warm resume')
}
const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 }
;(window as unknown as { __RENDER_COUNT__: typeof state }).__RENDER_COUNT__ = state
let currentBatch = 0
let flushTimer: ReturnType<typeof setTimeout> | null = null
const flush = () => {
flushTimer = null
if (currentBatch > 0 && !state.stopped) {
state.bursts += 1
state.timeline.push(currentBatch)
currentBatch = 0
}
}
const observer = new MutationObserver(records => {
if (state.stopped) return
let batchAdded = 0
for (const record of records) {
state.mutations += 1
if (record.type === 'childList' && record.addedNodes.length > 0) {
batchAdded += 1
}
}
if (batchAdded > 0) {
currentBatch += batchAdded
if (flushTimer) clearTimeout(flushTimer)
flushTimer = setTimeout(flush, 5)
}
})
observer.observe(viewport, {
childList: true,
subtree: true,
attributes: false,
characterData: false,
})
// Poll the first message's text content every 2ms. The MutationObserver
// only catches childList additions; React may reconcile by key without
// adding/removing nodes (same keys → in-place prop update → no childList
// mutation). The poll catches this by detecting text content changes in
// the first message after the initial paint. Metadata-only changes (model
// name, busy indicator) don't affect message text, so they don't produce
// false positives.
const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport
let lastFirstMsgText = ''
let hasMessages = false
const pollInterval = setInterval(() => {
if (state.stopped) {
clearInterval(pollInterval)
return
}
const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]')
const firstMsgText = firstMsg?.textContent ?? ''
if (firstMsgText && firstMsgText !== lastFirstMsgText) {
if (hasMessages) {
state.reconciles = (state.reconciles ?? 0) + 1
}
lastFirstMsgText = firstMsgText
hasMessages = true
}
}, 2)
})
}
/** Stop the render counter and return the recorded burst/reconcile counts. */
async function readRenderCount(page: import('@playwright/test').Page): Promise<{
bursts: number
mutations: number
timeline: number[]
reconciles: number
} | null> {
return page.evaluate(() => {
type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number }
const w = window as unknown as { __RENDER_COUNT__?: RenderCount }
const rc = w.__RENDER_COUNT__
if (rc) {
rc.stopped = true
}
return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null
})
}
/** Assert the render counter shows exactly one paint with no re-renders. */
function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
expect(
result!.bursts,
`Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` +
`Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
).toBe(1)
expect(
result!.reconciles,
`Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` +
`This means the warm-route resume re-rendered the transcript after the initial paint ` +
`— the "warm resume jitter" bug is present.`,
).toBe(0)
}
test('warm-route resume paints transcript exactly once (no jitter)', async ({}, testInfo) => {
const page = fixture!.page
// Wait for the sidebar to populate with our seeded session.
const sessionRow = page
.locator('[data-slot="sidebar"] button')
.filter({ hasText: SESSION_TITLE })
.first()
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
// Step 1: Cold resume — click the session row to load it.
// This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId).
await sessionRow.click()
// Wait for the transcript to appear — the first user message text confirms
// the cold-path prefetch painted.
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
// Wait for the session to fully settle (cold-path RPC + reconciliation).
await page.waitForTimeout(2_000)
// Step 2: Navigate away to a new chat — this does NOT evict the warm cache.
const newSessionButton = page
.locator('[data-slot="sidebar"] button[aria-label="New session"]')
.first()
await newSessionButton.click()
// Wait for the new-chat empty state.
await page.waitForFunction(
(firstMsg: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return false
const text = viewport.textContent ?? ''
return !text.includes(firstMsg)
},
FIRST_USER_MSG,
{ timeout: 15_000 },
)
await page.waitForTimeout(500)
// Step 3: Install render counter, click back (warm resume), wait, assert.
await installRenderCounter(page)
await sessionRow.click()
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
// Wait for at least 1 burst, then settle.
await page.waitForFunction(
() => {
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
},
undefined,
{ timeout: 10_000 },
)
await page.waitForTimeout(2_000)
const result = await readRenderCount(page)
await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') })
assertNoJitter(result)
})
test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => {
test.fixme(
true,
'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).',
)
const page = fixture!.page
const { mock } = fixture!
// Wait for the sidebar to populate with our seeded session.
const sessionRow = page
.locator('[data-slot="sidebar"] button')
.filter({ hasText: SESSION_TITLE })
.first()
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
// Step 1: Cold resume — populate the warm cache.
await sessionRow.click()
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
await page.waitForTimeout(2_000)
// Step 2: Send a message — triggers inference via the mock server.
const PROMPT = 'E2E post-inference warm resume test prompt'
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(PROMPT, { delay: 10 })
await page.keyboard.press('Enter')
// Wait for the mock response to appear in the transcript, confirming
// the turn completed and message.complete fired (which updates the warm
// cache via updateSessionState).
await page.waitForFunction(
() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
return viewport?.textContent?.includes('mock inference server') ?? false
},
undefined,
{ timeout: 60_000 },
)
// Extra settle for message.complete → updateSessionState → cache write.
await page.waitForTimeout(2_000)
// Verify the prompt was received by the mock server.
expect(mock.receivedPrompts).toContain(PROMPT)
// Step 3: Navigate away — the warm cache retains the updated messages.
const newSessionButton = page
.locator('[data-slot="sidebar"] button[aria-label="New session"]')
.first()
await newSessionButton.click()
await page.waitForFunction(
(prompt: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return false
return !(viewport.textContent ?? '').includes(prompt)
},
PROMPT,
{ timeout: 15_000 },
)
await page.waitForTimeout(500)
// Step 4: Install render counter, click back (warm resume), wait, assert.
await installRenderCounter(page)
await sessionRow.click()
// Wait for the transcript to reappear — the warm cache should already
// have the completed turn (updated by message.complete events).
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
// Wait for at least 1 burst, then settle.
await page.waitForFunction(
() => {
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
},
undefined,
{ timeout: 10_000 },
)
await page.waitForTimeout(2_000)
const result = await readRenderCount(page)
await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') })
assertNoJitter(result)
})
@@ -1,94 +0,0 @@
import { execFileSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { test, expect } from './test'
import {
buildAppEnv,
createSandbox,
launchDesktop,
writeEnvFile,
writeMockProviderConfig,
type MockBackendFixture,
waitForAppReady,
} from './fixtures'
import { startMockServer } from './mock-server'
const BRANCH_NAME = 'e2e-composer-branch'
function createGitRepo(root: string): string {
const repo = path.join(root, 'repo')
fs.mkdirSync(repo, { recursive: true })
execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo })
execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo })
execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo })
fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8')
execFileSync('git', ['add', 'README.md'], { cwd: repo })
execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo })
return repo
}
function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void {
writeMockProviderConfig(hermesHome, mockUrl)
fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8')
writeEnvFile(hermesHome)
}
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
const sandbox = createSandbox('worktree-branch-status')
const repo = createGitRepo(sandbox.root)
const mock = await startMockServer()
configureRepoCwd(sandbox.hermesHome, mock.url, repo)
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
fixture = {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('creating a branch with ctrl-shift-b updates the composer git-status branch', async ({}, testInfo) => {
const page = fixture!.page
const codingRow = page.locator('.coding-status-bar')
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('create a repo-backed e2e session', { delay: 2 })
await page.keyboard.press('Enter')
await page.waitForFunction(
prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt),
'create a repo-backed e2e session',
{ timeout: 15_000 },
)
await expect(codingRow).toContainText('main')
await page.keyboard.press('Control+Shift+B')
const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
await expect(branchInput).toBeVisible()
await branchInput.fill(BRANCH_NAME)
await page.getByRole('button', { name: 'New worktree' }).click()
await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 })
await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') })
})
-20
View File
@@ -68,26 +68,6 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () =
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly', () => {
const defaulted = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
currentEnv: { PATH: '/usr/bin' },
platform: 'darwin',
pathModule: path.posix
})
assert.equal(defaulted.PYTHONUTF8, '1')
const optedOut = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
currentEnv: { PATH: '/usr/bin', PYTHONUTF8: '0' },
platform: 'darwin',
pathModule: path.posix
})
assert.equal(optedOut.PYTHONUTF8, '0')
})
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),

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