Compare commits

..
Author SHA1 Message Date
ethernet 0a8d4da69a WIPipw wipwip 2026-06-26 22:07:54 -04:00
ethernet 4dce531189 wip thin client 2026-06-26 19:30:29 -04:00
3420 changed files with 59919 additions and 512253 deletions
+1 -5
View File
@@ -66,12 +66,8 @@ runtime/
# ---------- Not needed inside the Docker image ----------
# Desktop app source (Tauri/Electron); never installed in the container.
# apps/shared is the dashboard↔desktop websocket helper and is linked from
# web/package.json as a file: workspace dep — keep it in the build context.
# Desktop app source (Tauri/Electron); never installed in the container
apps/
!apps/shared/
!apps/shared/**
# Test suite — not shipped in production images
tests/
-20
View File
@@ -1,13 +1,6 @@
# Hermes Agent Environment Configuration
# Copy this file to .env and fill in your API keys
# =============================================================================
# LLM PROVIDER (Fireworks AI)
# =============================================================================
# Get your key at: https://app.fireworks.ai/settings/users/api-keys
# Address models directly by catalog ID, e.g.
# accounts/fireworks/models/kimi-k2p6, accounts/fireworks/models/glm-5p2
# FIREWORKS_API_KEY=
# =============================================================================
# LLM PROVIDER (OpenRouter)
# =============================================================================
@@ -115,10 +108,6 @@
# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
# DeepInfra — 100+ top open models, pay-per-use.
# Get your key at: https://deepinfra.com/dash/api_keys
# DEEPINFRA_API_KEY=
# =============================================================================
# LLM PROVIDER (Qwen OAuth)
# =============================================================================
@@ -136,15 +125,6 @@
# Optional base URL override:
# XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1
# =============================================================================
# LLM PROVIDER (Upstage Solar)
# =============================================================================
# Upstage provides access to Upstage Solar models.
# Get your key at: https://console.upstage.ai/api-keys
# UPSTAGE_API_KEY=your_key_here
# Optional base URL override:
# UPSTAGE_BASE_URL=https://api.upstage.ai/v1
# =============================================================================
# TOOL API KEYS
# =============================================================================
+2 -2
View File
@@ -1,5 +1,5 @@
watch_file pyproject.toml uv.lock hermes
watch_file pyproject.toml uv.lock
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
use flake
+6 -46
View File
@@ -5,18 +5,12 @@ description: >-
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
events and fail open (everything "true") when the diff cannot be computed.
inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
required: false
default: ${{ github.token }}
outputs:
python:
description: Run Python tests / ruff / ty / windows-footguns.
value: ${{ steps.classify.outputs.python }}
frontend:
description: Run the TypeScript testing matrix + desktop build.
description: Run the TypeScript typecheck matrix + desktop build.
value: ${{ steps.classify.outputs.frontend }}
docker_meta:
description: Docker setup and meta files have changed.
@@ -30,15 +24,9 @@ outputs:
deps:
description: Check pyproject.toml dependency upper bounds.
value: ${{ steps.classify.outputs.deps }}
npm_lock:
description: Post/update the semantic package-lock.json diff PR comment.
value: ${{ steps.classify.outputs.npm_lock }}
mcp_catalog:
description: Require MCP catalog security review label.
value: ${{ steps.classify.outputs.mcp_catalog }}
ci_review:
description: Require CI-sensitive file review label.
value: ${{ steps.classify.outputs.ci_review }}
runs:
using: composite
@@ -47,12 +35,7 @@ runs:
id: classify
shell: bash
env:
# Fall back to the built-in read-only token when the caller passes an
# empty value. Fork PRs get no repo secrets, so AUTOFIX_BOT_PAT is ""
# there, and an input `default:` only applies when the input is omitted,
# not when it's passed empty. Without this fallback the compare API
# fails on forks and the classifier fails open (every lane forced on).
GH_TOKEN: ${{ inputs.github-token || github.token }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -68,33 +51,10 @@ runs:
# event payload instead of the "current PR files" endpoint. The SHAs
# are frozen at trigger time, so the file list is deterministic even
# if the PR receives a new push between trigger and detect.
#
# Retried: a rate-limit blip or eventual-consistency 404 on a
# freshly-pushed HEAD would otherwise silently fall open (all lanes
# run — safe, but wasteful and it masks the API failure).
#
# `.files[]?` (null-safe): with --paginate, a PR more than 100
# commits ahead of its merge-base paginates the compare, and pages
# after the first carry `files: null` — bare `.files[]` makes jq
# die with "cannot iterate over: null", which fails every retry
# and forces the fail-open path (seen on stacked PRs). The full
# file list (up to the API's 300-file cap) is on page one.
CHANGED=""
for i in 1 2 3; do
if CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[]?.filename')"; then
break
fi
if [ "$i" = 3 ]; then
echo "::warning::compare API failed after 3 attempts — failing open (all lanes run)"
CHANGED=""
break
fi
echo "::warning::compare API failed (attempt $i); retrying in 10s"
sleep 10
done
CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[].filename' || true)"
fi
echo "Changed files:"
-59
View File
@@ -1,59 +0,0 @@
name: Get GitHub App Token
description: >-
Mint a short-lived (1-hour) installation access token from the repo's
GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
and are scoped to the App's installation permissions, not a user account.
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 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 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: ''
outputs:
token:
description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks.
value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }}
runs:
using: composite
steps:
- name: Check if App credentials exist
id: check
shell: bash
env:
CLIENT_ID: ${{ inputs.client-id }}
run: |
if [ -n "$CLIENT_ID" ]; then
echo "has_app=true" >> "$GITHUB_OUTPUT"
else
echo "has_app=false" >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub App token
id: app-token
if: steps.check.outputs.has_app == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ inputs.client-id }}
private-key: ${{ inputs.private-key }}
- name: Fall back to GITHUB_TOKEN
id: fallback
if: steps.check.outputs.has_app != 'true'
shell: bash
run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
@@ -0,0 +1,50 @@
name: Hermes smoke test
description: >
Run the image's built-in entrypoint against `--help` and `dashboard --help`
to catch basic runtime regressions before publishing. Requires the image
to already be loaded into the local Docker daemon under `image`.
Works identically on amd64 and arm64 runners.
inputs:
image:
description: Fully-qualified image tag (e.g. nousresearch/hermes-agent:test)
required: true
runs:
using: composite
steps:
- name: Ensure /tmp/hermes-test is hermes-writable
shell: bash
run: |
# The image runs as the hermes user (UID 10000). GitHub Actions
# creates /tmp/hermes-test root-owned by default, which hermes
# can't write to — chown it to match the in-container UID before
# bind-mounting. Real users doing `docker run -v ~/.hermes:...`
# with their own UID hit the same issue and have their own
# remediations (HERMES_UID env var, or chown locally).
mkdir -p /tmp/hermes-test
sudo chown -R 10000:10000 /tmp/hermes-test
- name: hermes --help
shell: bash
run: |
# Use the image's real ENTRYPOINT (/init + main-wrapper.sh) so
# this exercises the actual production startup path. PR #30136
# review caught that an --entrypoint override here had been
# silently neutered by the s6-overlay migration — stage2-hook
# ignores its CMD args, so the smoke test was a no-op.
docker run --rm \
-v /tmp/hermes-test:/opt/data \
"${{ inputs.image }}" --help
- name: hermes dashboard --help
shell: bash
run: |
# Regression guard for #9153: dashboard was present in source but
# missing from the published image. If this fails, something in
# the Dockerfile is excluding the dashboard subcommand from the
# installed package.
docker run --rm \
-v /tmp/hermes-test:/opt/data \
"${{ inputs.image }}" dashboard --help
+3 -23
View File
@@ -3,8 +3,7 @@ description: >-
Run a shell command, retrying on non-zero exit. For dependency installs
(npm ci, uv sync) whose only failures are transient network/toolchain
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
instead of needing a manual re-run. Can also capture stdout as a step
output for commands whose result must be consumed by later steps.
instead of needing a manual re-run.
inputs:
command:
@@ -20,16 +19,10 @@ inputs:
description: Directory to run in.
default: "."
outputs:
stdout:
description: Captured stdout from the successful attempt (empty if not needed).
value: ${{ steps.retry.outputs.stdout }}
runs:
using: composite
steps:
- id: retry
shell: bash
- shell: bash
working-directory: ${{ inputs.working-directory }}
# command goes through env, never interpolated into the script body, so
# a command with quotes/specials can't break or inject into the runner.
@@ -39,25 +32,12 @@ runs:
_DELAY: ${{ inputs.delay }}
run: |
set -uo pipefail
_OUTFILE="$(mktemp)"
trap 'rm -f "$_OUTFILE"' EXIT
n=0
while :; do
n=$((n + 1))
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
# Run the command, capturing stdout to a temp file while still
# streaming to the log. We redirect first, then tee the file to
# stdout — this avoids pipefail + tee exit-code interactions that
# can cause the if-branch to be skipped under set -e.
if bash -c "$_CMD" > "$_OUTFILE"; then
cat "$_OUTFILE"
if bash -c "$_CMD"; then
echo "::endgroup::"
# Preserve newlines in the output via heredoc delimiter.
{
echo 'stdout<<__RETRY_STDOUT_EOF__'
cat "$_OUTFILE"
echo '__RETRY_STDOUT_EOF__'
} >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::endgroup::"
+11 -265
View File
@@ -17,10 +17,9 @@ on:
permissions:
contents: read
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
actions: read # needed by osv-scanner (SARIF upload)
security-events: write # needed by osv-scanner (SARIF upload)
packages: write # needed by docker build
concurrency:
group: ci-${{ github.ref }}
@@ -33,219 +32,80 @@ jobs:
# (all lanes true) so post-merge validation is never weakened.
# ─────────────────────────────────────────────────────────────────────
detect:
name: Detect affected areas
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
python: ${{ steps.classify.outputs.python }}
frontend: ${{ steps.classify.outputs.frontend }}
site: ${{ steps.classify.outputs.site }}
scan: ${{ steps.classify.outputs.scan }}
deps: ${{ steps.classify.outputs.deps }}
npm_lock: ${{ steps.classify.outputs.npm_lock }}
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
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:
# 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.
# Skipped workflows (if condition is false) don't spin up runners.
# ─────────────────────────────────────────────────────────────────────
tests:
name: Python tests
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
secrets: inherit
js-tests:
name: JS & TS checks
typecheck:
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
secrets: inherit
e2e-desktop:
name: Desktop E2E
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/e2e-desktop.yml
uses: ./.github/workflows/typecheck.yml
docs-site:
name: Docs Site
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
secrets: inherit
history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
secrets: inherit
contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
secrets: inherit
uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
secrets: inherit
lockfile-diff:
name: package-lock.json diff
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
uses: ./.github/workflows/lockfile-diff.yml
secrets: inherit
docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
secrets: inherit
docker:
name: Build&Test Docker image
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker.yml
secrets: inherit
supply-chain:
name: Supply-chain scan
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true')
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
uses: ./.github/workflows/supply-chain-audit.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
scan: ${{ needs.detect.outputs.scan == 'true' }}
deps: ${{ needs.detect.outputs.deps == 'true' }}
review-labels:
name: Review label gate
needs: [detect, supply-chain]
if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true')
uses: ./.github/workflows/review-labels.yml
with:
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
secrets: inherit
osv-scanner:
name: OSV scan
needs: detect
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Live-updating PR review comment.
#
# A single ``comment-live`` job polls the GitHub Actions API every 15s
# for job statuses in this run, re-assembles the review comment from
# whatever results are available, and upserts it via the
# ``<!-- hermes-ci-review-bot -->`` marker.
#
# The poller exits when all non-infra jobs are completed (or on
# timeout). ci-timings' review_status is picked up automatically when
# its artifact becomes available — the poller downloads and merges it.
# ─────────────────────────────────────────────────────────────────────
comment-live:
name: CI review comment (live)
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
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
- name: Run live comment poller
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_RUN_ID: ${{ github.run_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Commit info for the review comment header.
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
# Structured review statuses from workflow_call jobs.
# Each job outputs a JSON array of {source, results: [...]} objects
# that the assembler renders directly — no hardcoded job-name
# matching. We merge all available outputs into one array.
REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }}
run: |
set -uo pipefail
# REVIEW_STATUSES is a JSON array of strings (some may be empty
# when a job was skipped). Parse each string and merge into one
# flat array for the assembler.
python3 - <<'PYEOF'
import json, os, sys
raw = os.environ.get("REVIEW_STATUSES", "")
merged = []
if raw:
try:
arr = json.loads(raw)
except (json.JSONDecodeError, TypeError):
arr = []
for item in arr:
if not item:
continue
try:
statuses = json.loads(item)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(statuses, list):
merged.extend(statuses)
# Write merged array to a temp file the poller reads.
with open("/tmp/review_statuses.json", "w") as f:
json.dump(merged, f)
print(f"Merged {len(merged)} review status entries")
PYEOF
python3 scripts/ci/live_comment.py \
--interval 15 \
--timeout 2100 \
--review-statuses-file /tmp/review_statuses.json
# ─────────────────────────────────────────────────────────────────────
# Gate: runs after everything. ``if: always()`` ensures it reports a
@@ -253,147 +113,33 @@ jobs:
# results cause it to fail; ``skipped`` is treated as success.
#
# Branch protection should require ONLY this check.
#
# Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so
# the live comment poller can list failed jobs in the PR comment.
# ─────────────────────────────────────────────────────────────────────
all-checks-pass:
name: All required checks pass
needs:
- detect
- tests
- lint
- js-tests
- e2e-desktop
- typecheck
- docs-site
- history-check
- contributor-check
- uv-lockfile
- lockfile-diff
- docker-lint
- supply-chain
- review-labels
- osv-scanner
# comment-live is a polling job — it doesn't block the gate.
# we don't require docker to pass rn because it's so slow lol
# - docker
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
needs-json: ${{ steps.evaluate.outputs.needs-json }}
steps:
- name: Evaluate job results
id: evaluate
env:
NEEDS: ${{ toJSON(needs) }}
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
echo "$NEEDS" | python3 -c "
echo "$RESULTS" | python3 -c "
import json, sys
needs = json.load(sys.stdin)
# Emit compact {job_name: result} for the comment assembler.
compact = {name: info['result'] for name, info in needs.items()}
print(f'needs-json={json.dumps(compact)}')
with open('$GITHUB_OUTPUT', 'a') as f:
f.write(f'needs-json={json.dumps(compact)}\n')
failed = [name for name, info in needs.items() if info['result'] == 'failure']
for name, info in sorted(needs.items()):
result = info['result']
icon = '✅' if result in ('success', 'skipped') else '❌'
print(f'{icon} {name}: {result}')
results = json.load(sys.stdin)
failed = [r for r in results if r == 'failure']
if failed:
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
print(f'::error::{len(failed)} job(s) failed')
sys.exit(1)
print('All checks passed (or were skipped)')
"
# ─────────────────────────────────────────────────────────────────────
# CI timing report: collect per-job/step durations from the GitHub API,
# cache them on main (as a baseline), and on PRs generate an HTML diff
# report with a gantt chart + per-step breakdown. The report is uploaded
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
#
# 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
needs: [all-checks-pass, docker]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- 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
with:
path: ci-timings-baseline.json
# Prefix-match: exact key will never hit (run_id differs), so
# restore-keys finds the most recent baseline from main.
key: ci-timings-baseline-never-exact
restore-keys: |
ci-timings-baseline-
- name: Collect timings and generate report
env:
# 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 \
--review-status-out review-status.json
- name: Upload HTML report + review status
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
with:
name: ci-timings-report
path: |
ci-timings-report.html
review-status.json
retention-days: 14
- name: Output summary
env:
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
run: |
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Save baseline cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
# Degraded runs (API rate-limited) produce no ci-timings.json —
# skip rather than fail, and never cache an empty baseline.
if [ -f ci-timings.json ]; then
cp ci-timings.json ci-timings-baseline.json
else
echo "No timings JSON this run — skipping baseline update"
fi
- name: Upload baseline to cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ci-timings-baseline.json
key: ci-timings-baseline-${{ github.run_id }}
+7 -31
View File
@@ -2,10 +2,6 @@ name: Contributor Attribution Check
on:
workflow_call:
outputs:
review_status:
description: "JSON array of review status objects"
value: ${{ jobs.check-attribution.outputs.review_status }}
permissions:
contents: read
@@ -13,16 +9,12 @@ permissions:
jobs:
check-attribution:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
review_status: ${{ steps.check-emails.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Full history needed for git log
- name: Check for unmapped contributor emails
id: check-emails
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
@@ -32,13 +24,10 @@ jobs:
if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
echo "review_status=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
# An email is mapped if it has a file in contributors/emails/
# (one file per email — conflict-free) or an entry in the frozen
# legacy AUTHOR_MAP in scripts/release.py.
# Check each email against AUTHOR_MAP in release.py
MISSING=""
while IFS= read -r email; do
# Skip teknium and bot emails
@@ -47,12 +36,9 @@ jobs:
continue ;;
esac
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
continue # GitHub id+login noreply emails auto-resolve
fi
if [ -f "contributors/emails/${email}" ]; then
continue # mapped via the contributors directory
continue # GitHub noreply emails auto-resolve
fi
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
@@ -63,29 +49,19 @@ jobs:
if [ -n "$MISSING" ]; then
echo ""
echo "⚠️ New contributor email(s) without a mapping:"
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
echo -e "$MISSING"
echo ""
echo "Add a mapping file (do NOT edit AUTHOR_MAP in release.py):"
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
echo -e "$MISSING" | while read -r line; do
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
[ -z "$email" ] && continue
echo " python3 scripts/add_contributor.py ${email} <github-username>"
echo " \"${email}\": \"<github-username>\","
done
echo ""
echo "To find the GitHub username for an email:"
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
# Emit review_status for unmapped emails
DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //')
HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"<email>": "<github-username>",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n'
REVIEW_STATUS=$(jq -nc \
--arg detail "$DETAIL" \
--arg how_to_fix "$HOW_TO_FIX" \
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
exit 1
else
echo "✅ All contributor emails are mapped."
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
fi
+6 -19
View File
@@ -41,28 +41,19 @@ jobs:
# doesn't auto-deploy via the deploy-docs path.
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Trigger Vercel Deploy
run: curl -fsS --retry 3 --retry-delay 10 -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
deploy-docs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
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 }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@@ -74,14 +65,12 @@ jobs:
python-version: '3.11'
- name: Install PyYAML for skill extraction
uses: ./.github/actions/retry
with:
command: pip install pyyaml==6.0.2 httpx==0.28.1
run: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Prepare skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |
@@ -161,10 +150,8 @@ jobs:
run: python3 website/scripts/generate-skill-docs.py
- name: Install dependencies
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: website
run: npm ci
working-directory: website
- name: Build Docusaurus
run: npm run build
+1 -1
View File
@@ -2,7 +2,7 @@ name: Docker / shell lint
# Lints the container build inputs: Dockerfile (via hadolint) and any shell
# scripts under docker/ (via shellcheck). These catch the class of regression
# the behavioral docker smoke test can't — unquoted variable
# the behavioral docker-publish smoke test can't — unquoted variable
# expansions, silently-failing RUN commands, etc.
#
# Rules and ignores are documented in .hadolint.yaml at the repo root.
+352
View File
@@ -0,0 +1,352 @@
name: Docker Build and Publish
on:
push:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
release:
types: [published]
permissions:
contents: read
# Needed so the arm64 job can push/pull its registry-backed build cache
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
# job for why registry cache replaced the gha cache on that arch.
packages: write
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image. PR runs reuse a PR-scoped group with
# cancel-in-progress: true so rapid pushes to the same PR collapse to the
# latest commit.
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# ---------------------------------------------------------------------------
# Build amd64 natively. This job also runs the smoke tests (basic --help
# and the dashboard subcommand regression guard from #9153), because amd64
# is the only arch we can `load` into the local daemon on an amd64 runner.
# ---------------------------------------------------------------------------
build-amd64:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The image build + smoke test + integration tests run ONLY on
# push-to-main and release — never on PRs. They are the heaviest jobs
# in CI (~15-45 min) and a broken build surfaces on the main push (and
# is gated pre-merge by docker-lint + uv-lockfile-check). Every step
# below is skipped on PRs, so the job still reports green and the
# required check never hangs.
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for smoke testing. Cached
# to gha with a per-arch scope; the push step below reuses every
# layer from this build.
- name: Build image (amd64, smoke test)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
load: true
platforms: linux/amd64
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
- name: Smoke test image
if: github.event_name != 'pull_request'
uses: ./.github/actions/hermes-smoke-test
with:
image: ${{ env.IMAGE_NAME }}:test
# ---------------------------------------------------------------------
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`). These tests
# are excluded from the sharded `tests.yml :: test` matrix on purpose
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
# shard would otherwise reach the session-scoped ``built_image``
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
# ``docker build`` — guaranteed to
# die in fixture setup.
#
# Piggybacking here avoids a second image build: the smoke test
# already proved the image loads + runs, so the daemon has it under
# `${IMAGE_NAME}:test` and we just point ``HERMES_TEST_IMAGE`` at
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
#
# Why this job and not a standalone one: the image is 5GB+; passing
# it between jobs via ``docker save``/``upload-artifact`` is slower
# than the build itself. Reusing the existing daemon state is the
# cheapest path to coverage on every PR that touches docker code.
# ---------------------------------------------------------------------
- name: Install uv (for docker tests)
if: github.event_name != 'pull_request'
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Set up Python 3.11 (for docker tests)
if: github.event_name != 'pull_request'
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
if: github.event_name != 'pull_request'
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uv pip install -e ".[dev]"
- name: Run docker integration tests
if: github.event_name != 'pull_request'
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
# Match the policy in tests.yml :: test job — no accidental
# real-API calls from inside the harness.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
source .venv/bin/activate
python -m pytest tests/docker/ -v --tb=short
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push amd64 by digest only (no tag). The merge job assembles the
# tagged manifest list. `push-by-digest=true` is docker's recommended
# pattern for multi-runner multi-platform builds.
- name: Push amd64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: linux/amd64
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
# Write the digest to a file and upload it as an artifact so the
# merge job can stitch both per-arch digests into a manifest list.
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: digest-amd64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---------------------------------------------------------------------------
# Build arm64 natively on GitHub's free arm64 runner. This replaces the
# previous QEMU-emulated arm64 build, which was ~5-10x slower and shared
# a cache scope with amd64. Matches the amd64 job's shape: build+load,
# smoke test, then on push/release push by digest.
# ---------------------------------------------------------------------------
build-arm64:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-24.04-arm
timeout-minutes: 45
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# arm64 build runs only on push-to-main and release (see build-amd64).
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
# the whole job — unlike the gha cache backend's short-lived Azure SAS
# token, which expired mid-build on slow cold-cache arm64 runs and
# crashed the build before the smoke test (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
if: github.event_name != 'pull_request'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build once, load into the local daemon for smoke testing, then push
# by digest below. Reads AND writes the registry-backed cache so the
# push reuses layers from this build and the next build starts warm.
#
# Registry cache (type=registry on ghcr.io) is used instead of the gha
# cache that previously broke here: its credential is the job-lifetime
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
# token failure mode cannot recur.
- name: Build image (arm64, smoke test, cached publish)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
load: true
platforms: linux/arm64
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Smoke test image
if: github.event_name != 'pull_request'
uses: ./.github/actions/hermes-smoke-test
with:
image: ${{ env.IMAGE_NAME }}:test
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push arm64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: linux/arm64
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: digest-arm64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
# so it runs in ~30 seconds.
#
# On main pushes: tags both :main and :latest.
# On releases: tags :<release_tag_name>.
# ---------------------------------------------------------------------------
merge:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
runs-on: ubuntu-latest
needs: [build-amd64, build-arm64]
timeout-minutes: 10
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
set -euo pipefail
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
docker buildx imagetools create \
-t "${IMAGE_NAME}:${TAG}" \
"${args[@]}"
else
docker buildx imagetools create \
-t "${IMAGE_NAME}:main" \
-t "${IMAGE_NAME}:latest" \
"${args[@]}"
fi
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
- name: Inspect image
run: |
if [ "${{ github.event_name }}" = "release" ]; then
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
else
docker buildx imagetools inspect "${IMAGE_NAME}:main"
fi
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
-219
View File
@@ -1,219 +0,0 @@
name: Docker Build, Test, and Publish
on:
release:
types: [published]
workflow_call:
permissions:
contents: read
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image. PR runs reuse a PR-scoped group with
# cancel-in-progress: true so rapid pushes to the same PR collapse to
# the latest commit.
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# Build, test, and optionally push the image for each architecture.
build:
if: github.repository == 'NousResearch/hermes-agent'
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
- arch: arm64
runner: ubuntu-24.04-arm
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for testing. Cached
# per-arch; the push step below reuses every layer from this build.
- name: Build image (${{ matrix.arch }})
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
load: true
platforms: ${{ matrix.platform }}
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push by digest only (no tag). The merge job assembles the
# tagged manifest list. `push-by-digest=true` is docker's recommended
# pattern for multi-runner multi-platform builds.
- name: Push ${{ matrix.arch }} by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
# Write the digest to a file and upload it as an artifact so the
# merge job can stitch both per-arch digests into a manifest list.
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`).
#
# Piggybacking here avoids a second image build: the build step
# already loaded the image into the daemon under
# `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
#
# Why this job and not a standalone one: the image is 5GB+; passing
# it between jobs via ``docker save``/``upload-artifact`` is slower
# than the build itself. Reusing the existing daemon state is the
# cheapest path to coverage on every PR that touches docker code.
# ---------------------------------------------------------------------
- name: Install uv (for docker tests)
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Python 3.11 (for docker tests)
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra dev
- name: Run docker integration tests
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
# Match the policy in tests.yml :: test job — no accidental
# real-API calls from inside the harness.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
# so it runs in ~30 seconds.
#
# On main pushes: tags both :main and :latest.
# On releases: tags :<release_tag_name>.
# ---------------------------------------------------------------------------
merge:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
runs-on: ubuntu-latest
needs: [build]
timeout-minutes: 10
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${{ github.event_name }}" = "release" ]; then
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
else
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
fi
# Retry: Docker Hub API + just-pushed digest eventual consistency
# can transiently fail the create; the operation is idempotent.
for i in 1 2 3; do
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
break
fi
if [ "$i" = 3 ]; then
echo "::error::imagetools create failed after 3 attempts"
exit 1
fi
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
sleep 20
done
- name: Inspect image
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
else
docker buildx imagetools inspect "${IMAGE_NAME}:main"
fi
-1
View File
@@ -9,7 +9,6 @@ permissions:
jobs:
docs-site-checks:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-212
View File
@@ -1,212 +0,0 @@
name: E2E Desktop
on:
workflow_call:
permissions:
contents: read
concurrency:
group: e2e-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
name: Playwright E2E (Linux)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ── System deps for Electron on headless Ubuntu ───────────────────
# Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's
# install-deps covers browsers; for Electron we install the apt
# packages directly.
- name: Install system dependencies for Electron
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
xvfb \
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
# ── Node ───────────────────────────────────────────────────────────
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# Full npm ci (not --ignore-scripts): electron's postinstall
# downloads the binary we launch, and node-pty's native build is
# needed for the terminal pane.
- uses: ./.github/actions/retry
with:
command: npm ci
# ── Python (for the hermes serve backend) ──────────────────────────
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install Python dependencies
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
# ── Build desktop app ─────────────────────────────────────────────
- run: npm run --prefix apps/desktop build
# ── Restore visual baseline screenshots from main ──────────────────
# Baselines are generated on main (via --update-snapshots) and cached.
# On PRs, we restore them so toHaveScreenshot has something to compare
# against. The cache key is keyed on the desktop source files so a
# UI change naturally invalidates it — but we fall back to the main
# cache to avoid cold starts on unrelated PRs.
- name: Restore visual baseline screenshots
id: restore-baselines
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-${{ github.ref_name }}
restore-keys: |
visual-baselines-main
# ── Run Playwright E2E under xvfb ─────────────────────────────────
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
# window always has a consistent viewport for screenshot comparison.
# On main, we run with --update-snapshots to generate baselines.
- name: Run Playwright E2E tests
working-directory: apps/desktop
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
CI: "true"
# Ensure no real API keys leak into the test env.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
# ── Save updated baselines to cache (main only) ───────────────────
- name: Save updated baselines to cache
if: github.ref_name == 'main' && always()
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-main
# ── Upload Playwright report (HTML + traces) ──────────────────────
- name: Upload Playwright report
id: upload-report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-${{ github.sha }}
path: apps/desktop/playwright-report
retention-days: 14
overwrite: true
# ── Upload test results (screenshots, traces, diffs) ───────────────
- name: Upload test results
id: upload-results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-test-results-${{ github.sha }}
path: apps/desktop/test-results
retention-days: 14
overwrite: true
# ── Upload just the visual diffs (small, fast to review) ──────────
- name: Upload visual diffs
id: upload-diffs
if: always() && github.ref_name != 'main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-diffs-${{ github.sha }}
path: |
apps/desktop/test-results/**/*-diff.png
apps/desktop/test-results/**/*-actual.png
apps/desktop/test-results/**/*-expected.png
retention-days: 14
overwrite: true
if-no-files-found: ignore
# ── Generate step summary with visual diff info ───────────────────
# Parse the JSON report + scan for diff images, then post a summary
# to the GitHub Actions step output so reviewers can see what changed
# without downloading artifacts. Runs AFTER uploads so it can link
# the artifact download URLs from their step outputs.
- name: Generate visual diff summary
if: always()
working-directory: apps/desktop
env:
REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }}
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
run: |
echo "## Desktop E2E — Visual Diff Report" >> "$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)." >> "$GITHUB_STEP_SUMMARY"
else
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=$(echo "$diff" | sed 's/-diff\.png$//')
test_name=$(basename "$base")
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
done
fi
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" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$REPORT_URL" ]; then
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)" >> "$GITHUB_STEP_SUMMARY"
fi
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 "" >> "$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 || {};
console.log('| Status | Count |');
console.log('|--------|-------|');
console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |');
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
fi
+1 -12
View File
@@ -15,10 +15,6 @@ name: History Check
on:
workflow_call:
outputs:
review_status:
description: "JSON array of review_status objects for the synthesizer."
value: ${{ jobs.check-common-ancestor.outputs.review_status }}
permissions:
contents: read
@@ -26,24 +22,18 @@ permissions:
jobs:
check-common-ancestor:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
review_status: ${{ steps.merge-base-check.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
- id: merge-base-check
name: Reject PRs with no common ancestor on main
- name: Reject PRs with no common ancestor on main
run: |
# `git merge-base` exits non-zero AND prints nothing when the two
# commits share no ancestor. We check both conditions explicitly
# so the failure message is clear regardless of which signal fires
# first.
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo ""
echo "::error::This PR has no common ancestor with main."
echo ""
@@ -65,4 +55,3 @@ jobs:
exit 1
fi
echo "::notice::Common ancestor with main: $BASE"
echo "review_status=[]" >> "$GITHUB_OUTPUT"
-258
View File
@@ -1,258 +0,0 @@
name: auto-fix lint issues & formatting
# On push to main (or manual trigger), run `npm run fix` on each workspace
# package and apply any changes via a PR.
#
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
# check in typecheck.yml fails only when un-fixable errors remain.
#
# NOTE: App token pushes DO trigger further workflow runs (unlike
# secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }})
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
# run cancels the in-flight one, and since the second run finds no new fixes
# (the first run already applied them), it exits with an empty patch.
#
# ── Security model: two-job split ───────────────────────────────────────────
#
# The eslint process executes repo code (eslint.config.mjs, package.json
# scripts, installed plugins). To prevent a malicious PR from getting arbitrary
# code execution on a runner with push access, the work is split:
#
# 1. generate-patch (unprivileged, contents: read only)
# Checks out, installs deps, runs eslint --fix, produces a .patch artifact.
# Worst case: malicious code runs here on an ephemeral runner with zero
# push permissions.
#
# 2. apply-patch (privileged, contents: write + pull-requests: write)
# Checks out, downloads the patch artifact, applies it, pushes to the
# bot/js-autofix branch, creates/updates a PR, and enables auto-merge.
# This job never runs npm, never installs anything, never executes any
# repo code. The only input it trusts is the patch artifact.
# Skipped entirely when generate-patch reports no fixes (has-fixes != true).
# The PR auto-merges (squash) once CI passes. If CI fails or main moves,
# the PR is auto-closed and the branch deleted — the next run re-applies
# on the current state.
on:
push:
branches: [main]
paths:
- '**/*.js'
- '**/*.cjs'
- '**/*.mjs'
- '**/*.ts'
- '**/*.tsx'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read # default; apply-patch job overrides to write
concurrency:
group: ts-autofix-${{ github.ref }}
cancel-in-progress: true
jobs:
generate-patch:
name: Generate eslint --fix patch
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has-fixes: ${{ steps.produce-patch.outputs.has-fixes }}
# No permissions override → inherits workflow-level contents: read.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# --ignore-scripts: eslint only needs TS sources + eslint packages.
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- name: npm run fix in all workspaces
# continue-on-error: if un-fixable errors exist on main, we still want
# to commit whatever fixes were applied. The PR-time check in
# typecheck.yml is what blocks un-fixable errors from landing.
continue-on-error: true
run: npm run fix
- name: Produce patch
id: produce-patch
run: |
if git diff --quiet; then
echo "No fixes needed."
echo "has-fixes=false" >> "$GITHUB_OUTPUT"
# Empty patch signals "nothing to do" to apply-patch.
: > js-fix.patch
else
git diff > js-fix.patch
echo "has-fixes=true" >> "$GITHUB_OUTPUT"
echo "Patch size: $(wc -c < js-fix.patch) bytes"
# Reject patches that touch anything outside JS/TS/JSON sources.
# `npm run fix` should only ever modify those; anything else means
# eslint/prettier or a plugin went rogue and we refuse to ship it.
BAD=$(git diff --name-only | grep -vE '\.(js|cjs|mjs|ts|tsx|json)$' || true)
if [ -n "$BAD" ]; then
echo "::error::Refusing to upload patch — touches disallowed files:"
echo "$BAD"
exit 1
fi
fi
- name: Upload patch artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: js-fix-patch
path: js-fix.patch
retention-days: 1
include-hidden-files: true
apply-patch:
name: Apply patch
needs: generate-patch
# Skip entirely when generate-patch found no fixes — saves a runner,
# avoids a redundant checkout/download, and keeps the job graph honest.
if: needs.generate-patch.outputs.has-fixes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # needed to push to bot/js-autofix
pull-requests: write # needed for PR creation + auto-merge
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Download patch
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: js-fix-patch
# ${{ runner.temp }} expands in with: params (shell-style $VAR does not).
# download-artifact's path is a *directory* — the artifact's js-fix.patch
# file lands inside it, so $RUNNER_TEMP/js-fix.patch resolves correctly
# in the run step below.
path: ${{ runner.temp }}
- name: Apply patch and push to bot branch
env:
BOT_BRANCH: bot/js-autofix
run: |
set -euo pipefail
# Empty patch = nothing to do.
if [ ! -s "$RUNNER_TEMP/js-fix.patch" ]; then
echo "Patch is empty. No fixes to apply."
exit 0
fi
# Apply the patch produced by the unprivileged job.
git apply --check "$RUNNER_TEMP/js-fix.patch" || {
echo "::error::Patch does not apply cleanly. Branch may have moved."
exit 1
}
git apply "$RUNNER_TEMP/js-fix.patch"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "fmt(js): \`npm run fix\` on merge"
# Push to the dedicated bot branch. Force-push is safe here:
# bot/js-autofix is a bot-only branch that gets rewritten each run.
# If the branch was deleted after a previous PR merge, this
# recreates it.
git push --force origin HEAD:"$BOT_BRANCH"
- name: Create/update PR and enable auto-merge
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BOT_BRANCH: bot/js-autofix
run: |
set -euo pipefail
# Create PR if one doesn't exist. If it already exists, the
# force-push above already updated it with the latest fixes.
PR_NUM=$(gh pr list --head "$BOT_BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true)
if [ -z "$PR_NUM" ]; then
# gh pr create prints the PR URL. Extract the number from it
# (https://github.com/<org>/<repo>/pull/<number>).
PR_URL=$(gh pr create \
--head "$BOT_BRANCH" --base main \
--title 'fmt(js): `npm run fix` auto-fix' \
--body 'Auto-generated by the `auto-fix lint issues & formatting` workflow. Auto-merges (squash) once CI passes. If CI fails or `main` moves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.')
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
fi
# Enable auto-merge (squash). If already enabled, this is a no-op.
gh pr merge "$PR_NUM" --auto --squash || true
- name: Wait for merge, auto-close on failure or stale
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
START_SHA: ${{ github.sha }}
run: |
set -euo pipefail
PR_NUM=$(gh pr list --head bot/js-autofix --state open --json number --jq '.[0].number' 2>/dev/null || true)
if [ -z "$PR_NUM" ]; then
echo "No open PR. Nothing to wait for."
exit 0
fi
echo "Waiting for PR #$PR_NUM to merge..."
# Poll every 15s for up to ~10 minutes. Auto-merge will handle the
# PR even if this job times out — the polling is for cleanup only
# (auto-close on CI failure, conflicts, or main moving).
for i in $(seq 1 40); do
sleep 15
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
if [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; then
echo "PR #$PR_NUM is $STATE."
exit 0
fi
# If main moved, the PR may have already merged (which moves
# main) or another commit landed. Re-check state first.
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/branches/main" --jq '.commit.sha')
if [ "$CURRENT_SHA" != "$START_SHA" ]; then
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
if [ "$STATE" = "MERGED" ]; then
echo "PR #$PR_NUM merged (main moved to $CURRENT_SHA)."
exit 0
fi
echo "Main moved ($START_SHA → $CURRENT_SHA). Closing stale PR."
gh pr close "$PR_NUM" --delete-branch || true
exit 0
fi
# If CI checks failed, close + delete the branch.
if gh pr checks "$PR_NUM" 2>/dev/null | grep -qi "fail"; then
echo "CI failed on PR #$PR_NUM. Closing + deleting branch."
gh pr close "$PR_NUM" --delete-branch
exit 0
fi
# If PR is conflicted, close + delete the branch.
MERGEABLE=$(gh pr view "$PR_NUM" --json mergeable --jq '.mergeable')
if [ "$MERGEABLE" = "CONFLICTING" ]; then
echo "PR #$PR_NUM is conflicted. Closing + deleting branch."
gh pr close "$PR_NUM" --delete-branch
exit 0
fi
done
echo "Timeout reached. Auto-merge will handle PR #$PR_NUM if CI passes."
-51
View File
@@ -1,51 +0,0 @@
# .github/workflows/js-tests.yml
name: JS Tests
on:
workflow_call:
jobs:
workspaces:
name: List npm workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
packages: ${{ steps.set-matrix.outputs.packages }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- id: set-matrix
run: |
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: Typecheck & Test
needs: workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
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
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix ${{ matrix.package }} check
- run: npm run --prefix ${{ matrix.package }} fix
-81
View File
@@ -1,81 +0,0 @@
name: Label rerun
# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in
# the latest CI run. This re-evaluates ``review-labels`` (which now sees the
# label) and GitHub automatically reruns dependent jobs (``comment-live``,
# ``all-checks-pass``) — so the review comment gets updated too.
#
# If the CI run is still in progress when the label is added, we wait for it
# to finish before rerunning (``gh run rerun`` only works on completed runs).
# The wait can be long (20+ min for a full CI run), but it's better than
# silently failing and leaving the reviewer stuck.
on:
pull_request:
types: [labeled]
permissions:
actions: write
pull-requests: read
concurrency:
group: label-rerun-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
rerun-review-labels:
name: Rerun review-labels job
if: github.event.label.name == 'ci-reviewed'
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Wait for CI run to finish, then rerun failed jobs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -uo pipefail
# Find the latest CI run for this PR's head SHA.
RUN_ID=$(gh run list \
--repo "$REPO" \
--commit "$HEAD_SHA" \
--workflow ci.yml \
--limit 1 \
--json databaseId,status \
--jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true)
if [ -z "$RUN_ID" ]; then
echo "No CI run found for this PR — nothing to rerun."
exit 0
fi
# Split "RUN_ID STATUS" into two vars.
RUN_ID="${RUN_ID%% *}"
STATUS="${RUN_ID##* }"
echo "Latest CI run: $RUN_ID (status: $STATUS)"
# If the run is still in progress, wait for it to finish.
# gh run rerun only works on completed runs — if we try while it's
# running, GitHub rejects with "cannot be rerun; This workflow is
# already running".
if [ "$STATUS" != "completed" ]; then
echo "Run is $STATUS — waiting for completion (this may take a while)..."
# gh run watch --exit-status exits non-zero if the run fails,
# which is expected (the label gate fails). Don't let that kill
# the workflow — we WANT to rerun failed jobs.
timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true
# Verify it's actually completed now.
STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown")
if [ "$STATUS" != "completed" ]; then
echo "Run is still $STATUS after wait — giving up."
exit 0
fi
fi
echo "Run completed. Rerunning all failed jobs..."
gh run rerun "$RUN_ID" --repo "$REPO" --failed || true
echo "Done. GitHub will rerun review-labels and all dependent jobs."
+47 -11
View File
@@ -2,14 +2,11 @@ name: Lint (ruff + ty)
# Two things here:
# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch.
# Writes a Markdown summary to the run page. Exit zero always.
# Posts a Markdown summary and a PR comment. Exit zero always.
# 2. Blocking ``ruff check .`` — enforces the explicit rules in
# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge.
# Separate job so the advisory diff still runs even when enforcement
# fails.
#
# CI-sensitive file review was previously here as a ``ci-review`` job but
# has moved to ``review-labels.yml`` so it can be rerun independently.
# Separate job so the advisory diff still runs and posts even when
# enforcement fails.
on:
workflow_call:
@@ -21,6 +18,7 @@ on:
permissions:
contents: read
pull-requests: write # needed to post/update PR comments
concurrency:
group: lint-${{ github.ref }}
@@ -39,7 +37,7 @@ jobs:
fetch-depth: 0 # need full history for merge-base + worktree
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Install ruff + ty
uses: ./.github/actions/retry
@@ -100,8 +98,6 @@ jobs:
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
- name: Generate diff summary
env:
HEAD_REF: ${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}
run: |
python scripts/lint_diff.py \
--base-ruff .lint-reports/base/ruff.json \
@@ -109,10 +105,50 @@ jobs:
--base-ty .lint-reports/base/ty.json \
--head-ty .lint-reports/head/ty.json \
--base-ref "${{ steps.base.outputs.ref }}" \
--head-ref "$HEAD_REF" \
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
--output .lint-reports/summary.md
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload reports as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: lint-reports
path: .lint-reports/
retention-days: 14
- name: Post / update PR comment
if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
const marker = '<!-- lint-diff-summary -->';
const fullBody = marker + '\n' + body;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: fullBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fullBody,
});
}
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
@@ -128,7 +164,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Install ruff
uses: ./.github/actions/retry
-95
View File
@@ -1,95 +0,0 @@
name: Lockfile diff
# Advisory PR comment showing the *semantic* diff of package-lock.json
# changes — which packages were added/removed/updated and their versions.
# The raw textual diff of a lockfile is unreadable (npm reorders entries
# and rewrites integrity hashes), so scripts/ci/lockfile_diff.py parses
# the ``packages`` map at the merge base and at HEAD and set-diffs the
# {install path: version} maps instead.
#
# The semantic diff is exposed as a workflow_call output ``review_status``
# (a JSON array in the unified status format) and an artifact
# (``lockfile-diff`` containing the markdown fragment) for the step
# summary.
#
# Never blocking — this is review signal, not enforcement.
on:
workflow_call:
outputs:
changed:
description: Whether package-lock.json changed relative to the target branch.
value: ${{ jobs.diff.outputs.changed }}
review_status:
description: JSON array of review status objects for the unified PR comment.
value: ${{ jobs.diff.outputs.review_status }}
permissions:
contents: read
concurrency:
group: lockfile-diff-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
diff:
name: package-lock.json semantic diff
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
changed: ${{ steps.diff.outputs.changed }}
review_status: ${{ steps.emit-status.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need history for the merge base
- name: Generate semantic lockfile diff
id: diff
run: |
set -euo pipefail
# Three-dot semantics by hand: diff from the merge base with the
# target branch to the PR head, so changes that landed on main
# after the branch point don't show up as this PR's doing.
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
echo "Merge base: ${BASE_SHA}"
python3 scripts/ci/lockfile_diff.py \
--base "$BASE_SHA" \
--head HEAD \
--output /tmp/lockfile-diff.md
if [ -s /tmp/lockfile-diff.md ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
{
echo "## package-lock.json semantic diff"
echo ""
cat /tmp/lockfile-diff.md
} >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
: > /tmp/lockfile-diff.md
fi
- name: Emit review_status
id: emit-status
run: |
set -euo pipefail
CHANGED="${{ steps.diff.outputs.changed }}"
STATUS="[]"
if [ "$CHANGED" = "true" ]; then
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}"
else
STATUS="[]"
fi
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
- name: Upload diff artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lockfile-diff
path: /tmp/lockfile-diff.md
retention-days: 1
overwrite: true
+2 -79
View File
@@ -18,21 +18,17 @@ name: OSV-Scanner
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
# fail-on-vuln is disabled so the job does not block merges on pre-existing
# vulnerabilities in pinned deps that we may need to patch deliberately.
#
# The reusable workflow can't emit custom outputs, so a wrapper job
# downloads the SARIF result and summarizes the vulnerability count into
# a review_status for the unified PR comment.
on:
workflow_call:
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.
- cron: '0 9 * * 1'
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
# Required to upload SARIF file to CodeQL. See: https://github.com/github/codeql-action/issues/2117
# Required by the reusable workflow to upload SARIF to the Security tab.
actions: read
contents: read
security-events: write
@@ -49,76 +45,3 @@ jobs:
--lockfile=package-lock.json
--lockfile=website/package-lock.json
fail-on-vuln: false
emit-status:
name: Emit review status
runs-on: ubuntu-latest
needs: scan
if: always()
outputs:
review_status: ${{ steps.emit.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download SARIF result
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: osv-results
path: /tmp/osv-results
continue-on-error: true
- name: Emit review_status
id: emit
run: |
set -euo pipefail
STATUS="[]"
if [ -f /tmp/osv-results/osv-results.sarif ]; then
# Count vulnerabilities from the SARIF file
VULN_COUNT=$(python3 -c "
import json, sys
try:
with open('/tmp/osv-results/osv-results.sarif') as f:
data = json.load(f)
count = 0
vulns = []
for run in data.get('runs', []):
for result in run.get('results', []):
count += 1
rule_id = result.get('ruleId', 'unknown')
message = result.get('message', {}).get('text', '')
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
vulns.append(f'- {rule_id} in {loc}: {message}')
print(count)
if vulns:
print('\n'.join(vulns[:20]), file=sys.stderr)
except Exception:
print(0)
")
VULN_DETAIL=""
if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then
VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies")
VULN_DETAIL=$(python3 -c "
import json, sys
try:
with open('/tmp/osv-results/osv-results.sarif') as f:
data = json.load(f)
vulns = []
for run in data.get('runs', []):
for result in run.get('results', []):
rule_id = result.get('ruleId', 'unknown')
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
vulns.append(f'- {rule_id} in {loc}')
print(json.dumps('\n'.join(vulns[:20])))
except Exception:
print(json.dumps(''))
")
STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]"
else
STATUS="[]"
fi
fi
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
-98
View File
@@ -1,98 +0,0 @@
name: Review labels
# Require explicit maintainer review when CI-sensitive files or the MCP
# catalog change. Previously this was split across two jobs in two
# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and
# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on
# ``mcp_catalog``). Both checked for their own label.
#
# Now consolidated: a single ``ci-reviewed`` label covers both. The
# comment sections tell the reviewer exactly what to verify per area,
# so one label is enough — the human reads the comment, not the label
# name.
#
# Outputs:
# ci_reviewed — "true" / "false" / "" (empty when neither lane ran)
# review_status — JSON array of status objects consumed by the review
# comment assembler. See scripts/ci/emit_review_status.py.
on:
workflow_call:
inputs:
ci_review:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
type: boolean
default: false
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
default: false
supply_chain:
description: Whether the critical supply-chain scan found a risk requiring review.
type: boolean
default: false
outputs:
ci_reviewed:
description: Whether the ci-reviewed label is present. Empty when neither input was true.
value: ${{ jobs.check.outputs.ci_reviewed }}
review_status:
description: JSON array of status objects for the review comment assembler.
value: ${{ jobs.check.outputs.review_status }}
permissions:
contents: read
pull-requests: read # read PR labels
jobs:
check:
name: Review label gate
if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }}
review_status: ${{ steps.build-status.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check ci-reviewed label
id: label-check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
echo "ci-reviewed label present."
echo "ci_reviewed=true" >> "$GITHUB_OUTPUT"
else
echo "ci-reviewed label missing."
echo "ci_reviewed=false" >> "$GITHUB_OUTPUT"
fi
- name: Build review_status JSON
id: build-status
env:
CI_REVIEW: ${{ inputs.ci_review }}
MCP_CATALOG: ${{ inputs.mcp_catalog }}
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
run: |
set -euo pipefail
args=()
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
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[@]}" --output "$GITHUB_OUTPUT"
- name: Fail on missing label
if: steps.label-check.outputs.ci_reviewed != 'true'
run: |
echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check."
exit 1
+2 -11
View File
@@ -20,7 +20,6 @@ jobs:
check-freshness:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Probe live index
id: probe
@@ -29,7 +28,7 @@ jobs:
URL="https://hermes-agent.nousresearch.com/docs/api/skills-index.json"
echo "Probing $URL"
# -L follows redirects; -f fails on HTTP errors; -s suppresses progress
if ! curl -fsSL --retry 3 --retry-delay 10 -o /tmp/skills-index.json "$URL"; then
if ! curl -fsSL -o /tmp/skills-index.json "$URL"; then
echo "status=fetch-failed" >> "$GITHUB_OUTPUT"
echo "detail=Could not download $URL" >> "$GITHUB_OUTPUT"
exit 0
@@ -108,18 +107,10 @@ jobs:
echo "Summary: ${{ steps.probe.outputs.summary }}"
fi
- name: Get GitHub App token
if: steps.probe.outputs.status != 'ok'
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Open issue on degraded / failed probe
if: steps.probe.outputs.status != 'ok'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STATUS: ${{ steps.probe.outputs.status }}
DETAIL: ${{ steps.probe.outputs.detail }}
run: |
+12 -29
View File
@@ -3,50 +3,40 @@ name: Build Skills Index
on:
schedule:
# Run twice daily: 6 AM and 6 PM UTC
- cron: "0 6,18 * * *"
workflow_dispatch: # Manual trigger
- cron: '0 6,18 * * *'
workflow_dispatch: # Manual trigger
push:
branches: [main]
paths:
- "scripts/build_skills_index.py"
- ".github/workflows/skills-index.yml"
- 'scripts/build_skills_index.py'
- '.github/workflows/skills-index.yml'
permissions:
contents: read
actions: write # to trigger deploy-site.yml on schedule
actions: write # to trigger deploy-site.yml on schedule
jobs:
build-index:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
python-version: '3.11'
- name: Install dependencies
uses: ./.github/actions/retry
with:
command: pip install httpx==0.28.1 pyyaml==6.0.2
run: pip install httpx==0.28.1 pyyaml==6.0.2
- name: Build skills index
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: skills-index
path: website/static/api/skills-index.json
@@ -59,15 +49,8 @@ jobs:
needs: build-index
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- 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: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
+72 -104
View File
@@ -10,18 +10,9 @@ name: Supply Chain Audit
# advisory-only workflow instead.
#
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as
# inputs; this workflow's jobs gate on those inputs instead of re-computing
# the diff. MCP catalog review was previously here but has moved to
# ``review-labels.yml`` so it can be rerun independently.
#
# Outputs:
# review_status — JSON array of status objects consumed by the review
# comment assembler (scripts/ci/assemble_review_comment.py).
# critical_findings — "true" when the narrow critical-pattern scan found
# something. The review-label gate consumes this and
# owns the action-required result, so adding
# ``ci-reviewed`` can heal the run on rerun.
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
# inputs instead of re-computing the diff.
on:
workflow_call:
@@ -38,13 +29,10 @@ on:
description: Whether pyproject.toml changed.
type: boolean
required: true
outputs:
review_status:
description: JSON array of review status objects for the review comment assembler.
value: ${{ jobs.aggregate.outputs.review_status }}
critical_findings:
description: Whether the critical-pattern scan found a risk requiring maintainer review.
value: ${{ jobs.aggregate.outputs.critical_findings }}
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
required: true
permissions:
pull-requests: write
@@ -55,27 +43,16 @@ jobs:
name: Scan PR for critical supply chain risks
if: inputs.scan
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.emit-status.outputs.review_status }}
critical_findings: ${{ steps.scan.outputs.found }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
@@ -83,7 +60,7 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
# Added lines only, excluding lockfiles.
# Three-point diff (base...head) diffs from the merge base to HEAD,
# Three-dot diff (base...head) diffs from the merge base to HEAD,
# so only changes introduced by this PR are included — not changes
# that landed on main after the PR branched off.
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
@@ -161,32 +138,32 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Emit review_status
id: emit-status
if: always()
- name: Post critical finding comment
if: steps.scan.outputs.found == 'true'
env:
FOUND: ${{ steps.scan.outputs.found }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 - <<'PYEOF'
import json, os
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
# The review-label gate renders and blocks critical findings. Keep
# this scan a fact-finder so adding ci-reviewed can rerun the gate
# without requiring the scanner itself to fail again.
status = []
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
$(cat /tmp/findings.md)
---
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
- name: Fail on critical findings
if: steps.scan.outputs.found == 'true'
run: |
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
dep-bounds:
name: Check PyPI dependency upper bounds
if: inputs.deps
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.emit-status.outputs.review_status }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -209,7 +186,7 @@ jobs:
exit 0
fi
# Match PyPI dep specs that have >= and no < ceiling.
# Match PyPI dep specs that have >= but no < ceiling.
# Pattern: "package>=version" without a following ",<" bound.
# Excludes git+ URLs (which use commit SHAs) and comments.
UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true)
@@ -221,36 +198,26 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Emit review_status
id: emit-status
if: always()
- name: Post unbounded dep warning
if: steps.bounds.outputs.found == 'true'
env:
FOUND: ${{ steps.bounds.outputs.found }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 - <<'PYEOF'
import json, os
BODY="## ⚠️ Unbounded PyPI Dependency Detected
found = os.environ.get("FOUND", "") == "true"
This PR adds PyPI dependencies without a \`<next_major\` upper bound. Per our [supply chain policy](../blob/main/CONTRIBUTING.md#dependency-pinning-policy-supply-chain-hardening), all PyPI deps must be pinned as \`>=floor,<next_major\`.
if found:
with open("/tmp/unbounded.txt", encoding="utf-8") as f:
detail = f.read()
status = [{
"source": "supply chain",
"results": [{
"kind": "action_required",
"title": "Unbounded PyPI dependencies",
"summary": "This PR adds PyPI dependencies without upper bounds.",
"detail": detail,
"how_to_fix": 'Add a `<next_major` upper bound, e.g. `"package>=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.'
}]
}]
else:
status = []
**Unbounded specs found:**
\`\`\`
$(cat /tmp/unbounded.txt)
\`\`\`
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
---
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
- name: Fail on unbounded deps
if: steps.bounds.outputs.found == 'true'
@@ -258,39 +225,40 @@ jobs:
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
exit 1
aggregate:
name: Aggregate review statuses
needs: [scan, dep-bounds]
if: always()
mcp-catalog-review:
name: MCP catalog security review
if: inputs.mcp_catalog
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.merge.outputs.review_status }}
critical_findings: ${{ steps.merge.outputs.critical_findings }}
steps:
- name: Merge review statuses
id: merge
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Require explicit MCP catalog review label
env:
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 - <<'PYEOF'
import json, os
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
echo "MCP catalog review label present."
exit 0
fi
merged = []
for key in ("SCAN_STATUS", "DEP_STATUS"):
raw = os.environ.get(key, "")
if not raw:
continue
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(data, list):
merged.extend(data)
BODY="## ⚠️ MCP catalog security review required
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(merged)}\n")
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
PYEOF
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
A maintainer should verify:
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
- stdio transports do not use shell+egress/exfiltration payloads,
- git install refs are pinned and bootstrap commands are minimal,
- requested env vars/secrets match the upstream MCP's documented needs.
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
exit 1
+36 -49
View File
@@ -2,11 +2,6 @@ name: Tests
on:
workflow_call:
inputs:
slice_count:
description: Number of parallel test slices
type: number
default: 8
permissions:
contents: read
@@ -17,12 +12,13 @@ concurrency:
cancel-in-progress: true
jobs:
generate:
name: "Generate slices"
test:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
slice: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -31,31 +27,12 @@ jobs:
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
# main always writes a new suffix, but jobs pick the latest one with the same prefix
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
# If there are no exact matches, the action searches for partial matches of the restore keys.
# When the action finds a partial match, the most recent cache is restored to the path directory.
key: test-durations
# Saves use test-durations-${run_id}, so the exact key above never
# matches — without this prefix fallback the cache ALWAYS missed,
# LPT slicing ran on no data, and unbalanced slices pushed heavy
# files toward the per-file timeout under load.
restore-keys: |
test-durations-
- name: Generate test slices
id: matrix
run: |
MATRIX=$(python3 scripts/run_tests_parallel.py --generate-slices ${{ inputs.slice_count }})
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
test:
name: Run tests slice ${{ matrix.slice.index }}/${{ inputs.slice_count }}
needs: generate
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install ripgrep (prebuilt binary)
run: |
@@ -72,7 +49,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
@@ -101,19 +78,33 @@ jobs:
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Run tests (slice ${{ matrix.slice.index }}/${{ inputs.slice_count }})
# Per-file isolation via scripts/run_tests.sh: each test file runs
# in its own freshly-spawned `python -m pytest <file>` subprocess
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
# every test_*.py file under tests/ (excluding integration/ + e2e/),
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
# with bounded parallelism. No xdist, no shared workers, no
# module-level state leakage between files.
#
# File list is pre-computed by the generate job (--generate-slices)
# which runs LPT distribution once and passes the file list to each
# matrix job via --files. Previously each job re-discovered files and
# re-ran LPT independently — redundant N times.
# Why per-file (not per-test): per-test spawn cost (~250ms × 17k
# tests = 70min CPU minimum) blew the wall-clock budget. Per-file
# spawn (~250ms × ~850 files = ~3.5min) fits while still giving
# every file a fresh interpreter — the only isolation boundary
# that matters in practice (cross-file leakage was the original
# flake source; intra-file is the test author's responsibility).
#
# Why drop xdist entirely: xdist's persistent workers accumulate
# state across files, which is exactly the leakage we wanted to
# fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
# the job with cleaner semantics.
#
# Matrix slicing (--slice I/N): files are distributed across 6
# jobs by cached duration (LPT algorithm) so each job gets
# roughly equal wall time. Without a cache, files default to 2s
# estimate and get split roughly evenly by count — still correct,
# just not perfectly balanced.
run: |
source .venv/bin/activate
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
@@ -121,12 +112,9 @@ jobs:
NOUS_API_KEY: ""
- name: Upload per-slice durations
# Advisory artifact (feeds slice balancing) — a transient artifact-
# service blip must not fail an otherwise-green test slice.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice.index }}
name: test-durations-slice-${{ matrix.slice }}
path: test_durations.json
retention-days: 1
@@ -136,7 +124,6 @@ jobs:
needs: test
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download all slice durations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -186,7 +173,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
+51
View File
@@ -0,0 +1,51 @@
# .github/workflows/typecheck.yml
name: Typecheck
on:
workflow_call:
jobs:
typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
package:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
# native builds. Skipping install scripts drops node-pty's node-gyp
# header fetch — the transient flake that killed this job pre-`tsc` — and
# is faster. retry covers the remaining registry blips.
-
uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
# which does NOT exercise Vite/Rolldown module resolution — so an
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
# longer exports "./react-shim") slips past typecheck and only explodes when
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# Keep install scripts here: the production build may need node-pty's
# native binary. retry handles the transient install-time fetch flakes.
-
uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix apps/desktop build
+20 -44
View File
@@ -5,11 +5,11 @@ name: Publish to PyPI
on:
push:
tags:
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
- 'v20*' # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
workflow_dispatch:
inputs:
confirm_tag:
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
description: 'Tag to publish (e.g. v2026.5.15). Must already exist.'
required: true
type: string
@@ -26,9 +26,8 @@ jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
@@ -44,37 +43,23 @@ jobs:
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
node-version: '22'
- name: Build web dashboard
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: web
- name: Compile web dashboard
run: npm run build
working-directory: web
run: cd web && npm ci && npm run build
- 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
run: cd ui-tui && npm ci && npm run build
- name: Bundle TUI into hermes_cli
run: |
@@ -96,7 +81,7 @@ jobs:
run: uv build --sdist --wheel
- name: Upload distribution artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: python-package-distributions
path: dist/
@@ -105,22 +90,21 @@ jobs:
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
id-token: write # OIDC trusted publishing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
@@ -131,28 +115,20 @@ jobs:
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
contents: write # attach assets to the existing release
id-token: write # sigstore signing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- 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 }}
GITHUB_TOKEN: ${{ github.token }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
@@ -169,7 +145,7 @@ jobs:
- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
@@ -178,7 +154,7 @@ jobs:
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ github.token }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
+4 -28
View File
@@ -4,7 +4,7 @@ name: uv.lock check
# that modify pyproject.toml without regenerating uv.lock (or vice versa)
# must not merge, because the Docker build's `uv sync --frozen` step will
# fail on a stale lockfile and we'd rather catch it here than in the
# docker workflow on main.
# docker-publish workflow on main.
#
# ─────────────────────────────────────────────────────────────────────────
# IMPORTANT: this check runs against the MERGED state, not just your branch
@@ -45,10 +45,6 @@ name: uv.lock check
on:
workflow_call:
outputs:
review_status:
description: "JSON review status for the review-status aggregator"
value: ${{ jobs.check.outputs.review_status }}
permissions:
contents: read
@@ -62,14 +58,12 @@ jobs:
name: uv lock --check
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
review_status: ${{ steps.verify.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.
@@ -79,22 +73,8 @@ jobs:
# of this file) — failures often mean "your branch is behind main,
# rebase and regenerate uv.lock."
- name: Verify uv.lock is up-to-date
id: verify
run: |
# uv lock --check re-resolves against PyPI (network). Retry so a
# registry blip doesn't read as "lockfile stale". A genuinely stale
# lockfile fails all attempts (deterministic), costing only seconds.
ok=false
for i in 1 2 3; do
if uv lock --check; then
ok=true
break
fi
[ "$i" = 3 ] && break
echo "::warning::uv lock --check failed (attempt $i); retrying in 10s"
sleep 10
done
if [ "$ok" != true ]; then
if ! uv lock --check; then
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
## ❌ uv.lock is out of sync with pyproject.toml
@@ -120,13 +100,9 @@ jobs:
This check is blocking because the Docker image build uses
`uv sync --frozen --extra all`, which rejects stale lockfiles
— catching it here avoids a ~15 min failed docker run
— catching it here avoids a ~15 min failed docker-publish run
on `main` post-merge.
EOF
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
exit 1
fi
review_status='[]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
+2 -34
View File
@@ -4,13 +4,10 @@
/_pycache/
*.pyc*
__pycache__/
act/
.act-sandbox-agent.*
.venv/
.venv
.vscode/
.env
.op.env
.env.local
.env.development.local
.env.test.local
@@ -44,10 +41,7 @@ run_datagen_sonnet.sh
source-data/*
run_datagen_megascience_glm4-6.sh
data/*
# No trailing slash: also matches node_modules SYMLINKS (worktrees often
# symlink node_modules to the main checkout; the dir-only pattern let one
# slip into a commit and break `npm ci` on CI with ENOTDIR).
node_modules
node_modules/
browser-use/
agent-browser/
# Private keys
@@ -59,10 +53,6 @@ __pycache__/
hermes_agent.egg-info/
wandb/
testlogs
playwright-report/
test-results/
# Playwright visual regression baselines — cached from main in CI, not committed
*-snapshots/
# CLI config (may contain sensitive SSH paths)
cli-config.yaml
@@ -75,23 +65,10 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Cross-process web UI build lock (flock target, always empty)
.web_ui_build.lock
apps/desktop/build/
apps/desktop/dist/
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
# resolves the stale .js OVER the .tsx — never track these)
apps/desktop/src/**/*.js
apps/desktop/src/**/*.js.map
apps/desktop/src/**/*.d.ts
!apps/desktop/src/global.d.ts
!apps/desktop/src/vite-env.d.ts
apps/shared/src/**/*.js
apps/shared/src/**/*.js.map
apps/shared/src/**/*.d.ts
apps/desktop/release/
*.tsbuildinfo
apps/desktop/*.tsbuildinfo
# Web UI assets — synced from @nous-research/ui at build time via
# `npm run sync-assets` (see web/package.json).
@@ -141,9 +118,6 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent)
.hermes-sandbox/
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.
@@ -163,9 +137,3 @@ RELEASE_v*.md
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
# walkthroughs). Throwaway artifacts, never part of the app.
apps/desktop/demo/
# PR infographics are rendered locally and embedded in PR descriptions via the
# image-provider (fal.media) URL — they are NEVER committed to the repo. The
# PR body is the archive. See the hermes-agent-dev skill's
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
infographic/
-4
View File
@@ -1,4 +0,0 @@
# Lockfiles must never be reformatted — main has a repo rule requiring
# team approval when lockfiles change, so an autofix PR touching one
# would hang waiting for review.
package-lock.json
+61 -125
View File
@@ -123,17 +123,6 @@ conservative at the waist.
without E2E proof, and plugins that touch core files.** Plugins live in their
own directory and work within the ABCs/hooks we provide; if a plugin needs
more, widen the generic plugin surface, don't special-case it in core.
- **Third-party products / other people's projects integrated into the core
tree.** Observability backends, vendor SaaS integrations, analytics dashboards,
and similar "someone else's product" plugins do NOT land under `plugins/` in
this repo. They place an ongoing maintenance burden on us to keep them working
against a fast-moving core, for a backend we don't own. Ship them as a
**standalone plugin repo** users install into `~/.hermes/plugins/` (or via a
pip entry point), and promote them in the Nous Research Discord
(`#plugins-skills-and-skins`). This is a coupling-and-maintenance decision, not
a quality bar — the plugin can be excellent and still be a close. PRs that add
such a directory to the tree are closed with a pointer to publish it as its own
repo.
### Before you call it a bug — verify the premise (and when NOT to close)
@@ -491,18 +480,18 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
### Electron Desktop Chat App (`apps/desktop/`)
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
- `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
- `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
- `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install).
---
@@ -794,24 +783,6 @@ landing in this tree. PRs that add a new directory under
provider as its own repo. Existing in-tree providers stay; bug fixes
to them are welcome.
**No new third-party-product plugins in-tree (policy, June 2026):** the
same rule applies beyond memory providers. Plugins that integrate
someone else's product or project — observability/metrics backends,
vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
must ship as **standalone plugin repos** that users install into
`~/.hermes/plugins/` (or via pip entry points). They register through
the existing plugin discovery path and use the ABCs/hooks/ctx surface
we expose; nothing special is needed in core. The reason is
maintenance load: every product we absorb into the tree becomes our
burden to keep working against a fast-moving core, for a backend we
don't own. Promote standalone plugins in the Nous Research Discord
(`#plugins-skills-and-skins`). PRs that add such a directory under
`plugins/` are closed with a pointer to publish it as its own repo —
this is a coupling decision, not a quality judgment. (The
`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
in the tree are existing precedent, not an invitation to add more
third-party-product plugins alongside them.)
### Model-provider plugins (`plugins/model-providers/<name>/`)
Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
@@ -998,8 +969,7 @@ Two shapes:
Roles:
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
`clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
(programmatic tool calling).
`clarify`, `memory`, `send_message`, `execute_code`.
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
own workers. Gated by `delegation.orchestrator_enabled` (default true)
and bounded by `delegation.max_spawn_depth` (default 2).
@@ -1095,16 +1065,14 @@ kanban task.
- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
`unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
`block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`,
`stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`,
`dispatch`, `daemon`, `gc`.
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
`kanban_comment`, `kanban_create`, `kanban_link`, `kanban_attach`,
`kanban_attach_url`, `kanban_attachments`; profiles that explicitly
enable the `kanban` toolset outside a dispatcher-spawned task also get
`kanban_list` and `kanban_unblock` for board routing.
`kanban_comment`, `kanban_create`, `kanban_link`; profiles that
explicitly enable the `kanban` toolset outside a dispatcher-spawned
task also get `kanban_list` and `kanban_unblock` for board routing.
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
stale claims, promotes ready tasks, atomically claims, and spawns
assigned profiles. Runs **inside the gateway** by default via
@@ -1281,7 +1249,6 @@ def profile_env(tmp_path, monkeypatch):
## Testing
### Python
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
@@ -1293,41 +1260,65 @@ scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
```
**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
section with both attempts' output. A FLAKY report is a bug to fix, not noise
to ignore — timing-sensitive tests must not assume a quiet runner (loose
wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
negative-timing races).
### Subprocess-per-test isolation
#### Subprocess-per-test-file isolation
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
ContextVars from one test cannot leak into the next — the historic
`_reset_module_state` autouse fixture is gone.
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
ContextVars from one test file cannot leak into the next.
Implementation notes:
#### Why the wrapper
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
Linux, macOS, and Windows alike (POSIX `fork` is not used).
- Per-test overhead is ~0.51.0s (Python startup + pytest collection). xdist
parallelism amortizes this across cores; on a 20-core box the full suite
finishes in roughly the same wall time as before, but flake-free.
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
Hangs are killed and surfaced as a failure report.
- Pass `--no-isolate` to disable isolation — useful when debugging a single
test interactively, or when you specifically want to verify state leakage.
- The plugin disables itself in child processes (sentinel envvar
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
| | Without wrapper | With wrapper |
| ------------------- | ------------------------------------------- | ----------------------------------------- |
| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
### Why the wrapper (and why the old "just call pytest" doesn't work)
### Where to place what tests
Five real sources of local-vs-CI drift the script closes:
The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
source, or any other JS-side artifact will not run on a PR that only touches
those files. This means a regression can go green on a PR and red on `main` (where the
classifier fails open and runs everything).
| | Without wrapper | With wrapper |
|---|---|---|
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
Any test that reads or asserts about `package.json`,
`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
is belt-and-suspenders.
### Running without the wrapper (only if you must)
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
at minimum activate the venv. The isolation plugin loads automatically from
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
either way.
```bash
source .venv/bin/activate # or: source venv/bin/activate
python -m pytest tests/ -q
```
If you need to bypass isolation for fast feedback while debugging:
```bash
python -m pytest tests/agent/test_foo.py -q --no-isolate
```
Always run the full suite before pushing changes.
### Don't write change-detector tests
@@ -1377,58 +1368,3 @@ not the specific names.
Reviewers should reject new change-detector tests; authors should convert
them into invariants before re-requesting review.
### Never read source code in tests
A test that reads a source file's text is testing *the shape of the
source code*, not its behavior. This is a hard antipattern, banned outright.
Any test that reads a .py, .ts, .tsx, etc., file is suspect.
**Why it's actively harmful, not just weak:**
- It passes when the implementation is subtly broken (the regex matches a
call site that exists but is wired wrong) and fails when a correct
refactor changes formatting, variable names, or control flow with
identical runtime behavior. Both directions of failure are wrong.
- It can't be run against a built/bundled/minified artifact, so it silently
stops testing anything the moment code moves, gets renamed, or a
dependency reformats it.
- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
fail during pure structural cleanup with no behavior change, and either
hand-wave the failure (dangerous) or waste time updating regexes that add
nothing (waste).
- It gives false confidence. a green suite full of source-regex tests
looks like coverage but has never once executed the code path it claims
to guard.
**Do not write:**
```ts
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
test('backend spawn hides the Windows console', () => {
assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
})
```
**Do write — extract the logic into a small pure/DI-testable function and
call it for real:**
```ts
// backend-spawn.ts
export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
if (!isWindows || 'windowsHide' in options) return options
return { ...options, windowsHide: true }
}
// backend-spawn.test.ts
test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
})
```
If the logic lives inline in a god-file (`main.ts`, `cli.py`,
`gateway/run.py`) and extracting it feels disruptive: that's the actual
signal to do the extraction, not to regex around it.
+1 -1
View File
@@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
| Requisito | Notas |
|-----------|-------|
| **Git** | Con la extensión `git-lfs` instalada |
| **Python 3.113.13** | uv lo instalará si falta |
| **Python 3.11+** | uv lo instalará si falta |
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
+4 -28
View File
@@ -85,23 +85,6 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
---
## Third-Party Product Integrations: Ship as a Standalone Plugin
The same rule extends to **any plugin that integrates someone else's product or project** — observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins, and similar third-party integrations. **These do not land in this repo.**
The reason is maintenance load, not quality. Every external product absorbed into the core tree becomes ours to keep working against a fast-moving codebase, for a backend we don't own and can't control. Hermes ships a lot and the core moves quickly; coupling third-party products into it creates an open-ended burden on the maintainers.
Publish these as a **standalone plugin repo** instead:
- Implement the relevant ABC and use the existing plugin discovery path (`~/.hermes/plugins/`, project `.hermes/plugins/`, or a pip entry point) — see [Build a Hermes Plugin](https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin)
- Register lifecycle hooks (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`), tools (`ctx.register_tool`), and CLI subcommands (`ctx.register_cli_command`) through the surface we already expose — no core changes needed
- If your plugin needs a capability the framework doesn't expose, that's a feature request to **widen the generic plugin surface** (a new hook or `ctx` method) — never special-case your plugin in core
- Promote it in the [Nous Research Discord](https://discord.gg/NousResearch) `#plugins-skills-and-skins` channel so users can find and install it
A well-built third-party-product plugin can clear automated review and still be closed for this reason — it's a placement decision, not a verdict on the code. PRs that add such a directory under `plugins/` will be closed with a pointer to publish it as its own repo.
---
## Development Setup
### Prerequisites
@@ -109,7 +92,7 @@ A well-built third-party-product plugin can clear automated review and still be
| Requirement | Notes |
|-------------|-------|
| **Git** | With the `git-lfs` extension installed |
| **Python 3.113.13** | uv will install it if missing |
| **Python 3.11+** | uv will install it if missing |
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
@@ -149,20 +132,13 @@ this way, make sure you run the `hermes` entrypoint from this venv; running the
system `python3 -m hermes_cli.main` can pick up unrelated system Python
packages.
Create the venv **outside** the cloned source tree. A venv that lives inside
the directory the agent operates from can be wiped by a relative-path command
the agent runs against its own checkout (`rm -rf venv`, `uv venv venv`, etc.),
which silently destroys the running runtime mid-session. Keeping it outside the
tree means no relative path from the workspace resolves to it.
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
# Create venv with Python 3.11, OUTSIDE the source tree
uv venv ~/.hermes/venvs/hermes-dev --python 3.11
export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev"
export PATH="$VIRTUAL_ENV/bin:$PATH"
# Create venv with Python 3.11
uv venv venv --python 3.11
export VIRTUAL_ENV="$(pwd)/venv"
# Install with all extras (messaging, cron, CLI menus, dev tools)
uv pip install -e ".[all,dev]"
+43 -49
View File
@@ -26,8 +26,8 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
# zombies non-blockingly on SIGCHLD and additionally supervises the main
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get -o Acquire::Retries=3 update && \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
@@ -40,30 +40,33 @@ RUN apt-get -o Acquire::Retries=3 update && \
# we map between them inline. The noarch + symlinks tarballs are
# architecture-independent and reused as-is.
#
# We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its
# URL at parse time (no ARG / TARGETARCH substitution) and — critically for
# CI reliability — cannot retry, so a single GitHub-release CDN blip fails
# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
# and every tarball is still checksum-verified below before extraction.
# We use `curl` instead of `ADD` for the per-arch tarball because `ADD`
# evaluates its URL at parse time, before any ARG / TARGETARCH substitution
# — splitting one URL per arch into two ADDs would download both on every
# build and leave dead bytes in the cache. A single curl + arch-keyed URL
# is simpler and cache-friendlier.
#
# Supply-chain integrity: every tarball is checksum-verified against the
# upstream-published SHA256. To bump S6_OVERLAY_VERSION, fetch the four
# `.sha256` files from the corresponding release and update the ARGs. The
# checksum lookup happens during build, so a compromised release artifact
# fails the build loudly instead of silently producing a tampered image.
ARG TARGETARCH
ARG S6_OVERLAY_VERSION=3.2.3.0
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-symlinks-noarch.tar.xz /tmp/
RUN set -eu; \
case "${TARGETARCH:-amd64}" in \
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
esac; \
base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
"${base}/s6-overlay-noarch.tar.xz"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
"${base}/s6-overlay-symlinks-noarch.tar.xz"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
"${base}/s6-overlay-${s6_arch}.tar.xz"; \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${s6_arch}.tar.xz"; \
{ \
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
@@ -73,19 +76,17 @@ RUN set -eu; \
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
# #34192 / #66679: backward-compat shim for orchestration templates that
# still reference the legacy /usr/bin/tini entrypoint (Hostinger's
# 'Hermes WebUI' catalog, NAS compose projects that preserve an old
# entrypoint on image update, etc.). A plain symlink to /init made the
# path exist, but forwarded tini flags like `-g` into s6-overlay's
# rc.init as the container CMD (`rc.init: 91: -g: not found`) and
# boot-looped any `restart: unless-stopped` deploy. The shim strips the
# tini CLI surface, then exec's /init + main-wrapper — see
# docker/tini-shim.sh. Safe to drop once the affected catalogs are
# updated.
COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
# #34192: backward-compat shim for orchestration templates that still\
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
# of this file), but external wrappers pinned to /usr/bin/tini will\
# crash with 'tini: No such file or directory' on startup. The shim\
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
# PID-1 reaper without behavior change for users on the current\
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
ln -sf /init /usr/bin/tini
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
@@ -118,9 +119,6 @@ COPY package.json package-lock.json ./
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# apps/shared/ is copied IN FULL because web/package.json references it as a
# `file:` workspace dependency (same pattern as hermes-ink above).
COPY apps/shared/ apps/shared/
# `npm_config_install_links=false` forces npm to install `file:` deps as
# symlinks instead of copies. This is the default since npm 10+, which is
@@ -134,11 +132,8 @@ COPY apps/shared/ apps/shared/
# guards against a future regression if the source npm version changes.
ENV npm_config_install_links=false
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
for i in 1 2 3; do \
npx playwright install --with-deps chromium --only-shell && break || \
{ [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
done && \
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
npm cache clean --force
# ---------- Layer-cached Python dependency install ----------
@@ -189,19 +184,12 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
COPY apps/shared/ apps/shared/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
# --link decouples this layer from parents for cache purposes; --chmod bakes
# the final read-only permissions at copy time so we skip the separate
# `chmod -R` pass that previously walked ~30k files across the venv +
# node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w`
# gives the non-root hermes user read + traverse but no write; root retains
# write so the build steps below don't need chmod u+w dances.
COPY --link --chmod=a+rX,go-w . .
COPY . .
# ---------- Permissions ----------
# Link hermes-agent itself (editable). Deps are already installed in the
@@ -209,15 +197,19 @@ COPY --link --chmod=a+rX,go-w . .
# resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# Wire the exec shim and install-method stamp. Files under /opt/hermes are
# already root-owned (COPY, uv sync, npm install all run as root) and
# read-only for the hermes user (go-w from the --chmod above).
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# instances must not be able to self-edit the installed source or venv; user
# data, skills, plugins, config, logs, and dashboard uploads live under
# /opt/data instead. Root can still repair the image during build/boot, but
# supervised Hermes processes drop to the non-root hermes user.
USER root
RUN mkdir -p /opt/hermes/bin && \
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
printf 'docker\n' > /opt/hermes/.install_method
printf 'docker\n' > /opt/hermes/.install_method && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
@@ -244,11 +236,13 @@ RUN mkdir -p /opt/hermes/bin && \
#
# The arg is optional — local `docker build` without --build-arg simply
# omits the file, and the runtime falls back to live-git lookup. CI
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
-3
View File
@@ -1,14 +1,11 @@
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]
+3 -8
View File
@@ -18,7 +18,7 @@
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenRouter, OpenAI, your own endpoint, and [many others](https://hermes-agent.nousresearch.com/docs/integrations/providers). Switch with `hermes model` — no code changes, no lock-in.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NovitaAI](https://novita.ai) (AI-native cloud for Model API, Agent Sandbox, and GPU Cloud), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
<table>
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>
@@ -109,7 +109,6 @@ hermes # Interactive CLI — start a conversation
hermes model # Choose your LLM provider and model
hermes tools # Configure which tools are enabled
hermes config set # Set individual config values
hermes config get # Print individual config values
hermes gateway # Start the messaging gateway (Telegram, Discord, etc.)
hermes setup # Run the full setup wizard (configures everything at once)
hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw)
@@ -233,14 +232,10 @@ scripts/run_tests.sh
Manual clone fallback (for throwaway clones/CI where you intentionally do not
want the managed install layout):
Create the venv outside the cloned source tree — a venv inside the directory
the agent operates from can be wiped by a relative-path command the agent runs
against its own checkout, destroying the running runtime mid-session.
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv ~/.hermes/venvs/hermes-dev --python 3.11
source ~/.hermes/venvs/hermes-dev/bin/activate
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
+2 -54
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
import tempfile
from concurrent.futures import TimeoutError as FutureTimeout
from contextvars import ContextVar, Token
@@ -128,64 +127,13 @@ def _proposal_for_patch_replace(arguments: dict[str, Any]) -> EditProposal:
)
def _extract_v4a_patch_paths(patch_body: str) -> list[str]:
paths: list[str] = []
for match in re.finditer(
r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$',
patch_body,
re.MULTILINE,
):
path = match.group(1).strip()
if path:
paths.append(path)
for match in re.finditer(
r'^\*\*\*\s+Move\s+File:\s*(.+?)\s*->\s*(.+)$',
patch_body,
re.MULTILINE,
):
src = match.group(1).strip()
dst = match.group(2).strip()
if src:
paths.append(src)
if dst:
paths.append(dst)
return paths
def _proposal_for_patch_v4a(arguments: dict[str, Any]) -> EditProposal:
patch_body = arguments.get("patch")
if not isinstance(patch_body, str) or not patch_body:
raise ValueError("patch content required")
paths = _extract_v4a_patch_paths(patch_body)
if not paths:
raise ValueError("no file paths found in V4A patch")
proposal_path = paths[0] if len(paths) == 1 else ", ".join(paths)
old_text = _read_text_if_exists(paths[0]) if len(paths) == 1 else None
return EditProposal(
tool_name="patch",
path=proposal_path,
old_text=old_text,
# ACP only supports a single diff payload here. Surface the exact V4A
# patch content before execution so patch-mode calls are permissioned
# and denied patches cannot mutate.
new_text=patch_body,
arguments=dict(arguments),
)
def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None:
"""Return an edit proposal for supported file mutation calls."""
if tool_name == "write_file":
return _proposal_for_write_file(arguments)
if tool_name == "patch":
mode = arguments.get("mode", "replace")
if mode == "replace":
return _proposal_for_patch_replace(arguments)
if mode == "patch":
return _proposal_for_patch_v4a(arguments)
if tool_name == "patch" and arguments.get("mode", "replace") == "replace":
return _proposal_for_patch_replace(arguments)
return None
+9 -16
View File
@@ -38,22 +38,19 @@ def _permission_option_supports_kind(kind: str) -> bool:
return True
def _build_permission_options(
*, allow_permanent: bool, smart_denied: bool = False,
) -> list[PermissionOption]:
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
"""Return ACP options that match Hermes approval semantics."""
options = [PermissionOption(
option_id="allow_once", kind="allow_once", name="Allow once",
)]
if not smart_denied:
options.append(PermissionOption(
options = [
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
PermissionOption(
option_id="allow_session",
# ACP has no session-scoped kind, so use the closest persistent
# hint while keeping Hermes semantics in the option id.
kind="allow_always",
name="Allow for session",
))
if allow_permanent and not smart_denied:
),
]
if allow_permanent:
options.append(
PermissionOption(
option_id="allow_always",
@@ -62,7 +59,7 @@ def _build_permission_options(
),
)
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
if not smart_denied and _permission_option_supports_kind("reject_always"):
if _permission_option_supports_kind("reject_always"):
options.append(
PermissionOption(
option_id="deny_always",
@@ -132,15 +129,11 @@ def make_approval_callback(
description: str,
*,
allow_permanent: bool = True,
smart_denied: bool = False,
**_: object,
) -> str:
from agent.async_utils import safe_schedule_threadsafe
options = _build_permission_options(
allow_permanent=allow_permanent,
smart_denied=smart_denied,
)
options = _build_permission_options(allow_permanent=allow_permanent)
tool_call = _build_permission_tool_call(command, description)
coro = request_permission_fn(
+21 -54
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 tools.approval import (
reset_hermes_interactive_context,
set_hermes_interactive_context,
)
logger = logging.getLogger(__name__)
@@ -456,7 +452,7 @@ class HermesACPAgent(acp.Agent):
"tools": "List available tools",
"context": "Show conversation context info",
"reset": "Clear conversation history",
"compress": "Compress conversation context",
"compact": "Compress conversation context",
"steer": "Inject guidance into the currently running agent turn",
"queue": "Queue a prompt to run after the current turn finishes",
"version": "Show Hermes version",
@@ -485,7 +481,7 @@ class HermesACPAgent(acp.Agent):
"description": "Clear conversation history",
},
{
"name": "compress",
"name": "compact",
"description": "Compress conversation context",
},
{
@@ -1450,23 +1446,20 @@ class HermesACPAgent(acp.Agent):
# Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr).
# Set it INSIDE _run_agent so the TLS write happens in the executor
# thread — setting it here would write to the event-loop thread's TLS,
# not the executor's. Interactive routing uses a contextvar in
# tools.approval (set_hermes_interactive_context) rather than
# os.environ["HERMES_INTERACTIVE"], so concurrent executor workers can't
# race on a process-global flag — one session's restore can't drop
# another onto the non-interactive auto-approve path mid-run
# (GHSA-96vc-wcxf-jjff). The contextvar write is isolated by the
# contextvars.copy_context() wrapper around the executor call below.
# not the executor's. Also set HERMES_INTERACTIVE so approval.py
# takes the CLI-interactive path (which calls the registered
# callback via prompt_dangerous_approval) instead of the
# non-interactive auto-approve branch (GHSA-96vc-wcxf-jjff).
# ACP's conn.request_permission maps cleanly to the interactive
# callback shape — not the gateway-queue HERMES_EXEC_ASK path,
# which requires a notify_cb registered in _gateway_notify_cbs.
previous_approval_cb = None
interactive_token = None
previous_interactive = None
edit_approval_token = None
previous_session_id = None
def _run_agent() -> dict:
nonlocal previous_approval_cb, interactive_token, edit_approval_token, previous_session_id
nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id
# Bind HERMES_SESSION_KEY for this session so per-session caches
# (e.g. the interactive sudo password cache in tools.terminal_tool)
# scope to the ACP session rather than leaking across sessions
@@ -1498,10 +1491,9 @@ class HermesACPAgent(acp.Agent):
except Exception:
logger.debug("Could not set ACP edit approval requester", exc_info=True)
# Signal to tools.approval that we have an interactive callback
# and the non-interactive auto-approve path must not fire. Uses a
# contextvar (not os.environ) so concurrent executor workers don't
# race on the flag (GHSA-96vc-wcxf-jjff).
interactive_token = set_hermes_interactive_context(True)
# and the non-interactive auto-approve path must not fire.
previous_interactive = os.environ.get("HERMES_INTERACTIVE")
os.environ["HERMES_INTERACTIVE"] = "1"
# Propagate the originating ACP session id to tools that want to
# tag side-effects with it (e.g. ``kanban_create`` stamps it on
# the new task so clients can render a per-session board). Save
@@ -1521,9 +1513,11 @@ class HermesACPAgent(acp.Agent):
logger.exception("Agent error in session %s", session_id)
return {"final_response": f"Error: {e}", "messages": state.history}
finally:
# Restore the interactive contextvar for this context.
if interactive_token is not None:
reset_hermes_interactive_context(interactive_token)
# Restore HERMES_INTERACTIVE.
if previous_interactive is None:
os.environ.pop("HERMES_INTERACTIVE", None)
else:
os.environ["HERMES_INTERACTIVE"] = previous_interactive
# Restore HERMES_SESSION_ID symmetrically.
if previous_session_id is None:
os.environ.pop("HERMES_SESSION_ID", None)
@@ -1617,28 +1611,12 @@ class HermesACPAgent(acp.Agent):
self._send_session_info_update(session_id),
)
# Snapshot the runtime identity; the validator lets the
# background titler skip its LLM call if the session's model
# changed before it fires (#19027).
_title_model = getattr(state.agent, "model", None)
_title_provider = getattr(state.agent, "provider", None)
maybe_auto_title(
self.session_manager._get_db(),
session_id,
user_text,
final_response,
state.history,
main_runtime={
"model": getattr(state.agent, "model", None),
"provider": getattr(state.agent, "provider", None),
"base_url": getattr(state.agent, "base_url", None),
"api_key": getattr(state.agent, "api_key", None),
"api_mode": getattr(state.agent, "api_mode", None),
},
runtime_validator=lambda: (
getattr(state.agent, "model", None) == _title_model
and getattr(state.agent, "provider", None) == _title_provider
),
title_callback=_notify_title_update,
)
except Exception:
@@ -1756,7 +1734,7 @@ class HermesACPAgent(acp.Agent):
"tools": self._cmd_tools,
"context": self._cmd_context,
"reset": self._cmd_reset,
"compress": self._cmd_compress,
"compact": self._cmd_compact,
"steer": self._cmd_steer,
"queue": self._cmd_queue,
"version": self._cmd_version,
@@ -1898,7 +1876,7 @@ class HermesACPAgent(acp.Agent):
lines.append(
f"Compression: due now (threshold ~{threshold_tokens:,}"
+ (f", {threshold_pct:.0f}%" if threshold_pct else "")
+ "). Run /compress."
+ "). Run /compact."
)
else:
lines.append(
@@ -1913,27 +1891,16 @@ class HermesACPAgent(acp.Agent):
if getattr(agent, "compression_enabled", True) is False:
lines.append("Compression is disabled for this agent.")
else:
lines.append("Tip: run /compress to compress manually before the threshold.")
lines.append("Tip: run /compact to compress manually before the threshold.")
return "\n".join(lines)
def _cmd_reset(self, args: str, state: SessionState) -> str:
state.history.clear()
reset_failed = False
try:
reset_session_state = getattr(state.agent, "reset_session_state", None)
if callable(reset_session_state):
reset_session_state()
except Exception:
reset_failed = True
logger.warning("ACP session state reset failed for %s", state.session_id, exc_info=True)
finally:
self.session_manager.save_session(state.session_id)
if reset_failed:
return "Conversation history cleared. Agent session state reset failed; see logs."
self.session_manager.save_session(state.session_id)
return "Conversation history cleared."
def _cmd_compress(self, args: str, state: SessionState) -> str:
def _cmd_compact(self, args: str, state: SessionState) -> str:
if not state.history:
return "Nothing to compress — conversation is empty."
try:
+26 -58
View File
@@ -26,18 +26,31 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _win_path_to_wsl(path: str) -> str | None:
"""Convert a Windows drive path to its WSL /mnt/<drive>/... equivalent."""
match = re.match(r"^([A-Za-z]):[\\/](.*)$", path)
if not match:
return None
drive = match.group(1).lower()
tail = match.group(2).replace("\\", "/")
return f"/mnt/{drive}/{tail}"
def _translate_acp_cwd(cwd: str) -> str:
"""Translate Windows ACP cwd values when Hermes itself is running in WSL.
Windows ACP clients can launch ``hermes acp`` inside WSL while still sending
editor workspaces as Windows drive paths (``E:\\Projects``) or
``\\\\wsl.localhost\\`` UNC paths. Store and execute against the POSIX form so
agents, tools, and persisted ACP sessions all agree on the usable workspace.
Native Linux/macOS keeps the original cwd unchanged.
editor workspaces as Windows drive paths such as ``E:\\Projects``. Store
and execute against the WSL mount path so agents, tools, and persisted ACP
sessions all agree on the usable workspace. Native Linux/macOS keeps the
original cwd unchanged.
"""
from hermes_constants import translate_cwd_for_wsl_backend
from hermes_constants import is_wsl
return translate_cwd_for_wsl_backend(str(cwd))
if not is_wsl():
return cwd
translated = _win_path_to_wsl(str(cwd))
return translated if translated is not None else cwd
def _normalize_cwd_for_compare(cwd: str | None) -> str:
@@ -48,9 +61,7 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str:
# Normalize Windows drive paths into the equivalent WSL mount form so
# ACP history filters match the same workspace across Windows and WSL.
from hermes_constants import windows_path_to_wsl
translated = windows_path_to_wsl(expanded)
translated = _win_path_to_wsl(expanded)
if translated is not None:
expanded = translated
elif re.match(r"^/mnt/[A-Za-z]/", expanded):
@@ -450,47 +461,10 @@ class SessionManager:
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)
# When the agent owns persistence to this same SessionDB it has
# already flushed the live transcript incrementally during
# run_conversation (append_message), and it preserves pre-compaction
# turns non-destructively via archive_and_compact() — keeping them on
# disk as searchable active=0/compacted=1 rows. Calling
# replace_messages() here would then be a redundant double-write that
# DELETEs exactly those archived rows (and, after a compression-driven
# id rotation where agent.session_id no longer equals
# state.session_id, clobbers the ended parent transcript) — silent
# data loss for any ACP conversation long enough to compress.
#
# Only fall back to the destructive atomic replace when the agent is
# NOT persisting itself to this DB (e.g. a test agent factory, or a
# fresh create/fork whose copied history the agent has not flushed
# yet). That path still rolls back on a mid-rewrite failure so the
# previously persisted conversation survives (salvaged from #13675).
agent = state.agent
agent_db = getattr(agent, "_session_db", None)
agent_owns_persistence = (
agent_db is not None
and agent_db is db
and bool(getattr(agent, "_session_db_created", False))
)
if not agent_owns_persistence:
# Even when the current agent doesn't "own" persistence, the
# session on disk may already carry compaction-archived rows —
# e.g. after a model switch or a /restore, both of which mint a
# fresh agent with _session_db_created=False (so the check above
# is False) yet leave the durable archived transcript in place.
# A full-history replace would DELETE those archived rows just
# like the owned-agent case. Guard against it: when archived
# rows exist, replace ONLY the live (active=1) set and leave the
# archived turns untouched; otherwise the destructive replace is
# safe (fresh create/fork with no archived history to lose).
try:
has_archived = db.has_archived_messages(state.session_id)
except Exception:
has_archived = False
db.replace_messages(
state.session_id, state.history, active_only=has_archived
)
# Replace stored messages with current history atomically so a
# mid-rewrite failure rolls back and the previously persisted
# conversation is preserved (salvaged from #13675).
db.replace_messages(state.session_id, state.history)
except Exception:
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
@@ -534,15 +508,9 @@ class SessionManager:
model = row.get("model") or None
# Load conversation history. repair_alternation: this restore feeds
# LIVE REPLAY — the loaded list becomes the resumed agent's working
# conversation. A durable ``user;user`` violation left in state.db would
# otherwise re-fire the pre-request defensive repair on every request
# for the rest of the session (see hermes_state.get_messages_as_conversation).
# Load conversation history.
try:
history = db.get_messages_as_conversation(
session_id, repair_alternation=True
)
history = db.get_messages_as_conversation(session_id)
except Exception:
logger.warning("Failed to load messages for ACP session %s", session_id, exc_info=True)
history = []
+3 -59
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import json
import logging
import uuid
from typing import Any, Dict, List, Optional
@@ -15,8 +14,6 @@ from acp.schema import (
ToolKind,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Map hermes tool names -> ACP ToolKind
# ---------------------------------------------------------------------------
@@ -113,12 +110,7 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
if tool_name == "web_extract":
urls = args.get("urls", [])
if urls:
first = urls[0]
if isinstance(first, dict):
first = first.get("url") or first.get("href") or "?"
elif not isinstance(first, str):
first = "?"
return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
return "web extract"
if tool_name == "process":
action = str(args.get("action") or "").strip() or "manage"
@@ -387,24 +379,6 @@ def _format_execute_code_result(result: Optional[str]) -> Optional[str]:
error = str(data.get("error") or "")
exit_code = data.get("exit_code")
parts = [f"Exit code: {exit_code}" if exit_code is not None else "Execution complete"]
if data.get("stdout_truncated"):
total = data.get("stdout_bytes_total")
captured = data.get("stdout_bytes_captured")
omitted = data.get("stdout_bytes_omitted")
if all(isinstance(v, int) for v in (captured, total, omitted)):
parts.extend([
"",
(
"Output truncated: "
f"captured {captured:,} of {total:,} bytes "
f"({omitted:,} omitted)."
),
])
else:
parts.extend(["", "Output truncated."])
warning = str(data.get("warning") or "").strip()
if warning:
parts.extend(["", "Warning:", warning])
if output:
parts.extend(["", "Output:", output])
if error:
@@ -643,7 +617,7 @@ def _format_session_search_result(result: Optional[str]) -> Optional[str]:
return None
mode = data.get("mode") or "search"
query = data.get("query")
lines = ["Recent sessions" if mode == "recent" else "Session search results" + (f" for `{query}`" if query else "")]
lines = ["Recent sessions" if mode == "recent" else f"Session search results" + (f" for `{query}`" if query else "")]
if not results:
lines.append(str(data.get("message") or "No matching sessions found."))
return "\n".join(lines)
@@ -1047,37 +1021,7 @@ def build_tool_start(
*,
edit_diff: Any = None,
) -> ToolCallStart:
"""Create a ToolCallStart event for the given hermes tool invocation.
A malformed tool argument (e.g. a non-string ``command``/``path`` from a
model that ignores the schema) must never abort the ACP tool-call render
``build_tool_start`` runs on the live tool-progress callback and during
session history replay. On any failure in the title/content/location
builders, fall back to a minimal, valid start event. Mirrors
``get_cute_tool_message`` in ``agent/display.py``, wrapped for the same
reason on the CLI side.
"""
try:
return _build_tool_start(
tool_call_id, tool_name, arguments, edit_diff=edit_diff
)
except Exception as exc: # noqa: BLE001 — a tool-call render must never abort the turn
logger.debug("ACP tool-start render failed for %r: %s", tool_name, exc)
safe_name = tool_name if isinstance(tool_name, str) and tool_name else "tool"
return acp.start_tool_call(
tool_call_id, safe_name, kind=get_tool_kind(safe_name),
content=None, locations=[], raw_input=None,
)
def _build_tool_start(
tool_call_id: str,
tool_name: str,
arguments: Dict[str, Any],
*,
edit_diff: Any = None,
) -> ToolCallStart:
"""Build the ToolCallStart event (unguarded; see ``build_tool_start``)."""
"""Create a ToolCallStart event for the given hermes tool invocation."""
kind = get_tool_kind(tool_name)
title = build_tool_title(tool_name, arguments)
locations = extract_locations(arguments)
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.19.0",
"version": "0.17.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",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.19.0",
"package": "hermes-agent[acp]==0.17.0",
"args": ["hermes-acp"]
}
}
+20 -272
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional
import httpx
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
if TYPE_CHECKING:
@@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /topup)")
details.append("(or run /credits)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/topup`` balance view.
"""Surface-agnostic data for the ``/credits`` command.
One portal fetch, one parse consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
@@ -356,11 +356,11 @@ class CreditsView:
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /topup balance view: balance block + identity line + top-up URL.
"""Build the /credits view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
supplies its own affordance). Fail-open ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
@@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
@@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
# appends for the /usage surface. /topup renders its own button/panel.
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
@@ -425,102 +425,31 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
)
def _codex_backend_urls(base_url: str) -> tuple[str, str, str]:
"""Resolve the Codex backend endpoints (usage, reset-credits list, consume).
Mirrors the Codex CLI's PathStyle split (codex-rs backend-client): base URLs
containing ``/backend-api`` use the ChatGPT ``/wham/...`` paths; everything
else uses ``/api/codex/...``.
"""
def _resolve_codex_usage_url(base_url: str) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
normalized = "https://chatgpt.com/backend-api/codex"
if normalized.endswith("/codex"):
normalized = normalized[: -len("/codex")]
prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex")
return (
prefix + "/usage",
prefix + "/rate-limit-reset-credits",
prefix + "/rate-limit-reset-credits/consume",
)
if "/backend-api" in normalized:
return normalized + "/wham/usage"
return normalized + "/api/codex/usage"
def _resolve_codex_usage_url(base_url: str) -> str:
return _codex_backend_urls(base_url)[0]
def _resolve_codex_usage_credentials(
base_url: Optional[str],
api_key: Optional[str],
) -> tuple[str, str, Optional[str]]:
"""Resolve Codex quota credentials from the native runtime path.
Prefer explicit live-agent credentials, then the legacy singleton OAuth
state, then the credential pool. Hermes's native OAuth setup now stores
device-code logins in the pool, so quota diagnostics must not depend only
on the older singleton store.
"""
explicit_key = str(api_key or "").strip()
if explicit_key:
return explicit_key, str(base_url or "").strip(), None
# Tier 2: the native runtime resolver. It ALREADY falls back to the
# credential pool when the singleton is empty (see
# ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only
# setup this returns a usable ``source="credential_pool"`` token.
#
# Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can
# run: a broad ``except Exception`` would (a) mask a transient refresh /
# network failure and silently hand back a DIFFERENT pool account's usage,
# and (b) hide genuine programming errors. A refresh/network error must
# propagate — the outer ``fetch_account_usage`` guard fails open (shows
# nothing this turn) rather than reporting the wrong account.
#
# The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read
# best-effort: a partial/missing singleton token store must not sink an
# otherwise-usable resolver credential and force a header-less pool fallback.
try:
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
account_id: Optional[str] = None
try:
token_data = _read_codex_tokens()
tokens = token_data.get("tokens") or {}
account_id = str(tokens.get("account_id", "") or "").strip() or None
except AuthError:
# Pool-only creds carry no singleton account_id; header is optional.
logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True)
return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id
except AuthError:
logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True)
# Tier 3: direct pool select. Reached only when the resolver itself raises
# AuthError (e.g. singleton missing AND its own pool read found nothing at
# resolve time, but a pool entry is usable now). Pool credentials have no
# account_id concept, so the ChatGPT-Account-Id header is intentionally
# omitted here.
from agent.credential_pool import load_pool
pool = load_pool("openai-codex")
entry = pool.select()
if entry is None:
raise RuntimeError("No available openai-codex credential in credential pool")
return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None
def _fetch_codex_account_usage(
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Optional[AccountUsageSnapshot]:
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
token_data = _read_codex_tokens()
tokens = token_data.get("tokens") or {}
account_id = str(tokens.get("account_id", "") or "").strip() or None
headers = {
"Authorization": f"Bearer {token}",
"Authorization": f"Bearer {creds['api_key']}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
if account_id:
headers["ChatGPT-Account-Id"] = account_id
with httpx.Client(timeout=15.0) as client:
response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers)
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
response.raise_for_status()
payload = response.json() or {}
rate_limit = payload.get("rate_limit") or {}
@@ -538,14 +467,6 @@ def _fetch_codex_account_usage(
)
)
details: list[str] = []
reset_credits = payload.get("rate_limit_reset_credits") or {}
banked = reset_credits.get("available_count")
if isinstance(banked, (int, float)) and int(banked) > 0:
count = int(banked)
plural = "s" if count != 1 else ""
details.append(
f"You have {count} reset{plural} banked - use /usage reset to activate"
)
credits = payload.get("credits") or {}
if credits.get("has_credits"):
balance = credits.get("balance")
@@ -563,179 +484,6 @@ def _fetch_codex_account_usage(
)
@dataclass(frozen=True)
class CodexResetRedeemResult:
"""Outcome of a `/usage reset` attempt against the Codex backend."""
status: str # reset | nothing_to_reset | no_credit | already_redeemed |
# not_exhausted | no_credits_banked | unavailable
message: str
available_count: int = 0
windows_reset: int = 0
@property
def redeemed(self) -> bool:
return self.status == "reset"
# Client-side guard threshold: a rate-limit window only counts as exhausted
# when it is fully used. Below this, redeeming a banked reset wastes most of
# its value, so we block and point at --force instead.
_CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0
def redeem_codex_reset_credit(
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
force: bool = False,
) -> CodexResetRedeemResult:
"""Redeem one banked Codex rate-limit reset credit (`/usage reset`).
Flow (mirrors the Codex CLI's reset-credits picker, codex-rs
``backend-client``):
1. ``GET .../usage`` read the current windows + banked credit count.
2. Guard: zero banked credits refuse. No window fully used and not
``force`` refuse with a warning (a banked reset restores the WHOLE
5h + weekly allowance; burning it early wastes it). The backend has
the same protection (``nothing_to_reset`` doesn't consume the
credit), but failing fast client-side gives a clearer message.
3. ``POST .../rate-limit-reset-credits/consume`` with a fresh UUID
idempotency key (``redeem_request_id``). No ``credit_id`` the
backend picks the next available credit, exactly like the CLI's
default "Full reset" option.
Never raises: every failure mode returns a ``CodexResetRedeemResult``
with a user-renderable message.
"""
import uuid
try:
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
except Exception:
return CodexResetRedeemResult(
status="unavailable",
message="No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.",
)
usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
if account_id:
headers["ChatGPT-Account-Id"] = account_id
try:
with httpx.Client(timeout=15.0) as client:
usage_resp = client.get(usage_url, headers=headers)
usage_resp.raise_for_status()
payload = usage_resp.json() or {}
reset_credits = payload.get("rate_limit_reset_credits") or {}
raw_count = reset_credits.get("available_count")
available = int(raw_count) if isinstance(raw_count, (int, float)) else 0
if available <= 0:
return CodexResetRedeemResult(
status="no_credits_banked",
message="No banked reset credits on this account — nothing to redeem.",
)
rate_limit = payload.get("rate_limit") or {}
worst_used: Optional[float] = None
for key in ("primary_window", "secondary_window"):
used = (rate_limit.get(key) or {}).get("used_percent")
if isinstance(used, (int, float)):
worst_used = max(worst_used or 0.0, float(used))
exhausted = worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT
if not exhausted and not force:
usage_note = (
f"your busiest window is only {worst_used:.0f}% used"
if worst_used is not None
else "your current usage could not be confirmed as exhausted"
)
plural = "s" if available != 1 else ""
return CodexResetRedeemResult(
status="not_exhausted",
message=(
f"⚠️ Not redeeming: {usage_note}. A banked reset restores your FULL "
f"5h + weekly limits, so spending it now would waste most of it. "
f"You have {available} reset{plural} banked. "
f"Use `/usage reset --force` to redeem anyway."
),
available_count=available,
)
consume_resp = client.post(
consume_url,
headers={**headers, "Content-Type": "application/json"},
json={"redeem_request_id": str(uuid.uuid4())},
)
consume_resp.raise_for_status()
body = consume_resp.json() or {}
except httpx.HTTPStatusError as exc:
code = exc.response.status_code
if code in (401, 403):
return CodexResetRedeemResult(
status="unavailable",
message=(
"Codex backend rejected the request (HTTP "
f"{code}). Reset credits require ChatGPT-account (OAuth) auth — "
"run `hermes auth` and sign in with your ChatGPT account."
),
)
return CodexResetRedeemResult(
status="unavailable",
message=f"Codex backend error (HTTP {code}) — try again shortly.",
)
except Exception as exc:
return CodexResetRedeemResult(
status="unavailable",
message=f"Could not reach the Codex backend: {exc}",
)
code = str(body.get("code", "") or "").strip().lower()
windows_reset = body.get("windows_reset")
windows_reset = int(windows_reset) if isinstance(windows_reset, (int, float)) else 0
remaining = max(0, available - 1)
plural = "s" if remaining != 1 else ""
if code == "reset":
return CodexResetRedeemResult(
status="reset",
message=(
f"✅ Reset redeemed — your usage limits have been reset. "
f"{remaining} banked reset{plural} remaining."
),
available_count=remaining,
windows_reset=windows_reset,
)
if code == "nothing_to_reset":
return CodexResetRedeemResult(
status="nothing_to_reset",
message=(
"Backend reports nothing to reset — your limits aren't exhausted. "
"The credit was NOT spent."
),
available_count=available,
)
if code == "no_credit":
return CodexResetRedeemResult(
status="no_credit",
message="Backend reports no available reset credit on this account.",
)
if code == "already_redeemed":
return CodexResetRedeemResult(
status="already_redeemed",
message="This redemption was already processed — no additional credit was spent.",
available_count=remaining,
)
return CodexResetRedeemResult(
status="unavailable",
message=f"Unexpected response from the Codex backend: {body!r}",
)
def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
token = (resolve_anthropic_token() or "").strip()
if not token:
@@ -880,7 +628,7 @@ def fetch_account_usage(
return None
try:
if normalized == "openai-codex":
return _fetch_codex_account_usage(base_url=base_url, api_key=api_key)
return _fetch_codex_account_usage()
if normalized == "anthropic":
return _fetch_anthropic_account_usage()
if normalized == "openrouter":
+55 -777
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+105 -271
View File
@@ -65,7 +65,6 @@ THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000}
# maps to low on every model. See:
# https://platform.claude.com/docs/en/about-claude/models/migration-guide
ADAPTIVE_EFFORT_MAP = {
"ultra": "max",
"max": "max",
"xhigh": "xhigh",
"high": "high",
@@ -127,8 +126,6 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
_ANTHROPIC_OUTPUT_LIMITS = {
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
"claude-fable": 128_000,
# Claude Sonnet 5
"claude-sonnet-5": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@@ -249,13 +246,7 @@ def _supports_adaptive_thinking(model: str) -> bool:
only returns False for the explicit legacy list of older Claude families
that require manual budget-based thinking. Non-Claude Anthropic-Messages
models (minimax, qwen3, ) return False so they keep the manual path.
Kimi / Moonshot models are the exception: their Anthropic-compatible
endpoints implement the adaptive contract (``thinking.type="adaptive"``
+ ``output_config.effort``, including ``xhigh`` and ``display``).
"""
if _model_name_is_kimi_family(model):
return True
if not _is_claude_model(model):
return False
m = model.lower()
@@ -457,8 +448,7 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``,
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
# Matched case-insensitively against the post-``normalize_model_name`` form,
# so a caller's ``provider/vendor/model`` slug is handled the same as a
# bare name.
@@ -468,14 +458,8 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
"k1.", "k1-",
"k2.", "k2-",
"k25", "k2.5",
"k3.", "k3-",
)
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
# merely start with the same characters don't get misclassified.
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
def _model_name_is_kimi_family(model: str | None) -> bool:
if not isinstance(model, str):
@@ -486,8 +470,6 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
if "/" in m:
m = m.rsplit("/", 1)[-1]
if m in _KIMI_FAMILY_EXACT_SLUGS:
return True
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
@@ -551,9 +533,8 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
Some third-party /anthropic endpoints implement Anthropic's Messages API but
require Authorization: Bearer instead of Anthropic's native x-api-key header.
MiniMax's global and China Anthropic-compatible endpoints, Azure AI
Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy
follow this pattern.
MiniMax's global and China Anthropic-compatible endpoints, and Azure AI
Foundry's Anthropic-style endpoint follow this pattern.
"""
normalized = _normalize_base_url_text(base_url)
if not normalized:
@@ -562,11 +543,6 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
return (
normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
or "azure.com" in normalized
# Palantir Foundry LLM proxy (<org>.palantirfoundry.com/api/v2/llm/proxy/anthropic)
# rejects x-api-key with 401 and requires Authorization: Bearer.
# Hostname match (not substring) so e.g. evil.com/palantirfoundry
# paths don't trigger Bearer auth.
or base_url_host_matches(normalized, "palantirfoundry.com")
)
@@ -697,9 +673,6 @@ def _build_anthropic_client_with_bearer_hook(
kwargs = {
"timeout": timeout_obj,
"http_client": http_client,
# Delegate retry to hermes's outer loop (honors Retry-After); the SDK
# default max_retries=2 ignores it and double-retries. (#26293)
"max_retries": 0,
# The SDK requires *something* for api_key/auth_token. Our
# event hook overrides Authorization per request so this value
# is never sent. The sentinel string makes accidental leaks
@@ -784,12 +757,6 @@ def build_anthropic_client(
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
kwargs = {
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
# Delegate all rate-limit / 5xx retry to hermes's outer conversation
# loop, which honors Retry-After. The SDK default (max_retries=2) uses
# its own 1-2s backoff that ignores Retry-After and double-retries
# inside our loop — burning request slots against a bucket that won't
# refill for minutes. (#26293)
"max_retries": 0,
}
if normalized_base_url:
# Azure Anthropic endpoints require an ``api-version`` query parameter.
@@ -841,7 +808,7 @@ def build_anthropic_client(
kwargs["auth_token"] = api_key
kwargs["default_headers"] = {
"anthropic-beta": ",".join(all_betas),
"user-agent": f"claude-code/{_get_claude_code_version()} (external, cli)",
"user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
"x-app": "cli",
}
else:
@@ -885,9 +852,6 @@ def build_anthropic_bedrock_client(region: str):
return _anthropic_sdk.AnthropicBedrock(
aws_region=region,
timeout=Timeout(timeout=900.0, connect=10.0),
# Delegate retry to hermes's outer loop (honors Retry-After); the SDK
# default max_retries=2 ignores it and double-retries. (#26293)
max_retries=0,
default_headers={"anthropic-beta": ",".join([*_COMMON_BETAS, _CONTEXT_1M_BETA])},
)
@@ -950,72 +914,44 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
return None
def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
"""Read Claude Code OAuth credentials from ~/.claude/.credentials.json.
Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
"""
cred_path = Path.home() / ".claude" / ".credentials.json"
if not cred_path.exists():
return None
try:
data = json.loads(cred_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, IOError) as e:
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
return None
oauth_data = data.get("claudeAiOauth")
if not (oauth_data and isinstance(oauth_data, dict)):
return None
access_token = oauth_data.get("accessToken", "")
if not access_token:
return None
return {
"accessToken": access_token,
"refreshToken": oauth_data.get("refreshToken", ""),
"expiresAt": oauth_data.get("expiresAt", 0),
"source": "claude_code_credentials_file",
}
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
"""Read refreshable Claude Code OAuth credentials.
Reads from two possible sources and reconciles them:
Checks two sources in order:
1. macOS Keychain (Darwin only) "Claude Code-credentials" entry
2. ~/.claude/.credentials.json file
Selection rules when both are present:
- If exactly one is non-expired, prefer that one. (Handles the case
where Claude Code refreshes one source but not the other observed
in the wild on Claude Code 2.1.x.)
- Otherwise, prefer the source with the later ``expiresAt`` so that
any subsequent refresh uses the most recent ``refreshToken``.
This intentionally excludes ~/.claude.json primaryApiKey. Opencode's
subscription flow is OAuth/setup-token based with refreshable credentials,
and native direct Anthropic provider usage should follow that path rather
than auto-detecting Claude's first-party managed key.
Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
"""
# Try macOS Keychain first (covers Claude Code >=2.1.114)
kc_creds = _read_claude_code_credentials_from_keychain()
file_creds = _read_claude_code_credentials_from_file()
if kc_creds:
return kc_creds
if kc_creds and file_creds:
kc_valid = is_claude_code_token_valid(kc_creds)
file_valid = is_claude_code_token_valid(file_creds)
if kc_valid and not file_valid:
return kc_creds
if file_valid and not kc_valid:
return file_creds
# Both valid or both expired: prefer the later expiresAt so the
# downstream refresh path uses the freshest refresh_token.
kc_exp = kc_creds.get("expiresAt", 0) or 0
file_exp = file_creds.get("expiresAt", 0) or 0
return kc_creds if kc_exp >= file_exp else file_creds
# Fall back to JSON file
cred_path = Path.home() / ".claude" / ".credentials.json"
if cred_path.exists():
try:
data = json.loads(cred_path.read_text(encoding="utf-8"))
oauth_data = data.get("claudeAiOauth")
if oauth_data and isinstance(oauth_data, dict):
access_token = oauth_data.get("accessToken", "")
if access_token:
return {
"accessToken": access_token,
"refreshToken": oauth_data.get("refreshToken", ""),
"expiresAt": oauth_data.get("expiresAt", 0),
"source": "claude_code_credentials_file",
}
except (json.JSONDecodeError, OSError, IOError) as e:
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
return kc_creds or file_creds
return None
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
@@ -1069,7 +1005,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
data=data,
headers={
"Content-Type": content_type,
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
},
method="POST",
)
@@ -1098,40 +1034,8 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
"""Attempt to refresh an expired Claude Code OAuth token.
Claude Code's OAuth refresh tokens are single-use: a successful refresh
rotates the pair and invalidates the old refresh token. Claude Code itself
also refreshes on its own schedule (IDE/CLI activity), so by the time
Hermes notices an expired token, Claude Code may have already rotated it.
POSTing our now-stale refresh token in that window races Claude Code and
fails with ``invalid_grant``.
So before refreshing, re-read the live credential sources. If Claude Code
has already produced a valid token, adopt it and skip the POST entirely.
Only fall back to refreshing ourselves when no fresh credential is found.
"""
# Claude Code may have already refreshed — adopt its token rather than
# racing it with our (possibly already-rotated) refresh token. Only adopt
# when the live re-read produced a DIFFERENT token with a real future
# expiry: re-adopting the same credential we were just handed would be a
# no-op, and a 0/absent ``expiresAt`` means "managed key / unknown expiry"
# (see is_claude_code_token_valid) which must NOT be treated as a fresh
# refresh here.
current = read_claude_code_credentials()
if current:
current_token = current.get("accessToken", "")
current_exp = current.get("expiresAt", 0) or 0
if (
current_token
and current_token != creds.get("accessToken", "")
and current_exp > 0
and is_claude_code_token_valid(current)
):
logger.debug("Adopted Claude Code's already-refreshed OAuth token")
return current_token
refresh_token = (current or {}).get("refreshToken", "") or creds.get("refreshToken", "")
"""Attempt to refresh an expired Claude Code OAuth token."""
refresh_token = creds.get("refreshToken", "")
if not refresh_token:
logger.debug("No refresh token available — cannot refresh")
return None
@@ -1402,20 +1306,9 @@ _OAUTH_TOKEN_URLS = [
"https://console.anthropic.com/v1/oauth/token",
]
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
# User-Agent sent on the OAuth *token endpoint* (login exchange + refresh).
# Anthropic rate-limits (HTTP 429) any token-endpoint request whose UA starts
# with ``claude-code/`` — verified empirically against platform.claude.com:
# ``claude-code/2.1.200`` and ``Mozilla/5.0`` -> 429; ``axios/*``, ``node``,
# and SDK-style UAs -> 400 (reached code validation). The real Claude Code CLI
# exchanges the auth code with a bare axios client (``axios/<ver>``), NOT its
# ``claude-code/`` inference UA. We mirror that here. NOTE: the *inference* path
# (build_anthropic_kwargs) still uses the ``claude-code/`` UA + ``x-app: cli`` —
# that fingerprint is required there and is NOT throttled on the messages API.
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
def _get_hermes_oauth_file() -> Path:
return get_hermes_home() / ".anthropic_oauth.json"
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
def _generate_pkce() -> tuple:
@@ -1513,9 +1406,6 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
# console.anthropic.com now 404s. Try the new host first, then fall
# back to console for older deployments (mirrors the refresh path).
# UA is _OAUTH_TOKEN_USER_AGENT (a non-claude-code UA) — see the
# constant's definition for why the token endpoint must not send
# claude-code/ (429 UA-prefix block).
result = None
last_error = None
for endpoint in _OAUTH_TOKEN_URLS:
@@ -1524,7 +1414,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
data=exchange_data,
headers={
"Content-Type": "application/json",
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
},
method="POST",
)
@@ -1563,10 +1453,9 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
oauth_file = _get_hermes_oauth_file()
if oauth_file.exists():
if _HERMES_OAUTH_FILE.exists():
try:
data = json.loads(oauth_file.read_text(encoding="utf-8"))
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
if data.get("accessToken"):
return data
except (json.JSONDecodeError, OSError, IOError) as e:
@@ -1591,10 +1480,7 @@ def _is_bedrock_model_id(model: str) -> bool:
"""
lower = model.lower()
# Regional inference-profile prefixes
if any(lower.startswith(p) for p in (
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.",
"ca.", "sa.", "me.", "af.",
)):
if any(lower.startswith(p) for p in ("global.", "us.", "eu.", "ap.", "jp.")):
return True
# Bare Bedrock model IDs: provider.model-family
if lower.startswith("anthropic."):
@@ -1933,18 +1819,6 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return None
def _apply_assistant_cache_control_to_last_cacheable_block(
blocks: List[Dict[str, Any]],
cache_control: Any,
) -> None:
if not isinstance(cache_control, dict):
return
for block in reversed(blocks):
if isinstance(block, dict) and block.get("type") in {"text", "tool_use"}:
block.setdefault("cache_control", dict(cache_control))
break
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.
@@ -1999,9 +1873,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
clean["input"] = redacted
replayed.append(clean)
if replayed:
_apply_assistant_cache_control_to_last_cacheable_block(
replayed, m.get("cache_control")
)
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
@@ -2027,9 +1898,6 @@ 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
@@ -2129,7 +1997,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
if isinstance(content, list):
converted_blocks = _convert_content_to_anthropic(content)
if not converted_blocks or all(
(b.get("text") or "").strip() == ""
b.get("text", "").strip() == ""
for b in converted_blocks
if isinstance(b, dict) and b.get("type") == "text"
):
@@ -2145,81 +2013,57 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
"""Strip tool_use blocks with no matching tool_result, and vice versa.
Context compression or session truncation can remove either side of a
tool-call pair, or insert messages between a tool_use and its result.
Anthropic requires each tool_use to have a matching tool_result in the
IMMEDIATELY FOLLOWING user message a global ID match is not enough.
tool-call pair. Anthropic rejects both orphans with HTTP 400.
Mutates ``result`` in place.
"""
# Pass 1: For each assistant message with tool_use blocks, check that
# EACH tool_use ID has a matching tool_result in the immediately following
# user message. Strip tool_use blocks that lack an adjacent result —
# Anthropic rejects non-adjacent pairs with HTTP 400 even when the IDs
# match somewhere later in the conversation.
for i, m in enumerate(result):
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
continue
tool_use_ids_in_turn = {
b.get("id")
for b in m["content"]
if isinstance(b, dict) and b.get("type") == "tool_use"
}
if not tool_use_ids_in_turn:
continue
# Collect result IDs from the immediately following user message only.
adjacent_result_ids: set = set()
if i + 1 < len(result):
nxt = result[i + 1]
if nxt.get("role") == "user" and isinstance(nxt.get("content"), list):
for block in nxt["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
adjacent_result_ids.add(block.get("tool_use_id"))
orphaned = tool_use_ids_in_turn - adjacent_result_ids
if not orphaned:
continue
kept = [
b
for b in m["content"]
if not (isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") in orphaned)
]
# If stripping an orphaned tool_use mutated a turn that also carries a
# signed thinking block, that block's Anthropic signature was computed
# against the ORIGINAL (un-stripped) turn content and is now invalid.
# Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
# the latest assistant message cannot be modified". Flag the turn so
# _manage_thinking_signatures can demote the dead signature instead of
# replaying it verbatim. See hermes-agent: extended-thinking + parallel
# tool batch interrupted mid-flight → non-retryable 400 crash-loop.
if len(kept) != len(m["content"]) and any(
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
for b in m["content"]
):
m["_thinking_signature_invalidated"] = True
m["content"] = kept if kept else [{"type": "text", "text": "(tool call removed)"}]
# Pass 2: Rebuild the set of tool_use IDs that survived pass 1, then
# strip tool_result blocks that no longer have any matching tool_use
# anywhere in the conversation.
surviving_tool_use_ids: set = set()
# Strip orphaned tool_use blocks (no matching tool_result follows)
tool_result_ids = set()
for m in result:
if m.get("role") == "assistant" and isinstance(m.get("content"), list):
if m["role"] == "user" and isinstance(m["content"], list):
for block in m["content"]:
if isinstance(block, dict) and block.get("type") == "tool_use":
surviving_tool_use_ids.add(block.get("id"))
if block.get("type") == "tool_result":
tool_result_ids.add(block.get("tool_use_id"))
for m in result:
if m.get("role") != "user" or not isinstance(m.get("content"), list):
continue
new_content = [
b
for b in m["content"]
if not (isinstance(b, dict) and b.get("type") == "tool_result")
or b.get("tool_use_id") in surviving_tool_use_ids
]
if len(new_content) != len(m["content"]):
m["content"] = new_content if new_content else [{"type": "text", "text": "(tool result removed)"}]
if m["role"] == "assistant" and isinstance(m["content"], list):
kept = [
b
for b in m["content"]
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
]
# If stripping an orphaned tool_use mutated a turn that also carries a
# signed thinking block, that block's Anthropic signature was computed
# against the ORIGINAL (un-stripped) turn content and is now invalid.
# Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
# the latest assistant message cannot be modified". Flag the turn so
# _manage_thinking_signatures can demote the dead signature instead of
# replaying it verbatim. See hermes-agent: extended-thinking + parallel
# tool batch interrupted mid-flight → non-retryable 400 crash-loop.
if len(kept) != len(m["content"]) and any(
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
for b in m["content"]
):
m["_thinking_signature_invalidated"] = True
m["content"] = kept
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
# Strip orphaned tool_result blocks (no matching tool_use precedes them)
tool_use_ids = set()
for m in result:
if m["role"] == "assistant" and isinstance(m["content"], list):
for block in m["content"]:
if block.get("type") == "tool_use":
tool_use_ids.add(block.get("id"))
for m in result:
if m["role"] == "user" and isinstance(m["content"], list):
m["content"] = [
b
for b in m["content"]
if b.get("type") != "tool_result" or b.get("tool_use_id") in tool_use_ids
]
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool result removed)"}]
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -2296,6 +2140,13 @@ def _manage_thinking_signatures(
"""
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
# Kimi / DeepSeek share a contract: strip signed Anthropic blocks
# (neither upstream can validate Anthropic signatures), preserve unsigned
# ones synthesised from reasoning_content. See #13848, #16748.
_preserve_unsigned_thinking = (
_is_kimi_family_endpoint(base_url, model)
or _is_deepseek_anthropic_endpoint(base_url)
)
last_assistant_idx = None
for i in range(len(result) - 1, -1, -1):
@@ -2307,12 +2158,8 @@ def _manage_thinking_signatures(
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
continue
if _is_kimi_family_endpoint(base_url, model):
# Kimi does not enforce thinking signatures — replay as-is
# (shared cleanup below still strips cache markers + the internal flag).
pass
elif _is_deepseek_anthropic_endpoint(base_url):
# DeepSeek: strip signed, preserve unsigned.
if _preserve_unsigned_thinking:
# Kimi / DeepSeek: strip signed, preserve unsigned.
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
@@ -2412,24 +2259,6 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
]
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
"""Anthropic requires messages[0] to have role=user.
After a second context compaction on the auto path the summary can be
emitted as role=assistant with nothing in front of it (the system prompt
lives outside messages[] or is extracted into the separate ``system``
param), so messages[0] ends up assistant and the Messages API rejects
the request with HTTP 400 often masked by a misleading
"tool_use ids were found without tool_result blocks" error (#52160).
Mirror the Bedrock Converse adapter, which unconditionally prepends a
minimal user turn when the first message is not user
(convert_messages_to_converse).
"""
if result and result[0].get("role") != "user":
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
@@ -2488,7 +2317,6 @@ def convert_messages_to_anthropic(
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_ensure_leading_user_turn(result)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)
@@ -2663,19 +2491,25 @@ def build_anthropic_kwargs(
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
#
# Kimi / Moonshot models also use adaptive thinking: their
# Anthropic-compatible endpoints (api.moonshot.cn/anthropic,
# api.kimi.com/coding) accept ``thinking.type="adaptive"`` +
# ``output_config.effort``, and the replay-validation 400s that
# originally motivated dropping the parameter (#13848) no longer
# occur. (Kimi on chat_completions enables thinking via extra_body
# in the ChatCompletionsTransport — see #13503.)
# Kimi's /coding endpoint speaks the Anthropic Messages protocol but has
# its own thinking semantics: when ``thinking.enabled`` is sent, Kimi
# validates the message history and requires every prior assistant
# tool-call message to carry OpenAI-style ``reasoning_content``. The
# Anthropic path never populates that field, and
# ``convert_messages_to_anthropic`` strips all Anthropic thinking blocks
# on third-party endpoints — so the request fails with HTTP 400
# "thinking is enabled but reasoning_content is missing in assistant
# tool call message at index N". Kimi's reasoning is driven server-side
# on the /coding route, so skip Anthropic's thinking parameter entirely
# for that host. (Kimi on chat_completions enables thinking via
# extra_body in the ChatCompletionsTransport — see #13503.)
#
# On 4.7+ the `thinking.display` field defaults to "omitted", which
# silently hides reasoning text that Hermes surfaces in its CLI. We
# request "summarized" so the reasoning blocks stay populated — matching
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
if reasoning_config and isinstance(reasoning_config, dict):
_is_kimi_coding = _is_kimi_family_endpoint(base_url, model)
if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding:
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
effort = str(reasoning_config.get("effort", "medium")).lower()
budget = THINKING_BUDGET.get(effort, 8000)
-16
View File
@@ -66,19 +66,3 @@ def safe_schedule_threadsafe(
coro.close()
log.log(log_level, "%s: %s", log_message, exc)
return None
def consume_detached_task_result(task: "asyncio.Future[Any]") -> None:
"""Retrieve a detached task's result without surfacing cancellation.
Used as an ``add_done_callback`` on tasks that were cancelled and
detached (e.g. an adapter close path that swallows ``CancelledError``
past its teardown deadline). Observing ``task.exception()`` prevents
"exception was never retrieved" noise on the event loop; cancellation
and any terminal error are deliberately swallowed the task's owner
already gave up on it.
"""
try:
task.exception()
except (asyncio.CancelledError, Exception):
pass
-138
View File
@@ -1,138 +0,0 @@
"""Ambient session-accounting context for auxiliary LLM calls.
Auxiliary calls (vision, compression, title generation, web_extract,
session_search, ...) funnel through ``agent.auxiliary_client`` which has no
session handle so their token usage was historically discarded, leaving
dashboard analytics blind to aux model spend (issue #23270).
Instead of threading ``session_db``/``session_id`` parameters through every
aux call site, the agent loop publishes them here (mirroring the Nous Portal
conversation context in ``agent.portal_tags``) and the auxiliary client
records usage at its single response-validation chokepoint.
ContextVar semantics give us the right isolation for free:
* concurrent agents in one process (gateway sessions, delegate subagents)
never see each other's accounting context;
* worker threads spawned via ``tools.thread_context.propagate_context_to_thread``
(MoA fan-out, background review) inherit the parent turn's context;
* asyncio tasks inherit the context of the code that created them.
MoA reference/aggregator slots are explicitly EXCLUDED from recording:
``agent/conversation_loop.py`` already folds MoA advisor usage and cost into
the main loop's ``update_token_counts`` delta, so recording them here would
double-count (see ``_EXCLUDED_TASKS``).
"""
from __future__ import annotations
import logging
from contextvars import ContextVar
from typing import Any, Optional
logger = logging.getLogger(__name__)
# (session_db, session_id) for the active agent turn, or None outside one.
_accounting: ContextVar[Optional[tuple]] = ContextVar(
"aux_accounting_context", default=None
)
# Aux tasks whose usage is already accounted by the main loop — recording
# them here would double-count. MoA advisor/aggregator usage is folded into
# conversation_loop's update_token_counts delta (tokens AND cost).
_EXCLUDED_TASKS = frozenset({"moa_reference", "moa_aggregator"})
def set_accounting_context(session_db: Any, session_id: Optional[str]):
"""Publish the active session's accounting handles for aux usage recording.
Called by the agent loop at turn entry. Returns the ContextVar token so
callers can ``reset_accounting_context(token)`` on turn exit. Publishing
``None`` handles (no DB / no session id) clears the context.
"""
if session_db is None or not session_id:
return _accounting.set(None)
return _accounting.set((session_db, session_id))
def reset_accounting_context(token) -> None:
"""Restore the previous accounting context (pair with ``set_...``)."""
try:
_accounting.reset(token)
except Exception:
_accounting.set(None)
def get_accounting_context() -> Optional[tuple]:
"""Return ``(session_db, session_id)`` for the active turn, or ``None``."""
return _accounting.get()
def record_aux_usage(
response: Any,
task: Optional[str],
*,
provider: Optional[str] = None,
base_url: Optional[str] = None,
) -> None:
"""Record an auxiliary response's token usage against the ambient session.
Called from the auxiliary client's response-validation chokepoint. Strictly
best-effort: any failure is swallowed (accounting must never break an aux
call). No-ops when:
* no accounting context is published (call is outside any agent turn),
* the task is main-loop-accounted (MoA slots see ``_EXCLUDED_TASKS``),
* the response carries no usage object.
The model is read from ``response.model`` (accurate even after the aux
client's provider-fallback chains); *provider*/*base_url* reflect the
originally-resolved route and are best-effort.
"""
try:
if not task or task in _EXCLUDED_TASKS:
return
ctx = _accounting.get()
if ctx is None:
return
session_db, session_id = ctx
raw_usage = getattr(response, "usage", None)
if raw_usage is None:
return
from agent.usage_pricing import estimate_usage_cost, normalize_usage
usage = normalize_usage(raw_usage, provider=provider)
if not (
usage.input_tokens or usage.output_tokens
or usage.cache_read_tokens or usage.cache_write_tokens
or usage.reasoning_tokens
):
return
model = str(getattr(response, "model", "") or "") or "unknown"
estimated_cost = None
try:
cost = estimate_usage_cost(
model, usage, provider=provider, base_url=base_url
)
if cost.amount_usd is not None:
estimated_cost = float(cost.amount_usd)
except Exception:
logger.debug("Aux usage cost estimation failed", exc_info=True)
session_db.record_auxiliary_usage(
session_id,
task,
model=model,
billing_provider=provider,
billing_base_url=base_url,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cache_read_tokens=usage.cache_read_tokens,
cache_write_tokens=usage.cache_write_tokens,
reasoning_tokens=usage.reasoning_tokens,
estimated_cost_usd=estimated_cost,
)
except Exception:
logger.debug("Aux usage recording failed (non-fatal)", exc_info=True)
+235 -1736
View File
File diff suppressed because it is too large Load Diff
+26 -143
View File
@@ -18,13 +18,12 @@ for invariants and PR review criteria.
from __future__ import annotations
import contextlib
import json
import logging
import os
from typing import Any, Dict, List, Optional
from agent.thread_scoped_output import thread_scoped_silence
logger = logging.getLogger(__name__)
@@ -62,11 +61,6 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
"api_key": parent_runtime.get("api_key") or None,
"base_url": parent_runtime.get("base_url") or None,
"api_mode": parent_api_mode,
"credential_pool": getattr(agent, "_credential_pool", None),
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
"max_tokens": getattr(agent, "max_tokens", None),
"command": getattr(agent, "acp_command", None),
"args": list(getattr(agent, "acp_args", []) or []),
"routed": False,
}
try:
@@ -94,15 +88,10 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
)
return {
"provider": rp.get("provider") or task_provider,
"model": rp.get("model") or task_model,
"model": task_model,
"api_key": rp.get("api_key"),
"base_url": rp.get("base_url"),
"api_mode": rp.get("api_mode"),
"credential_pool": rp.get("credential_pool"),
"request_overrides": dict(rp.get("request_overrides") or {}),
"max_tokens": rp.get("max_output_tokens"),
"command": rp.get("command"),
"args": list(rp.get("args") or []),
"routed": True,
}
except Exception as e:
@@ -459,21 +448,10 @@ def summarize_background_review_actions(
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
continue
# ``data`` may not be a dict — some memory/skill tool responses in
# older codepaths or wrapper MCP servers return a top-level JSON
# list (e.g. ``[{"success": true, ...}]``) or a scalar. The original
# isinstance check below silently skips non-dict payloads, which
# is correct, but ``data.get("_change")`` further down can still
# hand back a list and break ``change.get("description", "")``.
# Defensively normalize everything through a dict-typed alias so
# the rest of the function can stay terse without per-call
# ``isinstance`` guards (#59437).
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
detail = call_details.get(tcid) or {}
if not isinstance(detail, dict):
detail = {}
detail = call_details.get(tcid, {})
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
@@ -501,30 +479,12 @@ def summarize_background_review_actions(
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
# ``operations`` may be anything callable put into the JSON
# arguments. Anything non-iterable that isn't a list[str]
# of dicts becomes unusable here, so coerce defensively.
ops_raw = detail.get("operations")
operations: list = (
ops_raw if isinstance(ops_raw, list) else []
)
operations = detail.get("operations") or []
max_preview = 120
if is_skill:
# ``_change`` is a free-form dict the skill tool leaves in
# the response. Older / wrapper MCP backends return it
# as a list, an int, or a JSON-shaped scalar — normalize
# to a dict so the .get() calls downstream don't
# AttributeError (#59437).
change_raw = data.get("_change")
change: dict = (
change_raw if isinstance(change_raw, dict) else {}
)
old_string = (
change.get("old", "") or detail.get("old_string", "")
)
new_string = (
change.get("new", "") or detail.get("new_string", "")
)
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
@@ -545,13 +505,7 @@ def summarize_background_review_actions(
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
# Each element must be a dict-of-fields; some
# legacy codepaths serialize the entry as a bare
# string and the message dict doesn't exist. Skip
# non-dict items defensively — they have no
# actionable fields anyway (#59437).
if not isinstance(op, dict):
continue
op = op or {}
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
@@ -648,15 +602,9 @@ def _run_review_in_thread(
review_agent = None
review_messages: List[Dict] = []
try:
# Silence stdout/stderr for THIS worker thread only. A process-global
# ``contextlib.redirect_stdout(devnull)`` here would also blank
# ``sys.stdout``/``sys.stderr`` for every other thread — including a
# gateway event-loop thread driving a Telegram long-poll — for the full
# duration of the review (tens of seconds), swallowing their console
# output (#55769 / #55925). ``thread_scoped_silence`` routes only this
# thread's writes to devnull and leaves all other threads on the real
# streams.
with thread_scoped_silence():
with open(os.devnull, "w", encoding="utf-8") as _devnull, \
contextlib.redirect_stdout(_devnull), \
contextlib.redirect_stderr(_devnull):
# Inherit the parent agent's live runtime (provider, model,
# base_url, api_key, api_mode) so the fork uses the exact
# same credentials the main turn is using. Without this,
@@ -690,25 +638,6 @@ def _run_review_in_thread(
# Match parent's toolset config so ``tools[]`` is byte-identical
# in the request body — Anthropic's cache key includes it.
# (The runtime whitelist below still restricts dispatch.)
_fork_kwargs: Dict[str, Any] = {}
if isinstance(_rt.get("max_tokens"), int):
_fork_kwargs["max_tokens"] = _rt["max_tokens"]
if isinstance(_rt.get("command"), str) and _rt["command"]:
_fork_kwargs["acp_command"] = _rt["command"]
_fork_kwargs["acp_args"] = _rt.get("args") or []
# Match parent's reasoning config so the fork's ``thinking`` /
# ``output_config`` are byte-identical in the request body —
# Anthropic's cache key is namespaced by ``thinking`` presence.
# Same-model path only: when routed to a different aux model the
# cache is cold regardless (parity buys nothing) and the parent's
# effort vocabulary may not be valid for the routed model/provider
# (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded
# unclamped; codex_responses passes ``max``/``ultra`` through
# unmapped except on gpt-5.6/xAI). Let the routed fork use
# provider defaults — matching the ``not _routed`` gate on
# _cached_system_prompt below.
if not _routed:
_fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None)
review_agent = AIAgent(
model=_rt.get("model") or agent.model,
max_iterations=16,
@@ -718,13 +647,11 @@ def _run_review_in_thread(
api_mode=_rt.get("api_mode"),
base_url=_rt.get("base_url") or None,
api_key=_rt.get("api_key") or None,
credential_pool=_rt.get("credential_pool"),
request_overrides=_rt.get("request_overrides") or {},
credential_pool=getattr(agent, "_credential_pool", None),
parent_session_id=agent.session_id,
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
skip_memory=True,
**_fork_kwargs,
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"
@@ -740,20 +667,6 @@ def _run_review_in_thread(
review_agent._user_profile_enabled = agent._user_profile_enabled
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
# PERSISTENCE ISOLATION (the curator-takeover root cause): the fork
# shares the parent's session_id (set below, for prompt-cache
# warmth), so without this it would write its harness turn ("Review
# the conversation above and update the skill library…") + its own
# response straight into the user's REAL session in state.db. On the
# user's next live turn the agent re-reads that injected user message
# as a standing instruction and "becomes" the curator, refusing the
# actual task. _persist_disabled hard-stops every DB write/lazy-open
# path (_flush_messages_to_session_db, _ensure_db_session,
# _get_session_db_for_recall); the review writes only to the skill
# and memory stores via its tools, which is all it needs.
review_agent._persist_disabled = True
review_agent._session_db = None
review_agent._session_json_enabled = False
# Suppress all status/warning emits from the fork so the
# user only sees the final successful-action summary.
# Without this, mid-review "Iteration budget exhausted",
@@ -812,17 +725,10 @@ def _run_review_in_thread(
clear_thread_tool_whitelist,
)
# Gate the built-in memory tool on the profile's memory_enabled flag.
# Hardcoding ["memory", "skills"] granted the review LLM the MEMORY.md
# read/write tool even when a profile set memory_enabled: false,
# contaminating a memory-disabled profile (#54937 layer 2).
review_toolsets = ["skills"]
if review_agent._memory_enabled or review_agent._user_profile_enabled:
review_toolsets.insert(0, "memory")
review_whitelist = {
t["function"]["name"]
for t in get_tool_definitions(
enabled_toolsets=review_toolsets,
enabled_toolsets=["memory", "skills"],
quiet_mode=True,
)
}
@@ -833,13 +739,6 @@ def _run_review_in_thread(
"{tool_name}. Only memory/skill tools are allowed."
),
)
try:
from tools.skill_manager_tool import _reset_background_review_read_marks
_reset_background_review_read_marks()
except Exception:
pass
try:
# Routed to a different model -> replay a digest (cache is cold
# on that model anyway, so minimise cold-written tokens). Same
@@ -885,29 +784,11 @@ def _run_review_in_thread(
# the review agent inherits that history and would otherwise
# re-surface stale "created"/"updated" messages from the prior
# conversation as if they just happened (issue #14944).
#
# Wrapped in try/except: a buggy/legacy tool response shape
# (e.g. ``_change`` returned as a list instead of a dict, #59437)
# must NOT take down the whole review with an AttributeError,
# since the caller's outer except logs only "Background
# memory/skill review failed" and discards every successful
# action the fork DID complete before the crash. Coerce an
# exception into an empty actions list so the partial valid
# actions from earlier in the messages are returned instead.
try:
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
except Exception as e:
logger.warning(
"summarize_background_review_actions returned partial results "
"after exception (treating as empty); suppressing AttributeError "
"that previously aborted the entire review (#59437): %s",
e,
)
actions = []
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
if actions:
summary = " · ".join(dict.fromkeys(actions))
@@ -927,14 +808,16 @@ def _run_review_in_thread(
logger.warning("Background memory/skill review failed: %s", e)
agent._emit_auxiliary_failure("background review", e)
finally:
# Safety-net cleanup for the exception path. Normal completion already
# shut down inside the thread-scoped silence above. Re-enter the
# thread-scoped silence here so teardown output (Honcho flush, Hindsight
# sync, background thread joins) stays quiet even on the exception path,
# without blanking other threads' streams.
# Safety-net cleanup for the exception path. Normal
# completion already shut down inside redirect_stdout above.
# Re-open devnull here so any teardown output (Honcho flush,
# Hindsight sync, background thread joins) stays silent even
# on the exception path where redirect_stdout already exited.
if review_agent is not None:
try:
with thread_scoped_silence():
with open(os.devnull, "w", encoding="utf-8") as _fn, \
contextlib.redirect_stdout(_fn), \
contextlib.redirect_stderr(_fn):
try:
review_agent.shutdown_memory_provider()
except Exception:
-131
View File
@@ -1,131 +0,0 @@
"""System-battery read-out for the CLI/TUI status bar.
Reads the host battery through ``psutil`` (already a Hermes dependency) and
exposes a compact, colour-coded label. Everything degrades to "unavailable"
when there is no battery (desktops, servers, VMs) or when the read fails, so
callers can render the result unconditionally and simply show nothing.
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
:func:`read_battery` memoises the last reading for a few seconds instead of
hitting ``psutil`` on every frame.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class BatteryStatus:
"""A single battery reading.
``available`` is False on machines without a battery (or when the read
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
power, False on battery, and None when the platform can't tell.
"""
available: bool
percent: Optional[int] = None
plugged: Optional[bool] = None
@property
def charging(self) -> bool:
return bool(self.plugged)
UNAVAILABLE = BatteryStatus(available=False)
# Colour buckets, mirroring the status-bar context styles but inverted (a full
# battery is "good", an empty one is "critical").
CATEGORY_GOOD = "good"
CATEGORY_WARN = "warn"
CATEGORY_BAD = "bad"
CATEGORY_CRITICAL = "critical"
CATEGORY_DIM = "dim"
_CACHE_TTL_SECONDS = 8.0
_cache: Optional[tuple[float, BatteryStatus]] = None
def _read_battery_uncached() -> BatteryStatus:
try:
import psutil
except Exception:
return UNAVAILABLE
# ``sensors_battery`` is missing on some platforms/builds of psutil.
reader = getattr(psutil, "sensors_battery", None)
if reader is None:
return UNAVAILABLE
try:
batt = reader()
except Exception:
return UNAVAILABLE
if batt is None:
return UNAVAILABLE
percent: Optional[int] = None
raw_percent = getattr(batt, "percent", None)
if raw_percent is not None:
try:
percent = max(0, min(100, int(round(float(raw_percent)))))
except (TypeError, ValueError):
percent = None
plugged = getattr(batt, "power_plugged", None)
if plugged is not None:
plugged = bool(plugged)
return BatteryStatus(available=True, percent=percent, plugged=plugged)
def read_battery(use_cache: bool = True) -> BatteryStatus:
"""Return the current battery status (cached for a few seconds)."""
global _cache
if use_cache and _cache is not None:
ts, cached = _cache
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
return cached
status = _read_battery_uncached()
_cache = (time.monotonic(), status)
return status
def clear_cache() -> None:
"""Drop the memoised reading (used by tests)."""
global _cache
_cache = None
def battery_category(status: BatteryStatus) -> str:
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
if not status.available or status.percent is None:
return CATEGORY_DIM
# On AC power the level isn't a concern — always read as healthy.
if status.charging:
return CATEGORY_GOOD
pct = status.percent
if pct <= 10:
return CATEGORY_CRITICAL
if pct <= 20:
return CATEGORY_BAD
if pct <= 50:
return CATEGORY_WARN
return CATEGORY_GOOD
def battery_glyph(status: BatteryStatus) -> str:
"""Return the leading glyph: a bolt while charging, else a battery."""
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
def format_battery(status: BatteryStatus) -> str:
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
if not status.available or status.percent is None:
return ""
return f"{battery_glyph(status)} {status.percent}%"
+23 -204
View File
@@ -448,10 +448,7 @@ def is_anthropic_bedrock_model(model_id: str) -> bool:
"""
model_lower = model_id.lower()
# Strip regional prefix if present
for prefix in (
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.",
"ca.", "sa.", "me.", "af.",
):
for prefix in ("us.", "global.", "eu.", "ap.", "jp."):
if model_lower.startswith(prefix):
model_lower = model_lower[len(prefix):]
break
@@ -493,26 +490,6 @@ def convert_tools_to_converse(tools: List[Dict]) -> List[Dict]:
return result
# Bedrock's Converse API rejects any text content block whose text is empty
# OR whitespace-only (ValidationException: "text content blocks must contain
# non-whitespace text"). A lone space is whitespace and is rejected too — the
# placeholder MUST itself be non-whitespace. Ref: issue #9486.
_EMPTY_TEXT_PLACEHOLDER = "(empty)"
def _safe_text(text) -> str:
"""Return ``text`` if it's non-whitespace, else a non-whitespace placeholder.
Handles None, empty string, and whitespace-only string (spaces, tabs,
newlines) all of which Bedrock's Converse API rejects as text content.
"""
if text is None:
return _EMPTY_TEXT_PLACEHOLDER
if not isinstance(text, str):
text = str(text)
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
def _convert_content_to_converse(content) -> List[Dict]:
"""Convert OpenAI message content (string or list) to Converse content blocks.
@@ -520,27 +497,26 @@ def _convert_content_to_converse(content) -> List[Dict]:
- Plain text strings [{"text": "..."}]
- Content arrays with text/image_url parts mixed text/image blocks
Replaces empty/whitespace-only text blocks with a non-whitespace
placeholder Bedrock's Converse API rejects messages where a text
content block is empty or whitespace-only (ValidationException:
"text content blocks must contain non-whitespace text"). Ref: issue #9486.
Filters out empty text blocks Bedrock's Converse API rejects messages
where a text content block has an empty ``text`` field (ValidationException:
"text content blocks must be non-empty"). Ref: issue #9486.
"""
if content is None:
return [{"text": _safe_text(content)}]
return [{"text": " "}]
if isinstance(content, str):
return [{"text": _safe_text(content)}]
return [{"text": content}] if content.strip() else [{"text": " "}]
if isinstance(content, list):
blocks = []
for part in content:
if isinstance(part, str):
blocks.append({"text": _safe_text(part)})
blocks.append({"text": part})
continue
if not isinstance(part, dict):
continue
part_type = part.get("type", "")
if part_type == "text":
text = part.get("text", "")
blocks.append({"text": _safe_text(text)})
blocks.append({"text": text if text else " "})
elif part_type == "image_url":
image_url = part.get("image_url", {})
url = image_url.get("url", "") if isinstance(image_url, dict) else ""
@@ -552,27 +528,18 @@ def _convert_content_to_converse(content) -> List[Dict]:
mime_part = header[5:].split(";")[0]
if mime_part:
media_type = mime_part
# Decode base64 to raw bytes — boto3 re-encodes at the
# wire layer, so passing the base64 string directly
# results in double-encoding and Bedrock rejects it with
# "Failed to sanitize image". Ref: #33317.
import base64
try:
raw_bytes = base64.b64decode(data)
except Exception:
raw_bytes = data.encode("utf-8")
blocks.append({
"image": {
"format": media_type.split("/")[-1] if "/" in media_type else "jpeg",
"source": {"bytes": raw_bytes},
"source": {"bytes": data},
}
})
else:
# Remote URL — Converse doesn't support URLs directly,
# include as text reference for the model.
blocks.append({"text": f"[Image: {url}]"})
return blocks if blocks else [{"text": _EMPTY_TEXT_PLACEHOLDER}]
return [{"text": _safe_text(content)}]
return blocks if blocks else [{"text": " "}]
return [{"text": str(content)}]
def convert_messages_to_converse(
@@ -602,18 +569,14 @@ def convert_messages_to_converse(
content = msg.get("content")
if role == "system":
# System messages become the system prompt. Blank/whitespace-only
# parts are dropped entirely (not placeholder-filled) since a
# system prompt made up of only placeholder text is meaningless.
# System messages become the system prompt
if isinstance(content, str) and content.strip():
system_blocks.append({"text": content})
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
text = part.get("text", "")
if isinstance(text, str) and text.strip():
system_blocks.append({"text": text})
elif isinstance(part, str) and part.strip():
system_blocks.append({"text": part.get("text", "")})
elif isinstance(part, str):
system_blocks.append({"text": part})
continue
@@ -624,7 +587,7 @@ def convert_messages_to_converse(
tool_result_block = {
"toolResult": {
"toolUseId": tool_call_id,
"content": [{"text": _safe_text(result_content)}],
"content": [{"text": result_content}],
}
}
# In Converse, tool results go in a "user" role message
@@ -663,7 +626,7 @@ def convert_messages_to_converse(
})
if not content_blocks:
content_blocks = [{"text": _EMPTY_TEXT_PLACEHOLDER}]
content_blocks = [{"text": " "}]
# Merge with previous assistant message if needed (strict alternation)
if converse_msgs and converse_msgs[-1]["role"] == "assistant":
@@ -689,11 +652,11 @@ def convert_messages_to_converse(
# Converse requires the first message to be from the user
if converse_msgs and converse_msgs[0]["role"] != "user":
converse_msgs.insert(0, {"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]})
converse_msgs.insert(0, {"role": "user", "content": [{"text": " "}]})
# Converse requires the last message to be from the user
if converse_msgs and converse_msgs[-1]["role"] != "user":
converse_msgs.append({"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]})
converse_msgs.append({"role": "user", "content": [{"text": " "}]})
return (system_blocks if system_blocks else None, converse_msgs)
@@ -817,7 +780,6 @@ def stream_converse_with_callbacks(
on_tool_start=None,
on_reasoning_delta=None,
on_interrupt_check=None,
on_event=None,
) -> SimpleNamespace:
"""Process a Bedrock ConverseStream event stream with real-time callbacks.
@@ -837,12 +799,6 @@ def stream_converse_with_callbacks(
on supported models (Claude 4.6+).
on_interrupt_check: Called on each event. Should return True if the
agent has been interrupted and streaming should stop.
on_event: Called once at the top of the loop body for EVERY yielded
Bedrock event (text/tool-input/reasoning/metadata deltas alike),
before any branching. Provides a wire-level liveness signal so an
external watchdog can distinguish "still receiving events" from
"stream wedged with no data". Errors raised by the callback are
swallowed so a liveness hook can never abort the stream.
Returns:
An OpenAI-compatible SimpleNamespace response, identical in shape to
@@ -858,15 +814,6 @@ def stream_converse_with_callbacks(
usage_data: Dict[str, int] = {}
for event in event_stream.get("stream", []):
# Wire-level liveness signal: fire on EVERY yielded event (text, tool
# input, reasoning, metadata) before branching so an external watchdog
# can tell a still-flowing stream from a wedged one. Best-effort — a
# liveness callback must never be able to abort the stream.
if on_event is not None:
try:
on_event()
except Exception:
pass
# Check for interrupt
if on_interrupt_check and on_interrupt_check():
break
@@ -1349,24 +1296,9 @@ def classify_bedrock_error(error_message: str) -> str:
# detection is unavailable.
BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
# Anthropic Claude models on Bedrock.
# Context windows per Anthropic's official models comparison
# (https://platform.claude.com/docs/en/about-claude/models/overview).
# Fable / Sonnet 5 / Opus 4.8 / 4.7 / 4.6 / Sonnet 4.6 have 1M generally
# available (no beta header required as of April 2026). Sonnet 4.5 and
# Sonnet 4 had their `context-1m-2025-08-07` beta retired on
# April 30, 2026, so they are standard 200K; Haiku 4.5 is 200K.
# These 1M entries must match agent/model_metadata.py
# DEFAULT_CONTEXT_LENGTHS or the agent compresses context prematurely.
# Keys are matched by longest-substring, so the versioned 4-6/4-7/4-8
# entries win over the generic "anthropic.claude-opus-4" fallback.
"anthropic.claude-fable-5": 1_000_000,
"anthropic.claude-fable": 1_000_000,
"anthropic.claude-sonnet-5": 1_000_000,
"anthropic.claude-opus-4-8": 1_000_000,
"anthropic.claude-opus-4-7": 1_000_000,
"anthropic.claude-opus-4-6": 1_000_000,
"anthropic.claude-sonnet-4-6": 1_000_000,
# Anthropic Claude models on Bedrock
"anthropic.claude-opus-4-6": 200_000,
"anthropic.claude-sonnet-4-6": 200_000,
"anthropic.claude-sonnet-4-5": 200_000,
"anthropic.claude-haiku-4-5": 200_000,
"anthropic.claude-opus-4": 200_000,
@@ -1393,22 +1325,9 @@ BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
# Default for unknown Bedrock models
BEDROCK_DEFAULT_CONTEXT_LENGTH = 128_000
# Probe tiers (in tokens). We send a request padded just past each tier and
# read the real window from Bedrock's length-validation error. Two reasons
# this is tiered rather than one giant request:
# 1. A wildly oversized payload (e.g. 5M tokens) makes Bedrock return an
# opaque InternalServerException after retries instead of a clean
# ValidationException — so we must stay within a sane overage.
# 2. Stepping up lets us discover larger windows (2M+) without over-padding
# smaller ones.
# Each tier value is the *padding target*; the error reports the true maximum,
# which is what we actually return.
_BEDROCK_PROBE_TIERS = (1_300_000, 2_200_000)
_WORDS_PER_TOKEN = 0.9 # conservative: ensures the padded prompt clears the tier
def _static_bedrock_context_length(model_id: str) -> int:
"""Longest-substring-match lookup against the static fallback table.
def get_bedrock_context_length(model_id: str) -> int:
"""Look up the context window size for a Bedrock model.
Uses substring matching so versioned IDs like
``anthropic.claude-sonnet-4-6-20250514-v1:0`` resolve correctly.
@@ -1421,103 +1340,3 @@ def _static_bedrock_context_length(model_id: str) -> int:
best_key = key
best_val = val
return best_val
def probe_bedrock_context_length(model_id: str, region: str) -> Optional[int]:
"""Discover a Bedrock model's real context window by provoking a length error.
Bedrock does not expose the context window via any metadata API
(``get-foundation-model`` omits it, ``Converse`` metrics omit it,
``CountTokens`` is unsupported on several models). The only authoritative
source is the ``ValidationException`` raised when a prompt exceeds the
window:
"The model returned the following errors: prompt is too long:
1300032 tokens > 1000000 maximum"
Length validation happens *before* inference, so an oversized request is
rejected immediately and cheaply no tokens are generated and no input is
actually processed. We pad a request just past each tier in
``_BEDROCK_PROBE_TIERS`` and parse the reported ``maximum``. Tiers exist
because (a) a *wildly* oversized payload makes Bedrock fail with an opaque
InternalServerException instead of a clean length error, and (b) stepping
up discovers larger windows without over-padding smaller ones.
Returns the detected window, or ``None`` if the probe could not run
(missing credentials, network error, or no parseable limit) so the caller
can fall back to the static table.
"""
try:
from agent.model_metadata import parse_context_limit_from_error
except ImportError: # pragma: no cover — same package
return None
try:
client = _get_bedrock_runtime_client(region)
except Exception as exc: # boto3 missing / credential resolution failure
logger.debug("Bedrock context probe skipped for %s: %s", model_id, exc)
return None
last_error = ""
for tier_tokens in _BEDROCK_PROBE_TIERS:
pad_words = int(tier_tokens / _WORDS_PER_TOKEN)
oversized = "data " * pad_words
try:
client.converse(
modelId=model_id,
messages=[{"role": "user", "content": [{"text": oversized}]}],
inferenceConfig={"maxTokens": 8},
)
# Accepted a prompt this large → the window is at least this tier.
# Returning the tier as a lower bound is safe and avoids inventing
# a number we can't confirm.
logger.debug(
"Bedrock context probe for %s accepted ~%s-token prompt; "
"window is at least that", model_id, f"{tier_tokens:,}",
)
return tier_tokens
except Exception as exc:
msg = str(exc)
last_error = msg
limit = parse_context_limit_from_error(msg)
if limit and limit >= 1024:
logger.info(
"Probed Bedrock context window for %s: %s tokens",
model_id, f"{limit:,}",
)
return limit
# No parseable limit at this tier (opaque server error, auth,
# throttle). Try the next, smaller-overage strategy is N/A here —
# tiers ascend — so just continue; if all fail we return None.
continue
logger.debug(
"Bedrock context probe for %s returned no parseable limit: %s",
model_id, last_error[:200],
)
return None
def get_bedrock_context_length(model_id: str, region: str = "", probe: bool = True) -> int:
"""Resolve the context window for a Bedrock model.
Resolution order:
1. Live probe against Bedrock (authoritative; cached by the caller).
2. Static fallback table (longest-substring match).
3. Conservative default.
The static table is intentionally a *fallback*, not the primary source:
AWS ships new model versions (opus-4-7, opus-4-8, ...) faster than the
table can track, and a stale entry silently caps the window (e.g. a
1M-token Opus pinned to 200K via an ``opus-4`` substring match). The
probe asks Bedrock directly so every model current or future gets its
real window with no table maintenance.
``probe=False`` (or an empty ``region``) skips the network call and uses
the static table only used by pure-offline/display code paths.
"""
if probe and region:
probed = probe_bedrock_context_length(model_id, region)
if probed:
return probed
return _static_bedrock_context_length(model_id)
-323
View File
@@ -1,323 +0,0 @@
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
The single source of truth behind the ``/usage`` and ``/subscription`` usage
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
**dollars**, never "credits", and every usage bar must make the monthly
subscription allowance and separately-purchased top-up dollars distinctly
visible.
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
``paid_service_access_info`` carries the three dollar magnitudes we render
(despite the legacy ``*_credits`` field names, these are USD floats):
- ``subscription_credits_remaining`` -> plan dollars left this month
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
- ``total_usable_credits`` -> total spendable
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
Design: two SEPARATE bars (decided with the user) rather than one crammed
three-segment bar at terminal widths three same-glyph density segments are
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
the top-up bar is "money you bought, doesn't expire". Each gets full
resolution and a single fill glyph, so the bar is never ambiguous and never
relies on color.
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
magnitudes-only view; a logged-out / unreachable portal yields
``available=False`` and the surface shows nothing.
"""
from __future__ import annotations
import logging
import math
import os
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
# (user feedback): "any amount below $5 should be an alert status."
LOW_BALANCE_THRESHOLD_USD = 5.0
def _finite(value: Any) -> Optional[float]:
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
f = float(value)
return f if math.isfinite(f) else None
def _fmt_usd(value: Optional[float]) -> str:
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
return f"${(value or 0.0):,.2f}"
def format_renews(value: Optional[str]) -> Optional[str]:
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
string unchanged if it can't be parsed (never raises), and ``None`` for
empty input.
"""
if not value:
return None
from datetime import datetime
text = str(value).strip()
if not text:
return None
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
try:
dt = datetime.fromisoformat(iso)
except ValueError:
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
try:
dt = datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return text
# %-d isn't portable to Windows; build the day without a leading zero.
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
@dataclass(frozen=True)
class UsageBar:
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
(purchased dollars, no denominator ``spent`` is 0 and ``total`` ==
``remaining`` so it renders as a full bar of available balance).
"""
kind: str # "plan" | "topup"
remaining_usd: float
total_usd: float
spent_usd: float = 0.0
@property
def pct_used(self) -> Optional[int]:
if self.kind != "plan" or self.total_usd <= 0:
return None
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
@property
def fill_fraction(self) -> float:
"""Fraction of the bar that should read as 'remaining' (filled)."""
if self.total_usd <= 0:
return 0.0
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
@dataclass(frozen=True)
class UsageModel:
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
``status`` classifies the account for copy selection:
- ``"free"`` : no paid access / no subscription (free models only)
- ``"low"`` : paid, but total spendable < $5 (ALERT)
- ``"healthy"`` : paid, total spendable >= $5
- ``"depleted"`` : paid access lost (balance exhausted)
"""
available: bool
status: str = "free"
plan_name: Optional[str] = None
renews_at: Optional[str] = None
renews_display: Optional[str] = None
subscription_remaining_usd: Optional[float] = None
topup_remaining_usd: Optional[float] = None
total_spendable_usd: Optional[float] = None
plan_bar: Optional[UsageBar] = None
topup_bar: Optional[UsageBar] = None
@property
def has_topup(self) -> bool:
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
def usage_model_from_account(account_info: Any) -> UsageModel:
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
Returns ``UsageModel(available=False)`` when there's no usable account info
(logged out, no entitlement block). Never raises.
"""
try:
if account_info is None or not getattr(account_info, "logged_in", False):
return UsageModel(available=False)
access = getattr(account_info, "paid_service_access_info", None)
sub = getattr(account_info, "subscription", None)
paid = getattr(account_info, "paid_service_access", None)
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
plan_name = getattr(sub, "plan", None) if sub is not None else None
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
# Total spendable: prefer the server's total; else sum the parts we have.
if total_usable is not None:
total_spendable = total_usable
else:
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
total_spendable = sum(parts) if parts else None
# Status classification.
if paid is False:
status = "depleted"
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
# No plan and no purchased balance -> free-models-only.
status = "free"
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
status = "low"
else:
status = "healthy"
# Plan bar — only with a positive monthly allowance AND a remaining we
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
# balance reads as fully spent rather than a nonsensical negative).
plan_bar: Optional[UsageBar] = None
if monthly is not None and monthly > 0 and sub_remaining is not None:
remaining = max(0.0, min(monthly, sub_remaining))
plan_bar = UsageBar(
kind="plan",
remaining_usd=remaining,
total_usd=monthly,
spent_usd=max(0.0, monthly - sub_remaining),
)
# Top-up bar — only when there are purchased dollars to show. No
# denominator (top-up has no monthly cap), so it renders full = balance.
topup_bar: Optional[UsageBar] = None
if topup_remaining is not None and topup_remaining > 0:
topup_bar = UsageBar(
kind="topup",
remaining_usd=topup_remaining,
total_usd=topup_remaining,
spent_usd=0.0,
)
return UsageModel(
available=True,
status=status,
plan_name=plan_name,
renews_at=renews_at,
renews_display=format_renews(renews_at),
subscription_remaining_usd=sub_remaining,
topup_remaining_usd=topup_remaining,
total_spendable_usd=total_spendable,
plan_bar=plan_bar,
topup_bar=topup_bar,
)
except Exception:
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
return UsageModel(available=False)
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
"""Fetch account-info and build the shared usage model. Fail-open.
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
every usage state is testable without a live account (mirrors the existing
``/usage`` credits-block fixture path).
"""
fixture = _dev_fixture_usage_model()
if fixture is not None:
return fixture
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return UsageModel(available=False)
except Exception:
return UsageModel(available=False)
try:
import concurrent.futures
from hermes_cli.nous_account import get_nous_portal_account_info
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
return usage_model_from_account(account)
except Exception:
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
return UsageModel(available=False)
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_usage_model() -> Optional[UsageModel]:
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
Recognized names: ``free | healthy | low | topup | depleted``. Returns
``None`` when the env var is unset (real portal path runs).
"""
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
if not name:
return None
if name == "free":
return UsageModel(available=True, status="free", plan_name=None)
if name in ("healthy", "mid"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
total_spendable_usd=14.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
)
if name in ("topup", "top-up"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
topup_remaining_usd=12.0,
total_spendable_usd=26.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
)
if name == "low":
return UsageModel(
available=True,
status="low",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=3.4,
total_spendable_usd=3.4,
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
)
if name == "depleted":
return UsageModel(
available=True,
status="depleted",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=0.0,
total_spendable_usd=0.0,
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
)
return None
+9 -172
View File
@@ -1,4 +1,4 @@
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods
@@ -15,7 +15,6 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
from __future__ import annotations
import logging
import os
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
@@ -65,47 +64,15 @@ def format_money(value: Optional[Decimal]) -> str:
# =============================================================================
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
# so the display degrades cleanly on servers that don't send resolvedVia yet.
_CARD_PROVENANCE_LABELS = {
"subPin": "the card on your subscription",
"customerDefault": "your default card saved on the portal",
"autoRefill": "your auto-reload card",
}
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
# NAS card-on-file field (post card-resolver): which ladder rung found the
# card. Defaults off so pre-resolver payloads parse unchanged.
resolved_via: Optional[str] = None
@property
def masked(self) -> str:
# A Link payment method has no card number (last4 = "") — render the
# brand alone, not "Link ····".
if not self.last4:
return self.brand
return f"{self.brand} ····{self.last4}"
@property
def provenance(self) -> Optional[str]:
"""Human label for why this card was picked, or None (unknown rung /
server too old to say)."""
if self.resolved_via is None:
return None
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
@property
def display(self) -> str:
"""The one-line card display: ``Visa ····4242 — the card on your
subscription`` (or just the masked card when provenance is unknown)."""
label = self.provenance
return f"{self.masked}{label}" if label else self.masked
@dataclass(frozen=True)
class MonthlyCap:
@@ -114,20 +81,11 @@ class MonthlyCap:
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReloadCard:
kind: str # "canonical" | "distinct" | "none"
payment_method_id: Optional[str] = None
brand: Optional[str] = None
last4: Optional[str] = None
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
card: Optional[AutoReloadCard] = None
@dataclass(frozen=True)
@@ -142,8 +100,7 @@ class BillingState:
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
@@ -158,33 +115,17 @@ class BillingState:
@property
def is_admin(self) -> bool:
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
"""True for OWNER/ADMIN — the roles that can manage billing."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
@property
def can_charge(self) -> bool:
"""True when the UI should offer charge/auto-reload actions.
Uses the server-granted plan-change capability (``can_change_plan``,
which itself falls back to the legacy OWNER/ADMIN role check when the
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
deprecated 3-role admin check. (The server still enforces; this is
just for graying out actions the user can't take.)
Admin role AND the per-org kill-switch on. (The server still enforces;
this is just for graying out actions the user can't take.)
"""
return self.can_change_plan and self.cli_billing_enabled
return self.is_admin and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
@@ -192,13 +133,9 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if not (isinstance(brand, str) and isinstance(last4, str)):
return None
# Post-resolver fields — all optional so both payload generations parse.
resolved_via = raw.get("resolvedVia")
if not isinstance(resolved_via, str):
resolved_via = None
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
if isinstance(brand, str) and isinstance(last4, str):
return CardInfo(brand=brand, last4=last4)
return None
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
@@ -218,27 +155,6 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
card=_parse_auto_reload_card(raw.get("card")),
)
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if kind not in ("canonical", "distinct", "none"):
return None
if kind in ("canonical", "none"):
return AutoReloadCard(kind=kind)
payment_method_id = raw.get("paymentMethodId")
brand = raw.get("brand")
last4 = raw.get("last4")
return AutoReloadCard(
kind=kind,
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
brand=brand if isinstance(brand, str) else None,
last4=last4 if isinstance(last4, str) else None,
)
@@ -263,11 +179,6 @@ def billing_state_from_payload(
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
@@ -291,15 +202,7 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
card-on-file / admin / scope states are testable offline (mirrors
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
"""
fixture = _dev_fixture_billing_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
@@ -340,72 +243,6 @@ def _fallback_portal_url(base: str) -> str:
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_billing_state() -> Optional[BillingState]:
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
Recognized names::
nocard logged in · billing on · admin · NO card on file
card card on file · auto-reload off
card-autoreload card on file · auto-reload on
notadmin logged in · MEMBER role (billing actions disabled)
billing-off logged in · admin · per-org kill-switch OFF
logged-out not logged in
Returns ``None`` when the env var is unset (the real portal path runs).
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
"""
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
if not name:
return None
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
portal = "https://portal.nousresearch.com/billing?topup=open"
common: dict[str, Any] = dict(
org_id="org_acme",
org_slug="acme",
org_name="Acme Inc",
role="OWNER",
balance_usd=Decimal("3.40"),
cli_billing_enabled=True,
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
min_usd=Decimal("5"),
max_usd=Decimal("500"),
portal_url=portal,
)
card = CardInfo(brand="Visa", last4="4242")
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
if name in ("logged-out", "logged_out", "loggedout"):
return BillingState(logged_in=False)
if name == "nocard":
return BillingState(logged_in=True, card=None, **common)
if name == "card":
return BillingState(logged_in=True, card=card, **common)
if name in ("card-sub", "card_sub"):
# Post-resolver: the card came from the subscription (provenance label).
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
return BillingState(logged_in=True, card=_sub_card, **common)
if name in ("card-autoreload", "card_autoreload", "autoreload"):
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
if name in ("notadmin", "not-admin", "member"):
opts = {**common, "role": "MEMBER"}
return BillingState(logged_in=True, card=card, **opts)
if name in ("billing-off", "billing_off", "off"):
opts = {**common, "cli_billing_enabled": False}
return BillingState(logged_in=True, card=None, **opts)
# Unknown name → logged-out so the misconfiguration is visible.
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
# =============================================================================
# Idempotency
# =============================================================================
-148
View File
@@ -1,148 +0,0 @@
"""Bounded reads of HTTP error response bodies.
When a provider returns a non-OK status on a *streaming* request, Hermes reads
the response body to build a useful diagnostic error. A bare ``response.read()``
on a streaming httpx response is unbounded in two dangerous ways:
1. A server can declare (or stream) an arbitrarily large body, so the read can
balloon memory.
2. A server can open the body and then stall forever (no ``Content-Length``,
no further bytes), so the read hangs the agent indefinitely.
Both are realistic against a misbehaving proxy, a hijacked endpoint, or a
provider having a bad day. The diagnostic body is only ever shown to the user
truncated to a few hundred characters, so reading megabytes or blocking
forever buys nothing.
``read_streaming_error_body`` bounds the read to a byte cap and enforces a
hard wall-clock deadline, returning the decoded text snippet. Callers pass the
returned text into their existing error builders instead of touching
``response.text`` (which would be unbounded / would raise after a partial
stream read).
A subtlety the implementation must respect: ``httpx``'s ``iter_bytes()`` blocks
*inside* the C/socket read while waiting for the next chunk. A wall-clock check
placed only between yielded chunks cannot interrupt a server that opens the
body and then stalls mid-chunk control never returns to Python until httpx's
own (often 30s+) read timeout fires. To guarantee a bounded stop regardless of
socket behavior, the read runs on a daemon worker thread and the caller waits
on it with a hard deadline; on timeout we close the response (which unblocks /
cancels the read) and return whatever partial bytes were collected.
Ported and adapted from openclaw/openclaw#95108 ("bound Anthropic error
streams"), generalized to cover Hermes's three streaming error-body sites
(native Gemini, Gemini Cloud Code, Antigravity Cloud Code).
"""
from __future__ import annotations
import logging
import threading
from typing import List, Optional
import httpx
logger = logging.getLogger(__name__)
# Defaults chosen to comfortably hold any real provider error envelope (Google
# RPC error JSON, Anthropic error JSON) while rejecting pathological bodies.
DEFAULT_ERROR_BODY_MAX_BYTES = 64 * 1024
# Hard wall-clock deadline for the whole bounded read. A streaming error body
# that does not finish within this window is abandoned and the connection is
# closed; we keep whatever partial bytes arrived.
DEFAULT_ERROR_BODY_TIMEOUT_S = 10.0
def read_streaming_error_body(
response: httpx.Response,
*,
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
) -> str:
"""Read a non-OK streaming response body with a byte cap and a hard deadline.
Returns the decoded body text (UTF-8, errors replaced), truncated to
``max_bytes``. Never raises: any transport error, stall, or oversize
condition is swallowed and the best-effort partial text (or an empty
string) is returned, because this runs on the error path and must not
mask the original HTTP failure with a read error.
The byte cap protects against huge bodies; the wall-clock deadline (enforced
via a worker thread so it can interrupt a socket read that stalls mid-chunk)
protects against bodies that open and then hang.
"""
chunks: List[bytes] = []
state = {"truncated": False}
done = threading.Event()
def _drain() -> None:
total = 0
try:
for chunk in response.iter_bytes():
if not chunk:
continue
remaining = max_bytes - total
if remaining <= 0:
state["truncated"] = True
break
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
total += remaining
state["truncated"] = True
break
chunks.append(chunk)
total += len(chunk)
except Exception as exc: # noqa: BLE001 - error path must not raise
logger.debug("bounded error-body read failed: %s", exc)
finally:
done.set()
worker = threading.Thread(
target=_drain, name="bounded-error-body-read", daemon=True
)
worker.start()
finished = done.wait(timeout=timeout_s)
if not finished:
logger.debug(
"bounded error-body read: hard timeout after %.1fs (%d bytes so far)",
timeout_s,
sum(len(c) for c in chunks),
)
# Closing the response cancels the in-flight socket read, letting the
# worker thread unwind. We do not join (it is a daemon and may be
# blocked in C); the partial `chunks` collected so far are returned.
_safe_close(response)
else:
_safe_close(response)
if state["truncated"]:
logger.debug(
"bounded error-body read: capped at %d bytes (max=%d)",
sum(len(c) for c in chunks),
max_bytes,
)
return b"".join(chunks).decode("utf-8", errors="replace")
def _safe_close(response: httpx.Response) -> None:
try:
response.close()
except Exception: # noqa: BLE001
pass
def read_error_body_or_default(
response: httpx.Response,
*,
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
) -> Optional[str]:
"""Like ``read_streaming_error_body`` but returns ``None`` on empty body.
Convenience for callers that distinguish "no body" from "empty string".
"""
text = read_streaming_error_body(
response, max_bytes=max_bytes, timeout_s=timeout_s
)
return text or None
File diff suppressed because it is too large Load Diff
+20 -151
View File
@@ -288,13 +288,6 @@ _RESPONSES_BUILTIN_TOOL_TYPES = {
_RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"}
# The Responses API rejects input[].id longer than this with a non-retryable
# HTTP 400 ("string too long"). Codex-issued assistant message ids are
# server-assigned base64 blobs that can run 400+ chars, while Hermes-minted
# ids (msg_...) stay well under this cap and are worth keeping for
# prefix-cache hits. Drop only the oversized ones on replay.
_MAX_RESPONSES_ITEM_ID_LENGTH = 64
def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
"""Normalize a Responses assistant message status for replay.
@@ -314,7 +307,6 @@ def _chat_messages_to_responses_input(
messages: List[Dict[str, Any]],
*,
is_xai_responses: bool = False,
is_github_responses: bool = False,
replay_encrypted_reasoning: bool = True,
current_issuer_kind: Optional[str] = None,
) -> List[Dict[str, Any]]:
@@ -339,16 +331,6 @@ def _chat_messages_to_responses_input(
items from the conversation history and threads ``replay_enabled=False``
through this converter so subsequent turns send no reasoning items.
``is_github_responses`` drops the ``id`` field from replayed
``codex_message_items`` regardless of length. The Copilot backend
(api.githubcopilot.com/responses) binds these ids to a specific
backend "connection" credential-pool rotation, a gateway restart,
or routine load-balancer churn between turns all invalidate it and
rejects a stale id with HTTP 401 "input item ID does not belong to
this connection" even for short ids (see #32716). ``phase``/
``status``/``content`` are still replayed; only ``id`` is unsafe to
reuse across a Copilot connection.
``current_issuer_kind`` enables a per-item cross-issuer guard. The
Responses API's ``encrypted_content`` blob is decryptable only by the
endpoint that minted it replaying a Codex-issued blob against xAI
@@ -481,14 +463,8 @@ def _chat_messages_to_responses_input(
"content": normalized_content_parts,
}
item_id = raw_item.get("id")
if (
not is_github_responses
and isinstance(item_id, str)
and item_id.strip()
):
stripped_id = item_id.strip()
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
replay_item["id"] = stripped_id
if isinstance(item_id, str) and item_id.strip():
replay_item["id"] = item_id.strip()
phase = raw_item.get("phase")
if isinstance(phase, str) and phase.strip():
replay_item["phase"] = phase.strip()
@@ -600,11 +576,7 @@ def _chat_messages_to_responses_input(
# Input preflight / validation
# ---------------------------------------------------------------------------
def _preflight_codex_input_items(
raw_items: Any,
*,
is_github_responses: bool = False,
) -> List[Dict[str, Any]]:
def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
if not isinstance(raw_items, list):
raise ValueError("Codex Responses input must be a list of input items.")
@@ -745,14 +717,8 @@ def _preflight_codex_input_items(
"content": normalized_content,
}
item_id = item.get("id")
if (
not is_github_responses
and isinstance(item_id, str)
and item_id.strip()
):
stripped_id = item_id.strip()
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
normalized_item["id"] = stripped_id
if isinstance(item_id, str) and item_id.strip():
normalized_item["id"] = item_id.strip()
phase = item.get("phase")
if isinstance(phase, str) and phase.strip():
normalized_item["phase"] = phase.strip()
@@ -824,7 +790,6 @@ def _preflight_codex_api_kwargs(
api_kwargs: Any,
*,
allow_stream: bool = False,
is_github_responses: bool = False,
) -> Dict[str, Any]:
if not isinstance(api_kwargs, dict):
raise ValueError("Codex Responses request must be a dict.")
@@ -846,10 +811,7 @@ def _preflight_codex_api_kwargs(
instructions = str(instructions)
instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY
normalized_input = _preflight_codex_input_items(
api_kwargs.get("input"),
is_github_responses=is_github_responses,
)
normalized_input = _preflight_codex_input_items(api_kwargs.get("input"))
tools = api_kwargs.get("tools")
normalized_tools = None
@@ -1118,22 +1080,6 @@ def _normalize_codex_response(
differs from the one that minted the encrypted_content blob and drop
the item instead of triggering HTTP 400 invalid_encrypted_content.
"""
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = ""
if isinstance(incomplete_details, dict):
incomplete_reason = str(incomplete_details.get("reason") or "").strip().lower()
elif incomplete_details is not None:
incomplete_reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
response_incomplete_content_filter = (
response_status == "incomplete" and incomplete_reason == "content_filter"
)
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
# The Codex backend can return empty output when the answer was
@@ -1150,18 +1096,15 @@ def _normalize_codex_response(
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
)]
response.output = output
elif response_incomplete_content_filter:
# This is a deterministic provider safety block, not a partial
# answer. Synthesize an empty message so finish_reason below becomes
# content_filter and the conversation loop can fallback/surface it
# instead of burning three continuation attempts.
output = [SimpleNamespace(
type="message", role="assistant", status="completed", content=[]
)]
response.output = output
else:
raise RuntimeError("Responses API returned no output items")
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
if response_status in {"failed", "cancelled"}:
error_obj = getattr(response, "error", None)
error_msg = _format_responses_error(error_obj, response_status)
@@ -1223,28 +1166,15 @@ def _normalize_codex_response(
if item_type == "message":
item_phase = getattr(item, "phase", None)
normalized_phase = None
is_commentary_phase = False
if isinstance(item_phase, str):
normalized_phase = item_phase.strip().lower()
if normalized_phase in {"commentary", "analysis"}:
saw_commentary_phase = True
is_commentary_phase = True
elif normalized_phase in {"final_answer", "final"}:
saw_final_answer_phase = True
message_text = _extract_responses_message_text(item)
if message_text:
# Responses ``commentary``/``analysis`` phase text is mid-turn
# preamble/progress narration, never the turn's final answer
# (Codex CLI excludes it from last-message extraction; issues
# #24933 / #41293). Keep it out of assistant content so it
# can't be concatenated into — or leak as — the final response,
# but surface it through the reasoning channel so the CLI/
# gateway display it like thinking text. The exact message
# item is still preserved below for replay/cache continuity.
if is_commentary_phase:
reasoning_parts.append(message_text)
else:
content_parts.append(message_text)
content_parts.append(message_text)
raw_message_item: Dict[str, Any] = {
"type": "message",
"role": "assistant",
@@ -1339,11 +1269,7 @@ def _normalize_codex_response(
))
final_text = "\n".join([p for p in content_parts if p]).strip()
if (
not final_text
and hasattr(response, "output_text")
and not (saw_commentary_phase and not saw_final_answer_phase)
):
if not final_text and hasattr(response, "output_text"):
out_text = getattr(response, "output_text", "")
if isinstance(out_text, str):
final_text = out_text.strip()
@@ -1379,45 +1305,6 @@ def _normalize_codex_response(
# so the model keeps its chain-of-thought on the retry.
final_text = ""
# ── Reasoning-channel answer salvage (xAI grok) ──────────────
# grok-4.x on the xAI /v1/responses surface sometimes emits its final
# answer inside the reasoning item instead of as a ``message`` output
# item, marking where the answer starts with grok's internal
# ``<response>`` delimiter. Without salvage, the reasoning-only rule
# below classifies the turn ``incomplete`` — and because reasoning
# items on this surface carry no ``encrypted_content``, the interim
# message replays as nothing, so every continuation request is
# byte-identical to the one that just failed. The turn burns its 3
# retries and dies with "Codex response remained incomplete after 3
# continuation attempts" even though the answer was produced on the
# first attempt. Observed live with grok-4.20 on xai-oauth
# (2026-07-13). Promote the delimited tail to assistant content and
# keep the untagged prefix as thinking text.
if (
issuer_kind == "xai_responses"
and not final_text
and not tool_calls
and reasoning_parts
):
joined_reasoning = "\n\n".join(reasoning_parts)
marker = joined_reasoning.rfind("<response>")
if marker != -1:
salvaged = joined_reasoning[marker + len("<response>"):]
closing = salvaged.find("</response>")
if closing != -1:
salvaged = salvaged[:closing]
salvaged = salvaged.strip()
if salvaged:
logger.warning(
"xAI response delivered its final answer inside the "
"reasoning channel (<response> delimiter); promoting "
"%d chars to assistant content.",
len(salvaged),
)
final_text = salvaged
reasoning_prefix = joined_reasoning[:marker].strip()
reasoning_parts = [reasoning_prefix] if reasoning_prefix else []
assistant_message = SimpleNamespace(
content=final_text,
tool_calls=tool_calls,
@@ -1430,8 +1317,6 @@ def _normalize_codex_response(
if tool_calls:
finish_reason = "tool_calls"
elif response_incomplete_content_filter:
finish_reason = "content_filter"
elif leaked_tool_call_text:
finish_reason = "incomplete"
elif saw_streaming_or_item_incomplete:
@@ -1440,28 +1325,12 @@ def _normalize_codex_response(
finish_reason = "incomplete"
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
# Response contains only reasoning (encrypted thinking state and/or
# human-readable summary) with no visible content or tool calls.
#
# For the specially-handled backends (Codex, xAI, GitHub/Copilot),
# reasoning-only with status="completed" means "the model is still
# thinking and needs another turn" — treat it as incomplete so the
# Codex continuation path retries instead of falling into the
# empty-content retry loop.
#
# For all other backends (other:<base_url>, etc.), trust the provider's
# own response.status signal. When status == "completed" and no items
# are queued/in_progress/incomplete, reasoning alone is a valid final
# state — forcing "incomplete" causes multi-minute stalls as the
# continuation path re-issues calls (3 retries × up to 240s each).
# See https://github.com/NousResearch/hermes-agent/issues/64434
if response_status == "completed" and issuer_kind not in (
"codex_backend",
"xai_responses",
"github_responses",
):
finish_reason = "stop"
else:
finish_reason = "incomplete"
# human-readable summary) with no visible content or tool calls. The
# model is still thinking and needs another turn to produce the actual
# answer. Marking this as "stop" would send it into the empty-content
# retry loop which burns retries then fails — treat it as incomplete so
# the Codex continuation path handles it correctly.
finish_reason = "incomplete"
else:
finish_reason = "stop"
return assistant_message, finish_reason
+82 -642
View File
@@ -16,18 +16,70 @@ compatibility.
from __future__ import annotations
import json
import logging
import os
import time
from types import SimpleNamespace
from typing import Any, Callable, Dict, List
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
def _codex_note_to_tool_progress(note: dict) -> tuple[str, str, dict] | None:
"""Map a Codex app-server ``item/started`` notification to a Hermes
tool-progress event ``(tool_name, preview, args)``.
The Codex app-server runtime processes ``item/started`` notifications for
command execution, file changes, and MCP/dynamic tool calls, but never
surfaced them as Hermes tool-progress events so gateways (Telegram, etc.)
showed no verbose "running X" breadcrumbs on this route while every other
provider did (#38835). Returns None for items that aren't tool-shaped.
"""
if not isinstance(note, dict) or note.get("method") != "item/started":
return None
params = note.get("params") or {}
item = params.get("item") or {}
if not isinstance(item, dict):
return None
item_type = item.get("type") or ""
if item_type == "commandExecution":
command = item.get("command") or ""
return "exec_command", command, {"command": command, "cwd": item.get("cwd") or ""}
if item_type == "fileChange":
changes = item.get("changes") or []
preview = "file changes"
if isinstance(changes, list) and changes:
paths = [
str(change.get("path"))
for change in changes
if isinstance(change, dict) and change.get("path")
]
if paths:
preview = ", ".join(paths[:3])
if len(paths) > 3:
preview += f", +{len(paths) - 3} more"
return "apply_patch", preview, {"changes": changes}
if item_type == "mcpToolCall":
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
args = item.get("arguments") or {}
if not isinstance(args, dict):
args = {"arguments": args}
return f"mcp.{server}.{tool}", tool, args
if item_type == "dynamicToolCall":
tool = item.get("tool") or "unknown"
args = item.get("arguments") or {}
if not isinstance(args, dict):
args = {"arguments": args}
return tool, tool, args
return None
def _coerce_usage_int(value: Any) -> int:
if isinstance(value, bool):
return 0
@@ -61,15 +113,6 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
usage = getattr(turn, "token_usage_last", None)
if not isinstance(usage, dict) or not usage:
compressor = getattr(agent, "context_compressor", None)
if (
compressor is not None
and getattr(compressor, "awaiting_real_usage_after_compression", False)
):
# No usage means this turn cannot adjudicate the pending compaction.
# Consume the marker so a later unrelated reading is not charged to
# it and preflight deferral cannot stay latched indefinitely.
compressor.update_from_response({})
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
@@ -77,9 +120,6 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
agent._session_db.update_token_counts(
agent.session_id,
model=agent.model,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included",
api_call_count=1,
)
except Exception as exc:
@@ -188,430 +228,6 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
}
def _record_codex_app_server_compaction(
agent,
turn,
*,
approx_tokens: int | None = None,
force: bool = False,
) -> bool:
"""Record a Codex-native context compaction boundary in Hermes state.
The app-server owns the compacted thread context, so Hermes should not
rewrite local transcript rows here; state.db records the boundary via the
session event/usage counters while preserving the visible transcript.
"""
if not force and not getattr(turn, "compacted", False):
return False
thread_id = getattr(turn, "thread_id", None) or ""
turn_id = getattr(turn, "turn_id", None) or ""
logger.info(
"codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
getattr(agent, "session_id", None) or "none",
thread_id,
turn_id,
force,
)
if not force:
try:
from agent.conversation_compression import COMPACTION_STATUS
agent._emit_status(COMPACTION_STATUS)
except Exception:
pass
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
compressor.compression_count = getattr(
compressor, "compression_count", 0
) + 1
compressor.last_compression_rough_tokens = approx_tokens or 0
# The app server has already completed a real compaction boundary. Its
# usage update (when supplied) is therefore the same real-vs-real
# effectiveness verdict used by the normal compression path.
record_boundary = getattr(
type(compressor), "record_completed_compaction", None
)
if callable(record_boundary):
# Codex owns this summary. A prior Hermes deterministic-fallback
# flag must not leak into the native boundary's quality verdict.
record_boundary(compressor, used_fallback=False)
elif hasattr(compressor, "_verify_compaction_cleared_threshold"):
compressor._verify_compaction_cleared_threshold = True
if not getattr(turn, "token_usage_last", None):
compressor.last_prompt_tokens = -1
compressor.last_completion_tokens = 0
compressor.awaiting_real_usage_after_compression = True
agent._last_compaction_in_place = False
try:
if getattr(agent, "event_callback", None):
agent.event_callback(
"session:compress",
{
"platform": getattr(agent, "platform", None) or "",
"session_id": getattr(agent, "session_id", None) or "",
"old_session_id": "",
"in_place": False,
"compression_count": getattr(
compressor, "compression_count", 0
)
if compressor is not None
else 0,
"runtime": "codex_app_server",
"thread_id": thread_id,
"turn_id": turn_id,
},
)
except Exception:
logger.debug("event_callback error on codex session:compress", exc_info=True)
return True
# ---------------------------------------------------------------------------
# Codex app-server → Hermes UI bridge (#33200)
#
# The codex_app_server runtime hands the entire turn to a subprocess and
# bypasses the normal Hermes tool loop. Without this bridge gateway
# adapters (Discord, Telegram, TUI) never see live tool-progress bubbles
# or interim assistant commentary while codex is working — the user just
# stares at a quiet channel until the final answer lands. The bridge
# translates raw codex JSON-RPC notifications into the same three agent
# callbacks the standard runtime fires:
# - tool_progress_callback("tool.started"|"tool.completed", name, ...)
# - _fire_stream_delta(text) for streaming agentMessage chunks
# - _emit_interim_assistant_message({...}) for completed agentMessages
# ---------------------------------------------------------------------------
# Codex item types that map to a Hermes tool_call in the projector (and
# therefore deserve a tool_progress bubble pair). The projector lives in
# agent/transports/codex_event_projector.py — keep these in sync so the
# tool name shown in the UI matches the name recorded in messages.
# webSearch is codex's built-in web search tool — it has no projector
# entry (codex handles it internally) but still deserves a bubble.
_CODEX_TOOL_ITEM_TYPES = frozenset(
{"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch"}
)
# Internal MCP server that wraps Hermes' native tools for codex. When
# codex calls back through it, the inner dispatch runs in a SEPARATE
# hermes-tools-mcp-server subprocess that has no access to the parent
# agent's tool_progress_callback — so the inner call can never surface
# its own native progress event. The codex-level mcpToolCall event IS
# the display event for those calls; we strip the mcp.hermes-tools.*
# namespacing and emit the bare tool name (web_search, browser_navigate,
# vision_analyze, ...) since the user thinks of these as Hermes tools,
# not as MCP calls.
_INTERNAL_MCP_SERVER = "hermes-tools"
def _codex_item_to_tool_name(item: dict) -> str:
"""Synthetic Hermes tool name for a codex item. Mirrors
CodexEventProjector so the progress bubble and the projected
tool_calls entry use the same identifier."""
item_type = item.get("type") or ""
if item_type == "commandExecution":
return "exec_command"
if item_type == "fileChange":
return "apply_patch"
if item_type == "mcpToolCall":
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
if server == _INTERNAL_MCP_SERVER:
return tool
return f"mcp.{server}.{tool}"
if item_type == "dynamicToolCall":
return item.get("tool") or "dynamic"
if item_type == "webSearch":
return "web_search"
return item_type or "unknown"
def _codex_item_to_args(item: dict) -> dict:
"""Args dict surfaced to tool_progress_callback("tool.started", ...).
Mirrors the projector's _project_command / _project_file_change /
_project_mcp_tool_call / _project_dynamic_tool_call shapes."""
item_type = item.get("type") or ""
if item_type == "commandExecution":
return {"command": item.get("command") or "",
"cwd": item.get("cwd") or ""}
if item_type == "fileChange":
return {"changes": [
{"kind": (c.get("kind") or {}).get("type") or "update",
"path": c.get("path") or ""}
for c in (item.get("changes") or []) if isinstance(c, dict)
]}
if item_type in {"mcpToolCall", "dynamicToolCall"}:
args = item.get("arguments") or {}
return args if isinstance(args, dict) else {"arguments": args}
if item_type == "webSearch":
return {"query": item.get("query") or ""}
return {}
def _codex_item_to_preview(item: dict) -> Any:
"""Short human-readable preview for the tool.started bubble. Returns
None when no useful preview is available (Hermes' UI tolerates None)."""
item_type = item.get("type") or ""
if item_type == "commandExecution":
cmd = item.get("command") or ""
return cmd[:120] if cmd else None
if item_type == "fileChange":
paths = [c.get("path") for c in (item.get("changes") or [])
if isinstance(c, dict) and c.get("path")]
if not paths:
return None
preview = ", ".join(paths[:3])
if len(paths) > 3:
preview += f", +{len(paths) - 3} more"
return preview
if item_type in {"mcpToolCall", "dynamicToolCall"}:
args = item.get("arguments") or {}
if not isinstance(args, dict) or not args:
return None
try:
return json.dumps(args, ensure_ascii=False)[:120]
except (TypeError, ValueError):
return None
if item_type == "webSearch":
query = item.get("query") or ""
return query[:120] if query else None
return None
def _codex_item_completion_payload(item: dict) -> tuple[str, bool]:
"""Return (result_text, is_error) for a completed codex tool item.
Mirrors the projector's tool-result content so the bubble shows the
same outcome string that ends up in the messages list."""
item_type = item.get("type") or ""
if item_type == "commandExecution":
out = item.get("aggregatedOutput") or ""
exit_code = item.get("exitCode")
is_error = bool(exit_code is not None and exit_code != 0)
if is_error:
out = f"[exit {exit_code}]\n{out}"
return out, is_error
if item_type == "fileChange":
status = item.get("status") or "unknown"
n = len(item.get("changes") or [])
return (
f"apply_patch status={status}, {n} change(s)",
status not in {"completed", "applied", "success"},
)
if item_type == "mcpToolCall":
error = item.get("error")
if error:
return (
f"[error] {json.dumps(error, ensure_ascii=False)[:1000]}",
True,
)
result = item.get("result")
return (
json.dumps(result, ensure_ascii=False)[:4000]
if result is not None else "",
False,
)
if item_type == "dynamicToolCall":
content_items = item.get("contentItems") or []
if isinstance(content_items, list) and content_items:
return (
json.dumps(content_items, ensure_ascii=False)[:4000],
not bool(item.get("success", True)),
)
success = item.get("success", True)
return f"success={success}", not bool(success)
return "", False
def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
"""Build an ``on_event`` callback that wires codex app-server JSON-RPC
notifications into Hermes' gateway UI callbacks.
Returns a single-argument callable suitable for
``CodexAppServerSession(on_event=...)``.
Translation map:
* ``item/started`` for tool-shaped items ``tool_progress_callback(
"tool.started", name, preview, args)``
* ``item/completed`` for tool-shaped items ``tool_progress_callback(
"tool.completed", name, None, None, duration=..., is_error=...,
result=...)``
* ``item/agentMessage/delta`` ``_fire_stream_delta(text)`` so chat
adapters can render the assistant's reply as it streams.
* ``item/reasoning/delta`` ``_fire_reasoning_delta(text)``
* ``item/completed`` for ``agentMessage``
``_emit_interim_assistant_message({"role": "assistant",
"content": text})``. The gateway's ``already_streamed`` check
dedupes against any text the stream-delta callback already
rendered for the same message.
All callback invocations are guarded a buggy display callback must
not tear down the codex turn loop. Errors are logged at DEBUG so the
notification stream keeps flowing regardless.
"""
# item_id -> (tool_name, args, started_wall_time). Populated on
# item/started and consumed on item/completed so duration is correct
# even when codex doesn't report durationMs.
started: dict[str, tuple[str, dict, float]] = {}
def _stable_call_id(item: dict, name: str) -> str:
"""Deterministic tool_call id mirroring CodexEventProjector, so a
live TUI tool card correlates with the same tool call after the
session is resumed and history is projected."""
from agent.transports.codex_event_projector import _deterministic_call_id
item_id = item.get("id") or ""
item_type = item.get("type") or ""
if item_type == "commandExecution":
return _deterministic_call_id("exec", item_id)
if item_type == "fileChange":
return _deterministic_call_id("apply_patch", item_id)
if item_type == "mcpToolCall":
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
return _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
if item_type == "dynamicToolCall":
tool = item.get("tool") or "unknown"
return _deterministic_call_id(f"dyn_{tool}", item_id)
return _deterministic_call_id(name, item_id)
def _fire_tool_started(item: dict) -> None:
item_id = item.get("id") or ""
name = _codex_item_to_tool_name(item)
args = _codex_item_to_args(item)
if item_id:
started[item_id] = (name, args, time.monotonic())
cb = getattr(agent, "tool_progress_callback", None)
if cb is not None:
try:
cb("tool.started", name, _codex_item_to_preview(item), args)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.started for %s",
name, exc_info=True,
)
# Authoritative stable-ID tool card (TUI / desktop). Fires
# alongside tool_progress so surfaces that render structured tool
# cards (not just progress bubbles) stay correlated with the
# projected history entry after a resume.
start_cb = getattr(agent, "tool_start_callback", None)
if start_cb is not None:
try:
start_cb(_stable_call_id(item, name), name, args)
except Exception:
logger.debug(
"tool_start_callback raised for %s", name, exc_info=True,
)
def _fire_tool_completed(item: dict) -> None:
item_id = item.get("id") or ""
name = _codex_item_to_tool_name(item)
prior = started.pop(item_id, None)
# Prefer codex's own durationMs when present so the bubble shows
# exact tool wall-time; fall back to our started timestamp; fall
# back to None if we never saw an item/started (some codex
# versions only emit completed for fast items).
duration: Any = None
codex_ms = item.get("durationMs")
if isinstance(codex_ms, (int, float)) and codex_ms >= 0:
duration = codex_ms / 1000.0
elif prior is not None:
duration = time.monotonic() - prior[2]
result, is_error = _codex_item_completion_payload(item)
cb = getattr(agent, "tool_progress_callback", None)
if cb is not None:
try:
cb("tool.completed", name, None, None,
duration=duration, is_error=is_error, result=result)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.completed for %s",
name, exc_info=True,
)
complete_cb = getattr(agent, "tool_complete_callback", None)
if complete_cb is not None:
args = prior[1] if prior is not None else _codex_item_to_args(item)
try:
complete_cb(_stable_call_id(item, name), name, args, result)
except Exception:
logger.debug(
"tool_complete_callback raised for %s", name, exc_info=True,
)
def _fire_text_delta(params: dict) -> None:
text = params.get("delta") or params.get("text") or ""
if not isinstance(text, str) or not text:
return
fn = getattr(agent, "_fire_stream_delta", None)
if fn is None:
return
try:
fn(text)
except Exception:
logger.debug("_fire_stream_delta raised", exc_info=True)
def _fire_reasoning_delta(params: dict) -> None:
text = params.get("delta") or params.get("text") or ""
if not isinstance(text, str) or not text:
return
fn = getattr(agent, "_fire_reasoning_delta", None)
if fn is None:
return
try:
fn(text)
except Exception:
logger.debug("_fire_reasoning_delta raised", exc_info=True)
def _fire_agent_message_completed(item: dict) -> None:
text = item.get("text") or ""
if not isinstance(text, str) or not text.strip():
return
# display.show_commentary=false — mid-turn narration stays off the
# visible interim path on this runtime too (same contract as the
# codex_responses commentary channel).
if not getattr(agent, "show_commentary", True):
return
emit = getattr(agent, "_emit_interim_assistant_message", None)
if emit is None:
return
try:
emit({"role": "assistant", "content": text})
except Exception:
logger.debug(
"_emit_interim_assistant_message raised", exc_info=True,
)
def on_event(note: dict) -> None:
if not isinstance(note, dict):
return
method = note.get("method") or ""
params = note.get("params") or {}
if not isinstance(params, dict):
params = {}
if method == "item/agentMessage/delta":
_fire_text_delta(params)
return
if method in {"item/reasoning/delta", "item/reasoning/summaryDelta"}:
_fire_reasoning_delta(params)
return
item = params.get("item")
if not isinstance(item, dict):
return
item_type = item.get("type") or ""
if method == "item/started" and item_type in _CODEX_TOOL_ITEM_TYPES:
_fire_tool_started(item)
return
if method == "item/completed":
if item_type in _CODEX_TOOL_ITEM_TYPES:
_fire_tool_completed(item)
elif item_type == "agentMessage":
_fire_agent_message_completed(item)
return on_event
def run_codex_app_server_turn(
agent,
*,
@@ -628,10 +244,7 @@ def run_codex_app_server_turn(
Called from run_conversation() when agent.api_mode == "codex_app_server".
Returns the same dict shape as the chat_completions path.
"""
from agent.transports.codex_app_server_session import (
CodexAppServerSession,
_ServerRequestRouting,
)
from agent.transports.codex_app_server_session import CodexAppServerSession
# Lazy session: one CodexAppServerSession per AIAgent instance.
# Spawned on first turn, reused across turns, closed at AIAgent
@@ -649,42 +262,26 @@ def run_codex_app_server_turn(
except Exception:
approval_callback = None
# Gateway / cron contexts have no UI to surface codex's approval
# requests through, so codex app-server exec / apply_patch requests
# fail closed (silently decline) by default. When the user has
# explicitly opted out of Hermes approvals — via `approvals.mode: off`
# in config, the /yolo session toggle, or --yolo / HERMES_YOLO_MODE —
# honor that and let codex's own sandbox permission profile
# (~/.codex/config.toml) be the policy gate instead of double-gating
# with a missing Hermes UI. Defaults (manual/smart/unset) preserve the
# current fail-closed behavior — this is a no-op for those users.
auto_approve_requests = False
try:
from tools.approval import is_approval_bypass_active
def _on_codex_event(note: dict) -> None:
# Bridge Codex app-server item/started notifications to Hermes
# tool-progress so gateways show verbose "running X" breadcrumbs
# on this route too (#38835).
progress_callback = getattr(agent, "tool_progress_callback", None)
if progress_callback is None:
return
mapped = _codex_note_to_tool_progress(note)
if mapped is None:
return
tool_name, preview, args = mapped
try:
progress_callback("tool.started", tool_name, preview, args)
except Exception:
logger.debug("codex tool-progress callback raised", exc_info=True)
auto_approve_requests = is_approval_bypass_active()
except Exception:
logger.debug(
"codex app-server: approval-bypass lookup failed; "
"keeping fail-closed default",
exc_info=True,
)
# Bridge codex JSON-RPC notifications (item/started, item/completed,
# item/agentMessage/delta, ...) into Hermes' gateway UI callbacks
# (tool_progress_callback, _fire_stream_delta,
# _emit_interim_assistant_message). Without this, Discord/Telegram
# users see no live tool-progress or interim commentary while
# codex_app_server is running — only the final answer (#33200).
# Supersedes the narrower item/started-only bridge from #38835.
agent._codex_session = CodexAppServerSession(
cwd=cwd,
approval_callback=approval_callback,
request_routing=_ServerRequestRouting(
auto_approve_exec=auto_approve_requests,
auto_approve_apply_patch=auto_approve_requests,
),
on_event=make_codex_app_server_event_bridge(agent),
on_event=_on_codex_event,
)
# NOTE: the user message is ALREADY appended to messages by the
@@ -736,28 +333,6 @@ def run_codex_app_server_turn(
if turn.projected_messages:
messages.extend(turn.projected_messages)
# Persist the newly-projected assistant/tool messages ourselves.
# This path is an early return that bypasses conversation_loop, whose
# normal per-step _persist_session() calls would otherwise flush them.
# The inbound user turn was already flushed at turn start
# (turn_context.py _persist_session), and _flush_messages_to_session_db
# is idempotent via the intrinsic _DB_PERSISTED_MARKER — so this writes
# ONLY the new codex projected rows and does NOT re-write the user turn.
# Keeping the agent as the sole persister lets us return
# agent_persisted=True below, so the gateway skips its own DB write and
# we avoid the #860/#42039 duplicate user-message write (append_message
# is a raw INSERT with no dedup, so a gateway re-write would duplicate
# the already-flushed user turn). See gateway/run.py agent_persisted.
if getattr(agent, "_session_db", None) is not None:
try:
agent._flush_messages_to_session_db(messages)
except Exception:
logger.debug(
"codex app-server projected-message flush failed",
exc_info=True,
)
# Counter ticks for the agent-improvement loop.
# _turns_since_memory and _user_turn_count are ALREADY incremented
# in the run_conversation() pre-loop block (lines ~11793-11817) so we
@@ -768,7 +343,6 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
_record_codex_app_server_compaction(agent, turn)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
@@ -820,18 +394,6 @@ def run_codex_app_server_turn(
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"error": turn.error,
# The codex app-server runtime IS an early-return path that bypasses
# conversation_loop, but we flush the projected assistant/tool messages
# ourselves above (see the _flush_messages_to_session_db call after
# messages.extend). The inbound user turn was already flushed at turn
# start (turn_context._persist_session) and the flush dedups via
# _DB_PERSISTED_MARKER, so state.db ends up with each real message
# exactly once and session_search / conversation-distill see the full
# gateway conversation. Report agent_persisted=True so the gateway
# skips its own append_to_transcript DB write — writing again there
# would re-INSERT the already-flushed user turn (append_message has no
# dedup), reintroducing the #860 / #42039 duplicate-write bug.
"agent_persisted": True,
"codex_thread_id": turn.thread_id,
"codex_turn_id": turn.turn_id,
**usage_result,
@@ -876,48 +438,18 @@ def _event_field(event: Any, name: str, default: Any = None) -> Any:
return value if value is not None else default
def _item_field(item: Any, name: str, default: Any = None) -> Any:
"""Field access for nested Response items (attr-style SDK object or dict)."""
value = getattr(item, name, None)
if value is None and isinstance(item, dict):
value = item.get(name, default)
return value if value is not None else default
def _raise_stream_error(event: Any) -> None:
"""Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame.
The Responses spec puts the failure details at the top level of the
frame (``{"type": "error", "code": ..., "message": ..., "param": ...}``),
but the official OpenAI SDK and several OpenAI-compatible proxies wrap
them in an HTTP-style nested envelope instead
(``{"type": "error", "error": {"code": ..., "message": ..., "param": ...}}``).
Read the top-level fields first, then fall back to the nested envelope so
the error classifier sees the provider's real code/message (rate-limit vs
context-overflow vs entitlement) rather than the generic placeholder.
Port of anomalyco/opencode#36130.
Imported lazily so this module stays importable from places that don't
pull in ``run_agent`` (e.g. plugin code, doc tools).
"""
from run_agent import _StreamErrorEvent
nested = _event_field(event, "error")
def _error_field(name: str) -> Any:
value = _event_field(event, name)
if value is None and nested is not None:
value = _item_field(nested, name)
return value
raw_message = _error_field("message")
if raw_message is not None and not isinstance(raw_message, str):
raw_message = str(raw_message)
message = (raw_message or "stream emitted error event").strip() or "stream emitted error event"
message = (_event_field(event, "message", "") or "stream emitted error event").strip()
raise _StreamErrorEvent(
message,
code=_error_field("code"),
param=_error_field("param"),
code=_event_field(event, "code"),
param=_event_field(event, "param"),
)
@@ -927,7 +459,6 @@ def _consume_codex_event_stream(
model: str,
on_text_delta=None,
on_reasoning_delta=None,
on_commentary_message=None,
on_first_delta=None,
on_event=None,
interrupt_check=None,
@@ -959,11 +490,7 @@ def _consume_codex_event_stream(
* ``on_text_delta(str)`` fires per ``response.output_text.delta``, suppressed
once a function_call event is seen (so tool-call turns don't bleed text
into the chat).
* ``on_reasoning_delta(str)`` fires per ``response.reasoning.*.delta`` and
``phase=analysis`` message deltas. When no dedicated commentary callback
is supplied, commentary also uses this legacy fallback.
* ``on_commentary_message(str)`` fires once per completed
``phase=commentary`` message, before any following tool item executes.
* ``on_reasoning_delta(str)`` fires per ``response.reasoning.*.delta``.
* ``on_first_delta()`` one-shot, fires on the first text delta only.
* ``on_event(event)`` fires for every event before any other processing.
Used for watchdog activity, debug logging, anything wire-shape-agnostic.
@@ -973,8 +500,6 @@ def _consume_codex_event_stream(
collected_text_deltas: List[str] = []
has_tool_calls = False
first_delta_fired = False
active_message_phase: str | None = None
commentary_text_deltas: List[str] = []
terminal_status: str = "completed"
terminal_usage: Any = None
terminal_response_id: str = None
@@ -1008,43 +533,9 @@ def _consume_codex_event_stream(
if event_type == "error":
_raise_stream_error(event)
# Track the phase of the active streamed message item. Codex/Harmony
# ``commentary``/``analysis`` text is mid-turn preamble/progress
# narration, never the final answer. We still collect completed output
# items for replay, but route those deltas to the reasoning callback so
# they display like thinking text instead of assistant content.
if event_type == "response.output_item.added":
item = _event_field(event, "item")
item_type = _item_field(item, "type", "")
if item_type == "message":
phase = _item_field(item, "phase", None)
active_message_phase = phase.strip().lower() if isinstance(phase, str) else None
if active_message_phase == "commentary":
commentary_text_deltas = []
else:
active_message_phase = None
if "function_call" in str(item_type):
has_tool_calls = True
continue
if "output_text.delta" in event_type or event_type == "response.output_text.delta":
delta_text = _event_field(event, "delta", "")
if delta_text and active_message_phase == "commentary":
commentary_text_deltas.append(delta_text)
# Preserve CLI/backward compatibility when no first-class
# commentary consumer is installed.
if on_commentary_message is None and on_reasoning_delta is not None:
try:
on_reasoning_delta(delta_text)
except Exception:
logger.debug("Codex stream on_reasoning_delta raised", exc_info=True)
elif delta_text and active_message_phase == "analysis":
if on_reasoning_delta is not None:
try:
on_reasoning_delta(delta_text)
except Exception:
logger.debug("Codex stream on_reasoning_delta raised", exc_info=True)
elif delta_text:
if delta_text:
collected_text_deltas.append(delta_text)
if not has_tool_calls:
if not first_delta_fired:
@@ -1078,27 +569,6 @@ def _consume_codex_event_stream(
done_item = _event_field(event, "item")
if done_item is not None:
collected_output_items.append(done_item)
done_phase = _item_field(done_item, "phase", None)
done_phase = done_phase.strip().lower() if isinstance(done_phase, str) else None
if done_phase == "commentary" and on_commentary_message is not None:
commentary_text = "".join(commentary_text_deltas).strip()
if not commentary_text:
content_parts = _item_field(done_item, "content", [])
if isinstance(content_parts, list):
commentary_text = "".join(
str(_item_field(part, "text", "") or "")
for part in content_parts
if _item_field(part, "type", "") == "output_text"
).strip()
if commentary_text:
try:
on_commentary_message(commentary_text)
except Exception:
logger.debug(
"Codex stream on_commentary_message raised",
exc_info=True,
)
commentary_text_deltas = []
continue
if event_type in _TERMINAL_EVENT_TYPES:
@@ -1199,14 +669,14 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
def _on_reasoning_delta(text: str) -> None:
agent._fire_reasoning_delta(text)
def _on_commentary_message(text: str) -> None:
agent._fire_streamed_codex_commentary(text)
def _on_event(event: Any) -> None:
# TTFB watchdog and activity touch — runs once per SSE event.
agent._codex_stream_last_event_ts = time.time()
agent._touch_activity("receiving stream response")
def _interrupt_check() -> bool:
return bool(agent._interrupt_requested)
for attempt in range(max_stream_retries + 1):
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted before Codex stream retry")
@@ -1226,27 +696,6 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
continue
raise
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions/anthropic/bedrock paths. If a prior attempt's
# stream is somehow still alive, this claim supersedes it so its
# late deltas are fenced out of the turn; conversely, a newer
# attempt supersedes us and the interrupt_check below stops our
# consumption immediately.
_writer_token = claim_stream_writer(agent)
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
if agent._interrupt_requested:
return True
if not stream_writer_is_current(agent, _tok):
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
"invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
return True
return False
try:
# Compatibility: some mocks/providers return a concrete response
# instead of an iterable. Pass it straight through.
@@ -1259,17 +708,9 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
model=api_kwargs.get("model"),
on_text_delta=_on_text_delta,
on_reasoning_delta=_on_reasoning_delta,
on_commentary_message=(
_on_commentary_message
if (
getattr(agent, "interim_assistant_callback", None) is not None
and getattr(agent, "show_commentary", True)
)
else None
),
on_first_delta=on_first_delta,
on_event=_on_event,
interrupt_check=_interrupt_or_superseded,
interrupt_check=_interrupt_check,
)
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
if attempt < max_stream_retries:
@@ -1318,5 +759,4 @@ __all__ = [
"run_codex_stream",
"run_codex_create_stream_fallback",
"_consume_codex_event_stream",
"make_codex_app_server_event_bridge",
]
+12 -51
View File
@@ -55,13 +55,11 @@ import json
import logging
import os
import re
import tempfile
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from hermes_cli._subprocess_compat import bounded_git_probe
logger = logging.getLogger("hermes.coding_context")
CODING_TOOLSET = "coding"
@@ -353,29 +351,6 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
return "auto"
def _coding_instructions(config: Optional[dict[str, Any]]) -> str:
"""Standing operator instructions for the coding posture (config).
``agent.coding_instructions`` a string or list of strings appended to the
coding brief as an extra stable system block, so a user can pin project-wide
coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve;
clean the diff before committing") without editing the shipped brief.
Cache-safe: resolved once per session into the stable system-prompt tier,
like the rest of the posture.
"""
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
raw = ((config or {}).get("agent", {}) or {}).get("coding_instructions", "")
if isinstance(raw, (list, tuple)):
return "\n".join(str(item).strip() for item in raw if str(item).strip())
return str(raw or "").strip()
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
if cwd:
return Path(cwd).expanduser()
@@ -412,18 +387,10 @@ def _marker_root(cwd: Path) -> Optional[Path]:
"""
current = cwd.resolve()
home = _home()
# Shared world-writable temp roots are never project roots: a stray
# manifest in /tmp (left by any process) must not flip every session
# whose cwd lives under the temp dir into the coding posture. Same
# reasoning as the $HOME skip below.
try:
temp_root = Path(tempfile.gettempdir()).resolve()
except Exception:
temp_root = None
for depth, parent in enumerate([current, *current.parents]):
if depth > 6:
break
if parent == home or (temp_root is not None and parent == temp_root):
if parent == home:
continue
for marker in _PROJECT_MARKERS:
if (parent / marker).exists():
@@ -490,9 +457,6 @@ class RuntimeMode:
# only to steer edit-format guidance toward the model's family — see
# ``_edit_format_line``. Fixed for the session, so cache-safe.
model: Optional[str] = None
# Standing operator instructions (``agent.coding_instructions``), appended
# as an extra stable system block. Empty unless the user configures it.
instructions: str = ""
@property
def kind(self) -> str:
@@ -539,10 +503,6 @@ class RuntimeMode:
workspace = build_coding_workspace_block(self.cwd)
if workspace:
blocks.append(workspace)
# Operator instructions ride their own block so the brief (block 0) stays
# byte-stable and cache-keyed independently of user config.
if self.instructions:
blocks.append(f"Operator instructions (from config):\n{self.instructions}")
return blocks
def compact_skill_categories(self) -> frozenset[str]:
@@ -595,7 +555,6 @@ def resolve_runtime_mode(
cwd=resolved_cwd,
config_mode=mode,
model=model,
instructions=_coding_instructions(config),
)
@@ -688,14 +647,16 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
def _git(cwd: Path, *args: str) -> str:
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
on Windows a plain ``subprocess.run(timeout=...)`` here deadlocked the agent
turn inside ``build_coding_workspace_block`` when a killed git left a suspended
descendant holding the pipe handles (issue #66037).
"""
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
-156
View File
@@ -1,156 +0,0 @@
"""Live session context-window breakdown for UI surfaces.
Estimates how the next provider request is composed: system prompt tiers,
tool schemas, and conversation history. Uses the same rough char/4 heuristic
as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align
with compression thresholds not exact tokenizer counts.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
_CATEGORY_COLORS = {
"system_prompt": "var(--context-usage-system)",
"tool_definitions": "var(--context-usage-tools)",
"rules": "var(--context-usage-rules)",
"skills": "var(--context-usage-skills)",
"mcp": "var(--context-usage-mcp)",
"subagent_definitions": "var(--context-usage-subagents)",
"memory": "var(--context-usage-memory)",
"conversation": "var(--context-usage-conversation)",
}
def _chars_to_tokens(text: str) -> int:
if not text:
return 0
return (len(text) + 3) // 4
def _json_tokens(value: Any) -> int:
if not value:
return 0
return _chars_to_tokens(json.dumps(value, ensure_ascii=False))
def _tool_name(tool: dict) -> str:
fn = tool.get("function") if isinstance(tool, dict) else None
if isinstance(fn, dict):
return str(fn.get("name") or "")
return str(tool.get("name") or "")
def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
builtin: List[dict] = []
mcp: List[dict] = []
subagent: List[dict] = []
for tool in tools:
name = _tool_name(tool)
if name.startswith("mcp_"):
mcp.append(tool)
elif name in _SUBAGENT_TOOL_NAMES:
subagent.append(tool)
else:
builtin.append(tool)
return builtin, mcp, subagent
def _memory_blocks(agent: Any) -> Tuple[str, str]:
memory_block = ""
user_block = ""
store = getattr(agent, "_memory_store", None)
if store is None:
return memory_block, user_block
try:
if getattr(agent, "_memory_enabled", True):
memory_block = store.format_for_system_prompt("memory") or ""
if getattr(agent, "_user_profile_enabled", True):
user_block = store.format_for_system_prompt("user") or ""
except Exception:
pass
return memory_block, user_block
def _strip_blocks(text: str, *blocks: str) -> str:
out = text
for block in blocks:
if block:
out = out.replace(block, "")
return out.strip()
def compute_session_context_breakdown(
agent: Any,
messages: Optional[List[dict]] = None,
) -> Dict[str, Any]:
"""Return a Cursor-style context usage breakdown for one live agent."""
from agent.model_metadata import estimate_messages_tokens_rough
from agent.system_prompt import build_system_prompt_parts
parts = build_system_prompt_parts(agent)
stable = parts.get("stable", "") or ""
context = parts.get("context", "") or ""
volatile = parts.get("volatile", "") or ""
skills_match = _SKILLS_BLOCK_RE.search(stable)
skills_index = skills_match.group(0) if skills_match else ""
memory_block, user_block = _memory_blocks(agent)
memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip()
system_core = _strip_blocks(stable, skills_index)
system_tail = _strip_blocks(volatile, memory_block, user_block)
system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip()
tools = list(getattr(agent, "tools", None) or [])
builtin_tools, mcp_tools, subagent_tools = _split_tools(tools)
conversation_tokens = estimate_messages_tokens_rough(messages or [])
categories = [
("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)),
("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)),
("rules", "Rules", _chars_to_tokens(context)),
("skills", "Skills", _chars_to_tokens(skills_index)),
("mcp", "MCP", _json_tokens(mcp_tools)),
("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)),
("memory", "Memory", _chars_to_tokens(memory_text)),
("conversation", "Conversation", conversation_tokens),
]
estimated_total = sum(tokens for _, _, tokens in categories)
comp = getattr(agent, "context_compressor", None)
context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0
measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0
context_used = measured_used if measured_used > 0 else estimated_total
context_percent = (
max(0, min(100, round(context_used / context_max * 100)))
if context_max
else 0
)
return {
"categories": [
{
"color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"),
"id": category_id,
"label": label,
"tokens": tokens,
}
for category_id, label, tokens in categories
if tokens > 0
],
"context_max": context_max,
"context_percent": context_percent,
"context_used": context_used,
"estimated_total": estimated_total,
"model": getattr(agent, "model", "") or "",
}
+155 -1453
View File
File diff suppressed because it is too large Load Diff
+5 -57
View File
@@ -26,31 +26,7 @@ Lifecycle:
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from agent.redact import redact_sensitive_text
MEMORY_CONTEXT_MAX_CHARS = 6_000
_MEMORY_CONTEXT_HEAD_CHARS = 4_000
_MEMORY_CONTEXT_TAIL_CHARS = 1_500
_MEMORY_CONTEXT_TRUNCATION_MARKER = "\n...[memory provider context truncated]...\n"
def sanitize_memory_context(memory_context: str) -> str:
"""Prepare provider context for a context-engine/LLM egress boundary."""
sanitized = redact_sensitive_text(
memory_context.strip(),
force=True,
redact_url_credentials=True,
)
if len(sanitized) <= MEMORY_CONTEXT_MAX_CHARS:
return sanitized
return (
sanitized[:_MEMORY_CONTEXT_HEAD_CHARS]
+ _MEMORY_CONTEXT_TRUNCATION_MARKER
+ sanitized[-_MEMORY_CONTEXT_TAIL_CHARS:]
)
from typing import Any, Dict, List
class ContextEngine(ABC):
@@ -111,10 +87,8 @@ class ContextEngine(ABC):
def compress(
self,
messages: List[Dict[str, Any]],
current_tokens: Optional[int] = None,
focus_topic: Optional[str] = None,
force: bool = False,
memory_context: str = "",
current_tokens: int = None,
focus_topic: str = None,
) -> List[Dict[str, Any]]:
"""Compact the message list and return the new message list.
@@ -129,12 +103,6 @@ class ContextEngine(ABC):
Engines that support guided compression should prioritise
preserving information related to this topic. Engines that
don't support it may simply ignore this argument.
force: Whether a user-requested compression should bypass an
engine-owned cooldown. Engines without cooldowns may ignore it.
memory_context: Text returned by memory providers immediately before
compaction. Summarizing engines should include non-empty text in
their handoff prompt. Older engines may omit this parameter; the
host filters unsupported optional arguments by signature.
"""
# -- Optional: pre-flight check ----------------------------------------
@@ -226,17 +194,12 @@ class ContextEngine(ABC):
Default returns the standard fields run_agent.py expects.
"""
# Clamp the -1 "compression just ran, awaiting real usage" sentinel
# (set by conversation_compression) to 0 so status readers don't see a
# raw -1 or a negative usage_percent on the transitional turn. Mirrors
# the CLI/gateway status-bar paths (cli.py, tui_gateway/server.py).
last_prompt = self.last_prompt_tokens if self.last_prompt_tokens > 0 else 0
return {
"last_prompt_tokens": last_prompt,
"last_prompt_tokens": self.last_prompt_tokens,
"threshold_tokens": self.threshold_tokens,
"context_length": self.context_length,
"usage_percent": (
min(100, last_prompt / self.context_length * 100)
min(100, self.last_prompt_tokens / self.context_length * 100)
if self.context_length else 0
),
"compression_count": self.compression_count,
@@ -260,19 +223,4 @@ class ContextEngine(ABC):
(e.g. recalculate DAG budgets, switch summary models).
"""
self.context_length = context_length
# Apply per-model threshold overrides if set (longest substring match).
# Falls back to _config_threshold_percent (the raw config value) when
# no override matches. Plugin engines that override update_model() can
# call resolve_model_threshold() for the same logic.
from agent.context_compressor import resolve_model_threshold
if not hasattr(self, "_config_threshold_percent"):
# Snapshot the pre-override percent ONCE so repeated model
# switches fall back to the engine's configured value, not the
# previous model's override.
self._config_threshold_percent = self.threshold_percent
self._base_threshold_percent = resolve_model_threshold(
model, getattr(self, "model_thresholds", {}),
self._config_threshold_percent,
)
self.threshold_percent = self._base_threshold_percent
self.threshold_tokens = int(context_length * self.threshold_percent)
+8 -55
View File
@@ -12,7 +12,6 @@ from pathlib import Path
from typing import Awaitable, Callable
from agent.model_metadata import estimate_tokens_rough
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
_QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
REFERENCE_PATTERN = re.compile(
@@ -152,24 +151,13 @@ async def preprocess_context_references_async(
blocks: list[str] = []
injected_tokens = 0
# Expand all references concurrently. Each _expand_reference is independent
# (no shared state during expansion) — a message with several @url: refs
# would otherwise pay one full web_extract round-trip per ref in series.
# gather preserves positional order, so we reassemble warnings/blocks in the
# original ref order exactly as the prior serial loop did; the token-budget
# check below is unchanged (it runs once, after all refs are expanded).
expanded = await asyncio.gather(
*(
_expand_reference(
ref,
cwd_path,
url_fetcher=url_fetcher,
allowed_root=allowed_root_path,
)
for ref in refs
for ref in refs:
warning, block = await _expand_reference(
ref,
cwd_path,
url_fetcher=url_fetcher,
allowed_root=allowed_root_path,
)
)
for warning, block in expanded:
if warning:
warnings.append(warning)
if block:
@@ -302,7 +290,6 @@ def _expand_git_reference(
args: list[str],
label: str,
) -> tuple[str | None, str | None]:
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try:
result = subprocess.run(
["git", *args],
@@ -311,7 +298,6 @@ def _expand_git_reference(
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -339,9 +325,9 @@ async def _fetch_url_content(
async def _default_url_fetcher(url: str) -> str:
from tools.web_tools import web_extract_tool
raw = await web_extract_tool([url], format="markdown")
raw = await web_extract_tool([url], format="markdown", use_llm_processing=True)
payload = json.loads(raw)
docs = payload.get("results", [])
docs = payload.get("data", {}).get("documents", [])
if not docs:
return ""
doc = docs[0]
@@ -381,37 +367,6 @@ def _ensure_reference_path_allowed(path: Path) -> None:
continue
raise ValueError("path is a sensitive credential or internal Hermes path and cannot be attached")
# Anchor to the canonical read deny-list (agent/file_safety.get_read_block_error),
# the single source of truth used by the file/terminal read path. The narrow
# list above predates that guard and never caught the real credential stores:
# provider keys (auth.json), Anthropic OAuth tokens (.anthropic_oauth.json),
# MCP OAuth material (mcp-tokens/), webhook HMAC secrets, and project-local
# .env files. That gap matters because the gateway feeds UNTRUSTED remote
# message text into reference expansion, so `@file:~/.hermes/auth.json` from a
# chat peer would otherwise read the operator's keys straight into context.
# Routing through the canonical guard closes the gap today and keeps this path
# protected automatically whenever that deny-list grows.
try:
from agent.file_safety import get_read_block_error
if get_read_block_error(str(path)) is not None:
raise ValueError(
"path is a sensitive credential or internal Hermes path and cannot be attached"
)
except ValueError:
raise
except Exception:
# Fail CLOSED on the security path. This guard exists specifically to
# cover credential stores the narrow list above misses (auth.json,
# .anthropic_oauth.json, mcp-tokens/, ...). If the canonical lookup
# ever fails, silently falling through would re-open that exact hole —
# the gateway feeds untrusted remote text here, so a probe could then
# attach the operator's keys. Refuse instead: a spurious block on a
# legitimate file is a recoverable annoyance; a leaked credential is not.
raise ValueError(
"path could not be verified against the credential deny-list and cannot be attached"
)
def _strip_trailing_punctuation(value: str) -> str:
stripped = value.rstrip(TRAILING_PUNCTUATION)
@@ -528,7 +483,6 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try:
result = subprocess.run(
["rg", "--files", str(path.relative_to(cwd))],
@@ -537,7 +491,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
File diff suppressed because it is too large Load Diff
+189 -1286
View File
File diff suppressed because it is too large Load Diff
+14 -86
View File
@@ -21,14 +21,8 @@ from pathlib import Path
from types import SimpleNamespace
from typing import Any
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
Function,
)
from agent.file_safety import get_read_block_error, get_write_denied_error
from agent.file_safety import get_read_block_error, is_write_denied
from agent.redact import redact_sensitive_text
from tools.environments.local import hermes_subprocess_env
ACP_MARKER_BASE_URL = "acp://copilot"
_DEFAULT_TIMEOUT_SECONDS = 900.0
@@ -100,10 +94,7 @@ def _resolve_home_dir() -> str:
def _build_subprocess_env() -> dict[str, str]:
# Copilot ACP is a model-driving CLI executor: it legitimately needs LLM
# provider credentials. Route through the central helper so Tier-1 secrets
# (gateway bot tokens, GitHub auth, infra) are still stripped (#29157).
env = hermes_subprocess_env(inherit_credentials=True)
env = os.environ.copy()
home = _resolve_home_dir()
env["HOME"] = home
from hermes_constants import apply_subprocess_home_env
@@ -233,73 +224,11 @@ def _render_message_content(content: Any) -> str:
return str(content).strip()
def _build_openai_tool_call(
*,
call_id: str,
name: str,
arguments: str,
) -> ChatCompletionMessageToolCall:
"""Build an OpenAI-compatible tool-call object for downstream handling."""
return ChatCompletionMessageToolCall(
id=call_id,
call_id=call_id,
response_item_id=None,
type="function",
function=Function(name=name, arguments=arguments),
)
def _completion_to_stream_chunks(completion: SimpleNamespace) -> list[SimpleNamespace]:
"""Convert a one-shot ACP response into OpenAI-style stream chunks."""
choice = completion.choices[0]
message = choice.message
tool_call_deltas = None
if message.tool_calls:
tool_call_deltas = []
for index, tool_call in enumerate(message.tool_calls):
tool_call_deltas.append(
SimpleNamespace(
index=index,
id=getattr(tool_call, "id", None),
type=getattr(tool_call, "type", "function"),
function=SimpleNamespace(
name=getattr(tool_call.function, "name", None),
arguments=getattr(tool_call.function, "arguments", None),
),
)
)
delta = SimpleNamespace(
role="assistant",
content=message.content or None,
tool_calls=tool_call_deltas,
reasoning_content=message.reasoning_content,
reasoning=message.reasoning,
)
data_chunk = SimpleNamespace(
choices=[
SimpleNamespace(
index=0,
delta=delta,
finish_reason=choice.finish_reason,
)
],
model=completion.model,
usage=None,
)
usage_chunk = SimpleNamespace(
choices=[],
model=completion.model,
usage=completion.usage,
)
return [data_chunk, usage_chunk]
def _extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessageToolCall], str]:
def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:
if not isinstance(text, str) or not text.strip():
return [], ""
extracted: list[ChatCompletionMessageToolCall] = []
extracted: list[SimpleNamespace] = []
consumed_spans: list[tuple[int, int]] = []
def _try_add_tool_call(raw_json: str) -> None:
@@ -323,10 +252,12 @@ def _extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessage
call_id = f"acp_call_{len(extracted)+1}"
extracted.append(
_build_openai_tool_call(
SimpleNamespace(
id=call_id,
call_id=call_id,
name=fn_name.strip(),
arguments=fn_args,
response_item_id=None,
type="function",
function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),
)
)
@@ -445,7 +376,6 @@ class CopilotACPClient:
timeout: float | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: Any = None,
stream: bool = False,
**_: Any,
) -> Any:
prompt_text = _format_messages_as_prompt(
@@ -492,14 +422,11 @@ class CopilotACPClient:
)
finish_reason = "tool_calls" if tool_calls else "stop"
choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
completion = SimpleNamespace(
return SimpleNamespace(
choices=[choice],
usage=usage,
model=model or "copilot-acp",
)
if stream:
return _completion_to_stream_chunks(completion)
return completion
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
try:
@@ -727,9 +654,10 @@ class CopilotACPClient:
elif method == "fs/write_text_file":
try:
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
denied = get_write_denied_error(str(path))
if denied:
raise PermissionError(denied)
if is_write_denied(str(path)):
raise PermissionError(
f"Write denied: '{path}' is a protected system/credential file."
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(params.get("content") or ""))
response = {
+1 -1
View File
@@ -22,7 +22,7 @@ _PERSISTABLE_PROVIDER_SOURCES = frozenset({
("minimax-oauth", "oauth"),
("nous", "device_code"),
("openai-codex", "device_code"),
("xai-oauth", "device_code"),
("xai-oauth", "loopback_pkce"),
})
_SAFE_SECRETISH_METADATA_KEYS = frozenset({
+60 -370
View File
@@ -43,19 +43,11 @@ logger = logging.getLogger(__name__)
def _load_config_safe() -> Optional[dict]:
"""Load config.yaml read-only, returning None on any error.
Uses ``load_config_readonly()``: every consumer in this module only reads
(``get_pool_strategy``, ``_iter_custom_providers``, the model-config seed),
and the deepcopy that ``load_config()`` pays per call is what made
credential-pool checks the dominant cost of ``model.options`` the picker
calls ``load_pool()`` once per provider row, each of which loaded (and
deep-copied) the full config again.
"""
"""Load config.yaml, returning None on any error."""
try:
from hermes_cli.config import load_config_readonly
from hermes_cli.config import load_config
return load_config_readonly()
return load_config()
except Exception:
return None
@@ -90,7 +82,7 @@ _TERMINAL_AUTH_REASONS = frozenset({
# without losing recoverability — the user always has the option to re-add
# via ``hermes auth add``.
#
# Singleton-seeded entries (``device_code``, ``claude_code``)
# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)
# are NOT pruned because ``_seed_from_singletons`` would just re-create them
# on the next ``load_pool()`` with the same stale singleton tokens, defeating
# the cleanup. They remain in the pool marked DEAD until an explicit re-auth
@@ -122,20 +114,6 @@ EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60 # 1 hour
# Throttle window for the "no available entries" INFO line. Credential
# selection runs on a hot path (every model call, plus auxiliary tasks like
# compression/moa/titles), so when a pool is empty or fully exhausted the
# un-throttled log fires on *every* selection. On Windows several Hermes
# processes share one rotating log guarded by concurrent-log-handler's
# cross-process lock; that per-selection volume storms the lock
# (``RuntimeError: Cannot acquire lock after 20 attempts``), pegs a core, and
# stalls the asyncio event loop long enough to fail the Desktop backend
# readiness handshake ("Timed out connecting to Hermes backend after
# 15000ms"). Logging the condition at most once per window preserves the
# signal while removing the storm — same class of fix as the warn-once
# dedup in #58265.
NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS = 60.0
# Pool key prefix for custom OpenAI-compatible endpoints.
# Custom endpoints all share provider='custom' but are keyed by their
# custom_providers name: 'custom:<normalized_name>'.
@@ -150,17 +128,6 @@ _EXTRA_KEYS = frozenset({
})
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
if (
provider == "anthropic"
and isinstance(token, str)
and token.startswith("sk-ant-oat")
):
return AUTH_TYPE_OAUTH
return str(auth_type or AUTH_TYPE_API_KEY)
@dataclass
class PooledCredential:
provider: str
@@ -190,11 +157,6 @@ class PooledCredential:
def __post_init__(self):
if self.extra is None:
self.extra = {}
self.auth_type = _normalize_pool_auth_type(
self.provider,
self.access_token,
self.auth_type,
)
def __getattr__(self, name: str):
if name in _EXTRA_KEYS:
@@ -483,44 +445,6 @@ def get_pool_strategy(provider: str) -> str:
return STRATEGY_FILL_FIRST
def credential_pool_matches_provider(
pool_or_provider: Any,
provider: Optional[str],
*,
base_url: Optional[str] = None,
) -> bool:
"""Return whether a pool belongs to the requested runtime provider.
Named custom endpoints intentionally use two identities: the live agent is
``custom`` while its pool is keyed ``custom:<name>``. Accept that pair only
when the runtime base URL resolves to the exact same custom pool key.
Empty string identities fail closed. Legacy pool adapters without a
``provider`` attribute remain compatible; production pools are scoped.
"""
raw_pool_provider = getattr(pool_or_provider, "provider", None)
if raw_pool_provider is None:
if isinstance(pool_or_provider, str):
raw_pool_provider = pool_or_provider
else:
# Backward compatibility for lightweight/unscoped pool adapters.
# Production CredentialPool instances always carry ``provider``;
# old plugins and tests may expose only select()/has_credentials().
return True
pool_provider = str(raw_pool_provider or "").strip().lower()
provider_norm = str(provider or "").strip().lower()
if not pool_provider or not provider_norm:
return False
if pool_provider == provider_norm:
return True
if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX):
return False
try:
matched_pool = get_custom_provider_pool_key(base_url or "")
except Exception:
return False
return str(matched_pool or "").strip().lower() == pool_provider
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
@@ -565,12 +489,14 @@ def _write_through_provider_state_to_global_root(
except Exception:
return
try:
auth_mod._persist_provider_state_to_store(
provider_id,
state,
global_path,
set_active=False,
)
if global_path.exists():
global_store = _load_auth_store(global_path)
else:
global_store = {}
if not isinstance(global_store, dict):
return
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
auth_mod._save_auth_store(global_store, global_path)
except Exception as exc: # pragma: no cover - best effort
logger.debug(
"%s pool refresh: write-through to global root failed: %s",
@@ -588,12 +514,6 @@ class CredentialPool:
self._lock = threading.Lock()
self._active_leases: Dict[str, int] = {}
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
# Monotonic timestamp of the last "no available entries" log, used to
# throttle that message so an empty/exhausted pool cannot storm the
# shared rotating log (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
# 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
def has_credentials(self) -> bool:
return bool(self._entries)
@@ -617,11 +537,10 @@ class CredentialPool:
self._entries[idx] = new
return
def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
def _persist(self) -> None:
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=removed_ids,
)
def _is_terminal_auth_failure(
@@ -696,32 +615,17 @@ class CredentialPool:
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
file_expires = creds.get("expiresAt", 0)
# Sync when either token changed. Access tokens can be re-issued
# without a new refresh token (silent re-issue path), so checking
# only refresh_token misses that case and leaves a stale
# access_token in the pool → 401 on every request until the pool
# entry's exhausted TTL expires.
entry_access = entry.access_token or ""
entry_refresh = entry.refresh_token or ""
if (file_access or file_refresh) and (
(file_access and file_access != entry_access)
or (file_refresh and file_refresh != entry_refresh)
):
logger.debug(
"Pool entry %s: syncing tokens from credentials file (tokens changed)",
entry.id,
)
# If the credentials file has a different token pair, sync it
if file_refresh and file_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
updated = replace(
entry,
access_token=file_access or entry.access_token,
refresh_token=file_refresh or entry.refresh_token,
expires_at_ms=file_expires or entry.expires_at_ms,
access_token=file_access,
refresh_token=file_refresh,
expires_at_ms=file_expires,
last_status=None,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
)
self._replace_entry(entry, updated)
self._persist()
@@ -804,11 +708,11 @@ class CredentialPool:
keeps the consumed refresh_token and the next ``_refresh_entry`` call
would replay it and get a ``refresh_token_reused``-style 4xx.
Only applies to entries seeded from the singleton (``device_code``);
manually added entries are independent credentials with their own
refresh-token lifecycle.
Only applies to entries seeded from the singleton (``loopback_pkce``);
manually added entries (``manual:xai_pkce``) are independent
credentials with their own refresh-token lifecycle.
"""
if self.provider != "xai-oauth" or entry.source != "device_code":
if self.provider != "xai-oauth" or entry.source != "loopback_pkce":
return entry
try:
with _auth_store_lock():
@@ -852,45 +756,6 @@ class CredentialPool:
logger.debug("Failed to sync xAI OAuth entry from auth.json: %s", exc)
return entry
def _sync_xai_oauth_entry_from_pool_store(
self, entry: PooledCredential
) -> PooledCredential:
"""Adopt a token pair rotated by another pool instance.
Direct xAI integrations load a fresh ``CredentialPool`` for each
request. Their in-memory locks therefore cannot protect xAI's
single-use refresh token across concurrent requests or processes.
This helper is called while the shared auth-store lock is held and
re-reads the exact persisted row before a refresh POST is attempted.
"""
if self.provider != "xai-oauth":
return entry
try:
persisted = next(
(
payload
for payload in read_credential_pool(self.provider)
if isinstance(payload, dict) and payload.get("id") == entry.id
),
None,
)
if not isinstance(persisted, dict):
return entry
stored = PooledCredential.from_dict(self.provider, persisted)
if (
stored.access_token != entry.access_token
or stored.refresh_token != entry.refresh_token
):
logger.debug(
"Pool entry %s: adopting xAI OAuth tokens rotated by another pool instance",
entry.id,
)
self._replace_entry(entry, stored)
return stored
except Exception as exc:
logger.debug("Failed to sync xAI OAuth entry from credential pool: %s", exc)
return entry
def _sync_nous_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
"""Sync a Nous pool entry from auth.json if tokens differ.
@@ -987,9 +852,8 @@ class CredentialPool:
"""
# Only sync entries that were seeded *from* a singleton. Manually
# added pool entries (source="manual:*") are independent credentials
# and must not write back to the singleton. All singleton-seeded
# device-code sources (nous, openai-codex, xAI) use ``device_code``.
if entry.source != "device_code":
# and must not write back to the singleton.
if entry.source not in {"device_code", "loopback_pkce"}:
return
try:
with _auth_store_lock():
@@ -1084,61 +948,6 @@ class CredentialPool:
self._mark_exhausted(entry, None)
return None
# Codex and xAI OAuth refresh tokens are single-use. The
# sync→POST→write-back sequence below must run atomically across Hermes
# processes: otherwise two processes can both adopt the same on-disk
# token, both POST it, and the loser gets ``refresh_token_reused``.
# Serialize the whole sequence through the shared cross-process
# auth-store flock (the same lock and extended-timeout pattern used by
# resolve_codex_runtime_credentials()). When a waiter finally acquires
# the lock, the in-lock re-sync below picks up the rotated token the
# winner persisted and skips the POST.
if self.provider in ("openai-codex", "xai-oauth"):
sync_entry = (
self._sync_codex_entry_from_auth_store
if self.provider == "openai-codex"
else self._sync_xai_oauth_entry_from_pool_store
)
with _auth_store_lock(
timeout_seconds=self._single_use_refresh_lock_timeout()
):
synced = sync_entry(entry)
if self.provider == "openai-codex":
if synced is not entry:
entry = synced
if not force and not self._entry_needs_refresh(entry):
return entry
return self._refresh_entry_impl(entry, force=force)
if (
synced.access_token != entry.access_token
or synced.refresh_token != entry.refresh_token
):
return synced
return self._refresh_entry_impl(synced, force=force)
return self._refresh_entry_impl(entry, force=force)
def _single_use_refresh_lock_timeout(self) -> float:
"""Lock timeout for single-use-refresh-token providers.
Covers the configured refresh POST timeout plus a margin so a slow
token endpoint cannot make the flock give up before the refresh
resolves. Reads the provider's ``HERMES_*_REFRESH_TIMEOUT_SECONDS``
override.
"""
env_var = (
"HERMES_CODEX_REFRESH_TIMEOUT_SECONDS"
if self.provider == "openai-codex"
else "HERMES_XAI_REFRESH_TIMEOUT_SECONDS"
)
refresh_timeout_seconds = auth_mod.env_float(env_var, 20)
return max(
float(auth_mod.AUTH_LOCK_TIMEOUT_SECONDS),
float(refresh_timeout_seconds) + 5.0,
)
def _refresh_entry_impl(
self, entry: PooledCredential, *, force: bool
) -> Optional[PooledCredential]:
try:
if self.provider == "anthropic":
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
@@ -1259,8 +1068,8 @@ class CredentialPool:
# consumed the refresh token between our proactive sync and the
# HTTP call. Re-check auth.json and adopt the fresh tokens if
# they have rotated since. Only meaningful for singleton-seeded
# (device_code) entries; manual entries don't share
# state with the singleton.
# (loopback_pkce) entries; manual entries don't share state with
# the singleton.
if self.provider == "xai-oauth":
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
if synced.refresh_token != entry.refresh_token:
@@ -1282,8 +1091,8 @@ class CredentialPool:
# Terminal error: auth.json has no newer tokens — the stored
# refresh_token is dead. Clear it from auth.json so the next
# session does not re-seed the same revoked credentials, and
# remove all singleton-seeded xAI entries from the in-memory
# pool. Mirrors the Nous quarantine path above.
# remove all singleton-seeded (loopback_pkce) entries from the
# in-memory pool. Mirrors the Nous quarantine path above.
if auth_mod._is_terminal_xai_oauth_refresh_error(exc):
logger.debug(
"xAI OAuth refresh token is terminally invalid; clearing local token state"
@@ -1315,17 +1124,13 @@ class CredentialPool:
logger.debug(
"Failed to clear terminal xAI OAuth state: %s", clear_exc
)
removed_ids = [
item.id for item in self._entries
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "device_code"
if item.source != "loopback_pkce"
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
self._persist()
return None
# For openai-codex: same race as xAI/nous — another Hermes process
# may have consumed the refresh token between our proactive sync
@@ -1385,17 +1190,13 @@ class CredentialPool:
logger.debug(
"Failed to clear terminal Codex OAuth state: %s", clear_exc
)
removed_ids = [
item.id for item in self._entries
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
self._persist()
return None
# For nous: another process may have consumed the refresh token
# between our proactive sync and the HTTP call. Re-sync from
@@ -1452,17 +1253,13 @@ class CredentialPool:
auth_mod.NOUS_DEVICE_CODE_SOURCE,
f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}",
}
removed_ids = [
item.id for item in self._entries
if item.source in singleton_sources
]
self._entries = [
item for item in self._entries
if item.source not in singleton_sources
]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
self._persist()
return None
self._mark_exhausted(entry, None)
return None
@@ -1499,7 +1296,7 @@ class CredentialPool:
if self.provider == "xai-oauth":
return auth_mod._xai_access_token_is_expiring(
entry.access_token,
auth_mod._xai_proactive_refresh_skew_seconds(entry.access_token),
auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
)
if self.provider == "nous":
# Nous refresh can require network access and should happen when
@@ -1524,11 +1321,6 @@ class CredentialPool:
entries_to_prune: List[str] = []
available: List[PooledCredential] = []
for entry in self._entries:
# Borrowed credentials persist as metadata-only references and are
# hydrated from their live source on load. A stale duplicate row
# can remain unhydrated; never lease or select it as an empty key.
if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key:
continue
# For anthropic claude_code entries, sync from the credentials file
# before any status/refresh checks. This picks up tokens refreshed
# by other processes (Claude Code CLI, other Hermes profiles).
@@ -1566,7 +1358,7 @@ class CredentialPool:
# tokens that another process (or a fresh `hermes model` ->
# xAI Grok OAuth login) has since rotated in auth.json.
if (self.provider == "xai-oauth"
and entry.source == "device_code"
and entry.source == "loopback_pkce"
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
if synced is not entry:
@@ -1629,35 +1421,16 @@ class CredentialPool:
pruned_ids = set(entries_to_prune)
self._entries = [e for e in self._entries if e.id not in pruned_ids]
if cleared_any:
self._persist(removed_ids=entries_to_prune)
self._persist()
return available
def _log_no_available_entries(self) -> None:
"""Emit the empty-pool INFO line at most once per throttle window.
Called on every selection while the pool is empty/exhausted. Without
throttling this storms the Windows cross-process log lock and stalls the
event loop (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
"""
now = time.monotonic()
last = self._last_no_entries_log_at
if last is not None and (now - last) < NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS:
return
self._last_no_entries_log_at = now
logger.info("credential pool: no available entries (all exhausted or empty)")
def _select_unlocked(self, *, refresh: bool = True) -> Optional[PooledCredential]:
available = self._available_entries(clear_expired=True, refresh=refresh)
def _select_unlocked(self) -> Optional[PooledCredential]:
available = self._available_entries(clear_expired=True, refresh=True)
if not available:
self._current_id = None
self._log_no_available_entries()
logger.info("credential pool: no available entries (all exhausted or empty)")
return None
# A successful selection means the pool recovered; re-arm the throttle
# so a later re-exhaustion logs immediately rather than being silenced
# by a window opened during the previous empty stretch.
self._last_no_entries_log_at = None
if self._strategy == STRATEGY_RANDOM:
entry = random.choice(available)
self._current_id = entry.id
@@ -1781,35 +1554,6 @@ class CredentialPool:
with self._lock:
return self._try_refresh_current_unlocked()
def try_refresh_matching(
self, api_key_hint: Optional[str] = None
) -> Optional[PooledCredential]:
"""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
issuing credential. With no hint, select an entry without first doing
the normal proactive refresh; the forced refresh below must consume a
rotating refresh token exactly once.
"""
with self._lock:
entry = 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() 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()
if entry is None:
@@ -1851,11 +1595,7 @@ class CredentialPool:
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],
)
self._persist()
if self._current_id == removed.id:
self._current_id = None
return removed
@@ -1893,15 +1633,11 @@ class CredentialPool:
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
matching_indices = []
existing_idx = None
for idx, entry in enumerate(entries):
if entry.source == source:
matching_indices.append(idx)
existing_idx = matching_indices[0] if matching_indices else None
duplicate_indices = set(matching_indices[1:])
if duplicate_indices:
entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices]
existing_idx = idx
break
if existing_idx is None:
payload.setdefault("id", uuid.uuid4().hex[:6])
@@ -1933,8 +1669,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
# Runtime-only borrowed secret updates should refresh the in-memory
# entry without forcing auth.json churn when the disk-safe payload is
# unchanged (for example env keys with the same fingerprint).
return bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
return bool(duplicate_indices)
return existing.to_dict() != updated.to_dict()
return False
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
@@ -2131,16 +1867,11 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
token, source = resolve_copilot_token()
if token:
api_token, enterprise_base_url = get_copilot_api_token(token)
api_token = get_copilot_api_token(token)
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
if not _is_suppressed(provider, source_name):
active_sources.add(source_name)
pconfig = PROVIDER_REGISTRY.get(provider)
# Use enterprise base URL from token exchange if available,
# otherwise fall back to the provider's default.
effective_base_url = enterprise_base_url or (
pconfig.inference_base_url if pconfig else ""
)
changed |= _upsert_entry(
entries,
provider,
@@ -2149,7 +1880,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
"source": source_name,
"auth_type": AUTH_TYPE_API_KEY,
"access_token": api_token,
"base_url": effective_base_url,
"base_url": pconfig.inference_base_url if pconfig else "",
"label": source,
},
)
@@ -2268,30 +1999,28 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
# (``providers["xai-oauth"]``). Surface them in the pool too so
# ``hermes auth list`` reflects the logged-in state and so the pool
# is the single source of truth for refresh during runtime resolution.
if _is_suppressed(provider, "loopback_pkce"):
return changed, active_sources
state = _load_provider_state(auth_store, "xai-oauth")
tokens = state.get("tokens") if isinstance(state, dict) else None
if isinstance(tokens, dict) and tokens.get("access_token"):
# Device code is the only supported xAI OAuth flow; the singleton is
# always surfaced as ``device_code`` (consistent with nous/codex).
source = "device_code"
if _is_suppressed(provider, source):
return changed, active_sources
active_sources.add(source)
active_sources.add("loopback_pkce")
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
base_url = DEFAULT_XAI_OAUTH_BASE_URL
changed |= _upsert_entry(
entries,
provider,
source,
"loopback_pkce",
{
"source": source,
"source": "loopback_pkce",
"auth_type": AUTH_TYPE_OAUTH,
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token"),
"base_url": base_url,
"last_refresh": state.get("last_refresh"),
"label": label_from_token(tokens.get("access_token", ""), source),
"label": label_from_token(tokens.get("access_token", ""), "loopback_pkce"),
},
)
@@ -2308,21 +2037,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# changes to the .env file.
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
raw = env_file.get(key, "").strip()
scoped_value = (_get_secret(key, "") or "").strip()
# If .env contains an unresolved op:// reference, prefer the
# already-resolved value supplied by the active secret scope (or by
# os.environ in legacy single-profile mode), set by
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
# "op://Vault/Item/field" string would otherwise win and every
# provider auth attempt would receive a URL instead of a key. This
# happens during a partial migration, or when the user wrote op://
# references straight into .env rather than the secrets.onepassword
# config block. For every non-op:// value the original
# .env-takes-precedence behaviour is preserved unchanged.
if raw.startswith("op://") and scoped_value:
return scoped_value
return raw or scoped_value
val = env_file.get(key) or _get_secret(key, "") or ""
return val.strip()
# Honour user suppression — `hermes auth remove <provider> <N>` for an
# env-seeded credential marks the env:<VAR> source as suppressed so it
@@ -2409,6 +2125,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
@@ -2423,6 +2140,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_var=env_var,
token=token,
base_url=base_url,
auth_type=auth_type,
),
)
return changed, active_sources
@@ -2539,48 +2257,22 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b
def load_pool(provider: str) -> CredentialPool:
provider = (provider or "").strip().lower()
raw_entries = read_credential_pool(provider)
disk_ids = {
entry.get("id")
for entry in raw_entries
if isinstance(entry, dict) and entry.get("id")
}
raw_needs_sanitization = any(
isinstance(payload, dict)
and sanitize_borrowed_credential_payload(payload, provider) != payload
for payload in raw_entries
)
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
raw_needs_auth_normalization = any(
isinstance(payload, dict)
and _normalize_pool_auth_type(
provider,
payload.get("access_token"),
payload.get("auth_type", AUTH_TYPE_API_KEY),
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
for payload in raw_entries
)
if raw_needs_auth_normalization:
# A profile may be reading this provider from the global-root fallback.
# Keep that fallback read-only: only the store that owns these rows may
# rewrite them. Loading the default/root profile will heal global rows.
active_pool = _load_auth_store().get("credential_pool")
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
raw_needs_auth_normalization = bool(active_entries)
if provider.startswith(CUSTOM_POOL_PREFIX):
# Custom endpoint pool — seed from custom_providers config and model config
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed
changed = raw_needs_sanitization or custom_changed
changed |= _prune_stale_seeded_entries(entries, custom_sources)
else:
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
env_changed, env_sources = _seed_from_env(provider, entries)
changed = (
raw_needs_sanitization
or raw_needs_auth_normalization
or singleton_changed
or env_changed
)
changed = raw_needs_sanitization or singleton_changed or env_changed
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
# process missing a provider env var must not delete the persisted
# pool entry for every other process (#9331). File-backed singletons
@@ -2593,10 +2285,8 @@ def load_pool(provider: str) -> CredentialPool:
changed |= _normalize_pool_priorities(provider, entries)
if changed:
new_ids = {entry.id for entry in entries}
write_credential_pool(
provider,
[entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)],
removed_ids=disk_ids - new_ids,
)
return CredentialPool(provider, entries)
+8 -3
View File
@@ -265,7 +265,7 @@ def _remove_minimax_oauth(provider: str, removed) -> RemovalResult:
return result
def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
"""xAI OAuth tokens live in auth.json providers.xai-oauth — clear them.
Without this step, ``hermes auth remove xai-oauth <N>`` silently undoes
@@ -275,6 +275,11 @@ def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
entry from the still-present singleton credentials reappear with no
user feedback. Clearing the singleton in step with the suppression set
by the central dispatcher makes the removal stick.
Belt-and-braces against the manual entry path: ``hermes auth add
xai-oauth`` produces a ``manual:xai_pkce`` entry whose removal step
falls through to "unregistered → nothing to clean up" (correct
manual entries are pool-only).
"""
result = RemovalResult()
if _clear_auth_store_provider(provider):
@@ -418,8 +423,8 @@ def _register_all_sources() -> None:
description="auth.json providers.openai-codex + ~/.codex/auth.json",
))
register(RemovalStep(
provider="xai-oauth", source_id="device_code",
remove_fn=_remove_xai_oauth_device_code,
provider="xai-oauth", source_id="loopback_pkce",
remove_fn=_remove_xai_oauth_loopback_pkce,
description="auth.json providers.xai-oauth",
))
register(RemovalStep(
+1 -1
View File
@@ -355,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /topup to top up",
text="✕ Credit access paused · run /credits to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",
+3 -92
View File
@@ -45,26 +45,12 @@ def _strip_aux_credential(value: Any) -> Optional[str]:
class _ReviewRuntimeBinding(NamedTuple):
"""Provider/model for the curator review fork plus per-slot overrides."""
"""Provider/model for the curator review fork plus optional per-slot overrides."""
provider: str
model: str
explicit_api_key: Optional[str]
explicit_base_url: Optional[str]
request_overrides: Dict[str, Any]
def _merge_request_overrides(
runtime_overrides: Any,
slot_extra_body: Any,
) -> Dict[str, Any]:
"""Merge resolver metadata with task-local request body fields."""
merged = dict(runtime_overrides or {})
if isinstance(slot_extra_body, dict) and slot_extra_body:
extra_body = dict(merged.get("extra_body") or {})
extra_body.update(slot_extra_body)
merged["extra_body"] = extra_body
return merged
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
@@ -287,21 +273,6 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
# Automatic state transitions (pure function, no LLM)
# ---------------------------------------------------------------------------
def _cron_referenced_skills() -> Set[str]:
"""Skill names referenced by any cron job (incl. paused/disabled).
Best-effort: a cron-module import error or corrupt jobs store must never
break the curator, so any failure yields an empty set (no protection,
but no crash).
"""
try:
from cron.jobs import referenced_skill_names as _refs
return _refs()
except Exception as e:
logger.debug("Curator could not read cron skill references: %s", e, exc_info=True)
return set()
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
"""Walk every curator-managed skill and move active/stale/archived based on
the latest real activity timestamp. Pinned skills are never touched.
@@ -321,8 +292,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
stale_cutoff = now - timedelta(days=get_stale_after_days())
archive_cutoff = now - timedelta(days=get_archive_after_days())
cron_referenced = _cron_referenced_skills()
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0}
for row in _u.agent_created_report():
@@ -331,15 +300,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
if row.get("pinned"):
continue
# A skill referenced by any cron job (incl. paused/disabled) is in
# use by definition — resuming or the next fire must find it. The
# scheduler only bumps usage when a job actually fires, so jobs that
# fire less often than archive_after_days, paused jobs, and far-future
# one-shots would otherwise have their skills aged out from under
# them. Treat referenced skills like pinned: never auto-transition.
if name in cron_referenced:
continue
# First sight of a curation-eligible skill with no persisted record
# (e.g. a newly-eligible built-in): anchor its clock to now and defer.
if not row.get("_persisted", True):
@@ -356,18 +316,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
current = row.get("state", _u.STATE_ACTIVE)
# Never-used skills (use_count == 0) get a grace floor: don't archive
# one until it is at least stale_after_days old. A use=0 skill is
# absence of evidence, not evidence of staleness — a skill created
# recently may simply not have had its trigger come up yet.
never_used = int(row.get("use_count", 0) or 0) == 0
if never_used and anchor > stale_cutoff:
# Younger than the stale window — leave it alone entirely.
if current == _u.STATE_STALE:
_u.set_state(name, _u.STATE_ACTIVE)
counts["reactivated"] += 1
continue
if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED:
ok, _msg = _u.archive_skill(name)
if ok:
@@ -442,19 +390,10 @@ CURATOR_REVIEW_PROMPT = (
"back load-bearing UX (slash-command entry points referenced in docs and "
"tips) and are filtered out of the candidate list below — never resurrect "
"one as an archive or absorb target.\n"
"3c. DO NOT archive or prune any skill marked `cron=yes` in the candidate "
"list. A cron job depends on it and will fail to load it on its next "
"run. You MAY still consolidate it into an umbrella — but only because "
"the curator rewrites cron job skill references to follow consolidations; "
"never simply prune it.\n"
"4. DO NOT use usage counters as a reason to skip consolidation. The "
"counters are new and often mostly zero. Judge overlap on CONTENT, "
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
"absence of evidence either way. Corollary: 'use=0' is ALSO not a "
"reason to PRUNE a skill. Never archive a never-used skill (use=0) "
"unless it is at least 30 days old (check last_activity / created date) "
"AND its content is genuinely obsolete or fully absorbed elsewhere — a "
"recently-created skill simply may not have had its trigger come up yet.\n"
"absence of evidence either way.\n"
"5. DO NOT reject consolidation on the grounds that 'each skill has "
"a distinct trigger'. Pairwise distinctness is the wrong bar. The "
"right bar is: 'would a human maintainer write this as N separate "
@@ -1474,14 +1413,12 @@ def _render_candidate_list() -> str:
rows = skill_usage.agent_created_report()
if not rows:
return "No agent-created skills to review."
cron_referenced = _cron_referenced_skills()
lines = [f"Agent-created skills ({len(rows)}):\n"]
for r in rows:
lines.append(
f"- {r['name']} "
f"state={r['state']} "
f"pinned={'yes' if r.get('pinned') else 'no'} "
f"cron={'yes' if r['name'] in cron_referenced else 'no'} "
f"activity={r.get('activity_count', 0)} "
f"use={r.get('use_count', 0)} "
f"view={r.get('view_count', 0)} "
@@ -1778,7 +1715,6 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
_task_model,
_strip_aux_credential(_cur_task.get("api_key")),
_strip_aux_credential(_cur_task.get("base_url")),
_merge_request_overrides({}, _cur_task.get("extra_body")),
)
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
@@ -1796,11 +1732,10 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
str(_legacy_model),
_strip_aux_credential(_legacy.get("api_key")),
_strip_aux_credential(_legacy.get("base_url")),
_merge_request_overrides({}, _legacy.get("extra_body")),
)
# 3. Fall through to the main chat model
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {})
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None)
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
@@ -1866,11 +1801,6 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
_base_url = None
_api_mode = None
_resolved_provider = None
_credential_pool = None
_request_overrides: Dict[str, Any] = {}
_max_tokens = None
_acp_command = None
_acp_args = None
_model_name = ""
try:
from hermes_cli.config import load_config
@@ -1888,16 +1818,6 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
_base_url = _rp.get("base_url")
_api_mode = _rp.get("api_mode")
_resolved_provider = _rp.get("provider") or _provider
_credential_pool = _rp.get("credential_pool")
_request_overrides = _merge_request_overrides(
_rp.get("request_overrides"),
_binding.request_overrides.get("extra_body"),
)
_max_tokens = _rp.get("max_output_tokens")
_acp_command = _rp.get("command")
_acp_args = list(_rp.get("args") or [])
if isinstance(_rp.get("model"), str) and _rp["model"].strip():
_model_name = _rp["model"].strip()
except Exception as e:
logger.debug("Curator provider resolution failed: %s", e, exc_info=True)
@@ -1906,21 +1826,12 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
review_agent = None
try:
_agent_kwargs: Dict[str, Any] = {}
if isinstance(_max_tokens, int):
_agent_kwargs["max_tokens"] = _max_tokens
if isinstance(_acp_command, str) and _acp_command:
_agent_kwargs["acp_command"] = _acp_command
_agent_kwargs["acp_args"] = _acp_args or []
review_agent = AIAgent(
model=_model_name,
provider=_resolved_provider,
api_key=_api_key,
base_url=_base_url,
api_mode=_api_mode,
credential_pool=_credential_pool,
request_overrides=_request_overrides,
**_agent_kwargs,
# Umbrella-building over a large skill collection is worth a
# high iteration ceiling — the pass typically takes 50-100
# API calls against hundreds of candidate skills. The
+2 -7
View File
@@ -98,12 +98,7 @@ def _backup_cron_jobs_into(dest: Path) -> Dict[str, Any]:
info["reason"] = "no cron/jobs.json present"
return info
try:
# utf-8-sig: same dialect as cron/jobs.load_jobs — a UTF-8 BOM left
# by Windows editors otherwise survives decoding as U+FEFF, breaks
# json.loads below, and misreports jobs_count as 0 with a spurious
# parse warning. The BOM-less text is also what gets written to the
# backup, so a later rollback restores a loadable file.
raw = src.read_text(encoding="utf-8-sig")
raw = src.read_text(encoding="utf-8")
except OSError as e:
logger.debug("Failed to read cron/jobs.json for backup: %s", e)
info["reason"] = f"read error: {e}"
@@ -561,7 +556,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
if target is None:
return (
False,
"no matching backup found"
f"no matching backup found"
+ (f" for id '{backup_id}'" if backup_id else "")
+ " (use `hermes curator rollback --list` to see available snapshots)",
None,
+7 -207
View File
@@ -27,14 +27,6 @@ logger = logging.getLogger(__name__)
_ANSI_RESET = "\033[0m"
def _display_url(value: Any) -> str:
"""Extract a display-only URL without assuming model argument types."""
if isinstance(value, dict):
value = value.get("url") or value.get("href")
return value.strip() if isinstance(value, str) else ""
# Diff colors — resolved lazily from the skin engine so they adapt
# to light/dark themes. Falls back to sensible defaults on import
# failure. We cache after first resolution for performance.
@@ -462,14 +454,13 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
sid = args.get("session_id", "")
data = args.get("data", "")
timeout_val = args.get("timeout")
parts = [str(action) if action else ""]
parts = [action]
if sid:
parts.append(str(sid)[:16])
parts.append(sid[:16])
if data:
parts.append(f'"{_oneline(str(data)[:20])}"')
parts.append(f'"{_oneline(data[:20])}"')
if timeout_val and action == "wait":
parts.append(f"{timeout_val}s")
parts = [p for p in parts if p]
return " ".join(parts) if parts else None
if tool_name == "todo":
@@ -524,16 +515,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
msg = msg[:17] + "..."
return f"to {target}: \"{msg}\""
if tool_name == "skill_view":
name = _oneline(str(args.get("name") or ""))
file_path = args.get("file_path")
if file_path:
file_path = _oneline(str(file_path))
preview = f"{name}{file_path}" if name else file_path
else:
preview = name
return _truncate_preview(preview, max_len) if preview else None
key = primary_args.get(tool_name)
if not key:
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
@@ -556,168 +537,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
return preview
# =========================================================================
# Friendly tool labels (human-phrased verbs for built-in tools)
#
# Turns "web_search <query>" into "Searching the web for <query>" — the
# ChatGPT-style "Searching…/Reading…" surface. Curated and built-in only:
# we know each core tool's semantics, so the verb is fixed, not computed.
# Custom/plugin/MCP tools have no entry and fall back to the raw preview.
# =========================================================================
# Each entry maps a built-in tool name to its present-participle verb phrase.
# A trailing space-then-preview is appended by build_tool_label() when the
# tool's argument preview is available (e.g. "Reading docs/api.md").
_TOOL_VERBS: dict[str, str] = {
"web_search": "Searching the web",
"web_extract": "Reading",
"browser_navigate": "Browsing",
"browser_click": "Clicking",
"browser_type": "Typing",
"read_file": "Reading",
"write_file": "Writing",
"patch": "Editing",
"search_files": "Searching files",
"terminal": "Running",
"execute_code": "Running code",
"image_generate": "Generating image",
"video_generate": "Generating video",
"text_to_speech": "Generating speech",
"vision_analyze": "Looking at the image",
"session_search": "Searching past sessions",
"skill_view": "Reading skill",
"skills_list": "Listing skills",
"skill_manage": "Updating skill",
"delegate_task": "Delegating",
"cronjob": "Scheduling",
"clarify": "Asking",
"memory": "Updating memory",
"todo": "Updating tasks",
}
# Verbs that read better without the raw argument preview appended.
_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({
"skills_list",
"session_search",
})
# Verbs that take a "for" connector before the preview (search-style phrasing):
# "Searching the web for <query>" reads better than "Searching the web <query>".
_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({
"web_search",
"search_files",
})
_friendly_tool_labels: bool = True
def set_friendly_tool_labels(enabled: bool) -> None:
"""Toggle friendly human-phrased tool labels (display.friendly_tool_labels)."""
global _friendly_tool_labels
_friendly_tool_labels = bool(enabled)
def get_friendly_tool_labels() -> bool:
"""Return whether friendly tool labels are enabled."""
return _friendly_tool_labels
def get_tool_verb(tool_name: str) -> str | None:
"""Return the friendly verb for a built-in tool, or None.
Returns None when friendly labels are disabled or the tool has no curated
verb (custom/plugin/MCP tools). Callers that already hold a computed
argument preview can compose ``f"{verb} {preview}"`` themselves; use
:func:`tool_verb_connector` to pick the right joiner.
"""
if not _friendly_tool_labels:
return None
return _TOOL_VERBS.get(tool_name)
def tool_verb_connector(tool_name: str) -> str:
"""Return the connector between a verb and its preview (" for " or " ")."""
return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " "
def verb_drops_preview(tool_name: str) -> bool:
"""Whether the verb should render alone, without the argument preview."""
return tool_name in _TOOL_VERBS_NO_PREVIEW
def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> str | None:
"""Build a short present-tense status phrase for platform status surfaces.
Used by text-rendering "typing" indicators (Slack's
``assistant.threads.setStatus`` line) to show what the agent is doing
right now: ``is running scripts/run_tests.sh`` instead of a static
``is thinking...``. The phrase is phrased to follow the bot's display
name ("Hermes is running …"), so it starts lowercase with "is".
Pass ``args=None`` for a verb-only phrase (``is running``) used when
``display.live_status`` is ``verb`` to keep argument previews out of
shared channels.
Returns None for the ``_thinking`` pseudo-tool and when friendly labels
are disabled (callers fall back to their static default). ``max_len``
caps the total phrase length; Slack truncates its status line around 50
characters, so the default stays just under that.
"""
if not tool_name or tool_name == "_thinking":
return None
if not _friendly_tool_labels:
return None
verb = _TOOL_VERBS.get(tool_name)
if verb:
head = f"is {verb[0].lower()}{verb[1:]}"
else:
# Custom / plugin / MCP tools: generic but still informative.
head = f"is using {tool_name}"
phrase = head
if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW:
preview = build_tool_preview(tool_name, args, max_len=None)
if preview:
# Previews can contain newlines (terminal commands); keep the
# status to the first line.
preview = preview.splitlines()[0].strip()
phrase = f"{head}{tool_verb_connector(tool_name)}{preview}"
if len(phrase) > max_len - 1:
phrase = phrase[: max_len - 2].rstrip() + ""
else:
phrase = phrase + ""
return phrase
def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
"""Build a human-phrased status label for a tool call.
For built-in tools with a known verb (``web_search`` -> "Searching the
web for ..."), returns the verb optionally followed by the argument
preview. For everything else (custom/plugin/MCP tools, or when friendly
labels are disabled) returns the raw preview, so callers can use this as a
drop-in replacement for :func:`build_tool_preview`.
"""
if not _friendly_tool_labels:
return build_tool_preview(tool_name, args, max_len=max_len)
verb = _TOOL_VERBS.get(tool_name)
if not verb:
return build_tool_preview(tool_name, args, max_len=max_len)
if tool_name in _TOOL_VERBS_NO_PREVIEW:
return verb
preview = build_tool_preview(tool_name, args, max_len=max_len)
if not preview:
return verb
if tool_name in _TOOL_VERBS_FOR_CONNECTOR:
return f"{verb} for {preview}"
return f"{verb} {preview}"
# =========================================================================
# Inline diff previews for write actions
# =========================================================================
@@ -1314,7 +1133,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
def _get_cute_tool_message(
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
"""Generate a formatted tool completion line for CLI quiet mode.
@@ -1356,11 +1175,9 @@ def _get_cute_tool_message(
if tool_name == "web_extract":
urls = args.get("urls", [])
if urls:
url = _display_url(urls[0] if isinstance(urls, list) else urls)
if not url:
return _wrap(f"┊ 📄 fetch pages {dur}")
url = urls[0] if isinstance(urls, list) else str(urls)
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else ""
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
@@ -1451,11 +1268,7 @@ def _get_cute_tool_message(
if tool_name == "skills_list":
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
if tool_name == "skill_view":
label = args.get("name", "")
file_path = args.get("file_path")
if file_path:
label = f"{label}{file_path}" if label else str(file_path)
return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}")
return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}")
if tool_name == "image_generate":
return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}")
if tool_name == "text_to_speech":
@@ -1490,19 +1303,6 @@ def _get_cute_tool_message(
return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}")
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
"""Render a completion label without letting cosmetic failures escape."""
try:
return _get_cute_tool_message(tool_name, args, duration, result=result)
except Exception as exc: # noqa: BLE001 — display must never abort a turn
logger.debug("Tool completion label failed for %s: %s", tool_name, exc)
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool"
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done"
return f"┊ ⚡ {safe_name:9} completed {safe_duration}"
# =========================================================================
# Honcho session line (one-liner with clickable OSC 8 hyperlink)
# =========================================================================
+29 -342
View File
@@ -31,9 +31,6 @@ class FailoverReason(enum.Enum):
# Billing / quota
billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately
rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate
# Upstream model rate-limited (aggregator 429) — fallback to a different
# model, NOT credential rotation. The user's key is healthy.
upstream_rate_limit = "upstream_rate_limit"
# Server-side
overloaded = "overloaded" # 503/529 — provider overloaded, backoff
@@ -41,11 +38,6 @@ class FailoverReason(enum.Enum):
# Transport
timeout = "timeout" # Connection/read timeout — rebuild client + retry
# TLS certificate verification failure — deterministic for the host
# (TLS-inspecting proxy, missing/expired CA bundle, self-signed cert).
# Retrying reproduces the identical handshake failure, so fail fast
# with actionable guidance instead of burning retries.
ssl_cert_verification = "ssl_cert_verification"
# Context / payload
context_overflow = "context_overflow" # Context too large — compress, not failover
@@ -115,7 +107,6 @@ _BILLING_PATTERNS = [
"exceeded your current quota",
"account is deactivated",
"plan does not include",
"out of extra usage", # Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400)
"out of funds",
"run out of funds",
"balance_depleted",
@@ -123,25 +114,6 @@ _BILLING_PATTERNS = [
"not available on the free tier",
]
# xAI's explicit Grok credit-exhaustion code. Keep the HTTP 403 special case
# provider-scoped: other providers' generic billing codes historically remain
# auth failures when they arrive as 403.
_XAI_SPENDING_LIMIT_ERROR_CODE = "personal-team-blocked:spending-limit"
# Structured provider codes that mean the account cannot serve paid traffic
# until credits/subscription capacity is restored. xAI returns its explicit
# Grok spending-limit signal as HTTP 403 rather than 402.
_BILLING_ERROR_CODES = frozenset({
"insufficient_quota",
"billing_not_active",
"payment_required",
"insufficient_credits",
"no_usable_credits",
"balance_depleted",
"model_not_supported_on_free_tier",
_XAI_SPENDING_LIMIT_ERROR_CODE,
})
# Patterns that indicate rate limiting (transient, will resolve)
_RATE_LIMIT_PATTERNS = [
"rate limit",
@@ -161,31 +133,6 @@ _RATE_LIMIT_PATTERNS = [
"servicequotaexceededexception",
]
# Patterns that indicate provider-side overload, NOT a per-credential rate
# limit or billing problem. The credential is valid — the server is just
# busy — so the correct recovery is "back off and retry the same key", never
# "rotate the credential" (rotating exhausts the pool while the endpoint is
# still busy; a single-key user has nothing to rotate to). Some providers
# (notably Z.AI / Zhipu) reuse HTTP 429 for server-wide overload, so the 429
# status path matches the body against this list before falling through to
# the rate_limit default. Phrases are kept narrow and overload-flavoured so a
# normal rate-limit message ("you have been rate-limited") doesn't hit this
# bucket. (#14038, #15297)
_OVERLOADED_PATTERNS = [
"overloaded",
"temporarily overloaded",
"service is temporarily overloaded",
"service may be temporarily overloaded",
"server is overloaded",
"server overloaded",
"service overloaded",
"service is overloaded",
"upstream overloaded",
"currently overloaded",
"at capacity",
"over capacity",
]
# Usage-limit patterns that need disambiguation (could be billing OR rate_limit)
_USAGE_LIMIT_PATTERNS = [
"usage limit",
@@ -269,11 +216,6 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"context window",
"prompt is too long",
"prompt exceeds max length",
# NOTE: bare "max_tokens" is load-bearing — the output-cap-retry path keys
# off it (e.g. "max_tokens: 65536 > context_window: 200000 ..."). Do NOT
# remove it. Provider empty-response advisories also contain "very low
# max_tokens", but those are intercepted by _EMPTY_PROVIDER_RESPONSE_PATTERNS
# BEFORE this list is consulted, so they never mis-route into compression.
"max_tokens",
"maximum number of tokens",
# vLLM / local inference server patterns
@@ -291,8 +233,6 @@ _CONTEXT_OVERFLOW_PATTERNS = [
# Chinese error messages (some providers return these)
"超过最大长度",
"上下文长度",
# Z.AI / Zhipu GLM pattern (English form; error code 1210)
"tokens in request more than max tokens allowed",
# AWS Bedrock Converse API error patterns
"input is too long",
"max input token",
@@ -310,15 +250,6 @@ _MODEL_NOT_FOUND_PATTERNS = [
"no such model",
"unknown model",
"unsupported model",
# OpenRouter returns 404 with this message when none of the candidate
# endpoints for the selected model support tool/function calling.
# Classifying this as model_not_found triggers fallback to a different
# model or provider that does support tools. Without this entry the
# pattern falls through to ``unknown`` with ``retryable=True``, the
# retry loop burns all attempts on the same deterministic rejection,
# and the error surfaces as a confusing "model not found" message
# instead of automatically failing over. See PR #58446.
"no endpoints found that support tool use",
]
# Request-validation patterns — the request is malformed and will fail
@@ -399,14 +330,6 @@ _CONTENT_POLICY_BLOCKED_PATTERNS = [
# echo back; the underscore form is provider-specific enough.
"content_filter",
"responsibleaipolicyviolation",
# MiniMax output-layer safety filter. The error string is surfaced
# verbatim by MiniMax SDK / OpenAI-compatible endpoints, usually in the
# form "output new_sensitive (1027)" when the model's *output* (often a
# large tool-call argument block) trips the upstream safety filter and
# the SSE stream is truncated mid-flight. ``new_sensitive`` is the
# filter name and is narrow enough that billing / format / auth error
# strings will not collide. See #32421.
"new_sensitive",
]
# Auth patterns (non-status-code signals)
@@ -431,19 +354,6 @@ _THINKING_SIG_PATTERNS = [
# the exception type is generic (e.g. RuntimeError from a local shim that
# wraps a subprocess timeout). Checked before the type-based transport
# heuristics so custom-provider "timed out" errors don't fall through to
# Provider empty-response advisories (OpenRouter / nano-gpt / similar).
# Checked before context-overflow matching because the advisory text often
# mentions "max_tokens" as a possible cause, which historically sat in
# _CONTEXT_OVERFLOW_PATTERNS and sent healthy sessions into a compression
# death spiral ending in "Cannot compress further".
_EMPTY_PROVIDER_RESPONSE_PATTERNS = [
"returned an empty response",
"empty response despite retries",
"provider returned an empty response",
"model returning empty responses",
"empty response stream",
]
# the unknown bucket and get misreported as empty responses.
_TIMEOUT_MESSAGE_PATTERNS = [
"timed out",
@@ -491,29 +401,6 @@ _SERVER_DISCONNECT_PATTERNS = [
"incomplete chunked read",
]
# SSL certificate verification failures — deterministic, NOT transient.
#
# A failed certificate chain (TLS-inspecting corporate proxy, missing
# custom CA in the trust store, expired certificate, self-signed cert)
# fails identically on every retry. Burning the retry budget before
# surfacing the error hides the actionable fix from the user for minutes.
# Inspired by Claude Code v2.1.199 (July 2026), which made SSL certificate
# errors fail immediately with a fix hint instead of retrying.
#
# Must be checked BEFORE _SSL_TRANSIENT_PATTERNS — "certificate verify
# failed" messages usually also contain "[SSL:" which would otherwise
# match the transient list and retry forever.
_SSL_CERT_VERIFY_PATTERNS = [
"certificate verify failed", # Python ssl module canonical text
"certificate_verify_failed", # OpenSSL error token
"unable to get local issuer certificate",
"self-signed certificate",
"self signed certificate",
"certificate has expired",
"hostname mismatch, certificate is not valid",
"unable to verify the first certificate", # Node/undici phrasing (MCP bridges)
]
# SSL/TLS transient failure patterns — intentionally distinct from
# _SERVER_DISCONNECT_PATTERNS above.
#
@@ -793,14 +680,6 @@ def classify_api_error(
if classified is not None:
return classified
# Local MoA config drift is deterministic: a persisted session can retain
# a preset name that was later renamed/deleted. Retrying the same lookup
# cannot recover and makes a clear config error look like an API outage.
from agent.errors import MoAPresetNotFoundError
if isinstance(error, MoAPresetNotFoundError):
return _result(FailoverReason.model_not_found, retryable=False)
# ── 3. Error code classification ────────────────────────────────
if error_code:
@@ -819,22 +698,7 @@ def classify_api_error(
if classified is not None:
return classified
# ── 5. SSL certificate verification failures → fail fast ────────
# A broken certificate chain (TLS-inspecting proxy, missing custom CA,
# expired/self-signed cert) is deterministic for the host — every retry
# reproduces the identical handshake failure. Fail immediately with
# actionable guidance instead of burning the retry budget first.
# Checked BEFORE the transient-SSL patterns: cert-verify messages also
# contain "[ssl:" which would otherwise match the transient list.
# Inspired by Claude Code v2.1.199 (July 2026).
if any(p in error_msg for p in _SSL_CERT_VERIFY_PATTERNS):
return _result(
FailoverReason.ssl_cert_verification,
retryable=False,
should_fallback=False,
)
# ── 5b. SSL/TLS transient errors → retry as timeout (not compression) ──
# ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
# SSL alerts mid-stream are transport hiccups, not server-side context
# overflow signals. Classify before the disconnect check so a large
# session doesn't incorrectly trigger context compression when the real
@@ -887,34 +751,12 @@ def classify_api_error(
)
return _result(FailoverReason.timeout, retryable=True)
# ── 7b. Stale-call circuit breaker → failover immediately ──────
# _check_stale_giveup() in agent/chat_completion_helpers.py raises a
# RuntimeError when the provider has been unresponsive for N
# consecutive stale attempts (default 5). The error is NOT a transport
# timeout — the circuit breaker fires *before* any network call to avoid
# an indefinite stall. Without this classification the RuntimeError
# falls through to FailoverReason.unknown (retryable=True), which burns
# all max_retries against the same dead provider (each retry hitting the
# circuit breaker instantly with zero network overhead) before fallback
# is attempted. Classify as non-retryable + should_fallback so the
# retry loop activates the next fallback provider on the first hit.
if (
error_type == "RuntimeError"
and "consecutive stale attempts" in error_msg
and "aborting this call" in error_msg
):
return _result(
FailoverReason.timeout,
retryable=False,
should_fallback=True,
)
# ── 8. Transport / timeout heuristics ───────────────────────────
# ── 7. Transport / timeout heuristics ───────────────────────────
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
return _result(FailoverReason.timeout, retryable=True)
# ── 9. Fallback: unknown ────────────────────────────────────────
# ── 8. Fallback: unknown ────────────────────────────────────────
return _result(FailoverReason.unknown, retryable=True)
@@ -953,11 +795,7 @@ def _classify_by_status(
# OpenRouter 403 "key limit exceeded" is actually billing. Other
# providers also use 403 for account-plan or credit exhaustion.
if (
(
provider == "xai-oauth"
and error_code.lower() == _XAI_SPENDING_LIMIT_ERROR_CODE
)
or "key limit exceeded" in error_msg
"key limit exceeded" in error_msg
or "spending limit" in error_msg
or any(p in error_msg for p in _BILLING_PATTERNS)
):
@@ -1025,35 +863,7 @@ def _classify_by_status(
)
if status_code == 429:
# Already checked long_context_tier above. Some providers (notably
# Z.AI / Zhipu) reuse HTTP 429 for server-wide overload — same status
# code as a true per-credential rate limit, but the credential is
# valid and the correct recovery is "back off and retry the same key",
# NOT "rotate the credential" (which exhausts the pool while the
# endpoint is still busy, and does nothing for a single-key user).
# Disambiguate on the error body so an overload 429 takes the
# transient-overload path instead of burning the pool. (#14038)
if any(p in error_msg for p in _OVERLOADED_PATTERNS):
return result_fn(
FailoverReason.overloaded,
retryable=True,
)
# Distinguish an OpenRouter-aggregator upstream 429 (an upstream model
# like DeepSeek rate-limited OpenRouter's aggregate traffic) from an
# account-level 429 (the user's key is actually throttled). OpenRouter
# wraps upstream errors with the outer message "Provider returned
# error" — the user's key is healthy, so marking it exhausted / rotating
# is wrong and burns the key for ~24min. Fall back to a different model.
if _is_openrouter_upstream_error(body, provider):
upstream_provider = _extract_upstream_provider_name(body)
ctx = {"upstream_provider": upstream_provider} if upstream_provider else {}
return result_fn(
FailoverReason.upstream_rate_limit,
retryable=True,
should_rotate_credential=False,
should_fallback=True,
error_context=ctx,
)
# Already checked long_context_tier above; this is a normal rate limit
return result_fn(
FailoverReason.rate_limit,
retryable=True,
@@ -1089,58 +899,11 @@ def _classify_by_status(
retryable=False,
should_fallback=True,
)
# Some local inference servers (notably llama.cpp / llama-server)
# report context overflow with an HTTP 500 instead of the standard
# 400/413. The request-validation guard above already ran, so any
# remaining explicit context-overflow signal routes into the
# compression-and-retry path (mirroring _classify_400) instead of
# blind server_error retries that exhaust and drop the turn.
# Empty-response advisories that mention "max_tokens" must not enter
# that compression path.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
retryable=True,
should_compress=True,
)
return result_fn(FailoverReason.server_error, retryable=True)
if status_code in {503, 529}:
# Same overflow-as-5xx variant (server busy / model-load OOM, or a
# Cloudflare/Tailscale hop relabeling the status). Route explicit
# overflow bodies into compression; otherwise treat as transient
# overload and retry.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
retryable=True,
should_compress=True,
)
return result_fn(FailoverReason.overloaded, retryable=True)
# 408 Request Timeout — a transient timing failure the server itself flags
# as safe to retry (RFC 9110 §15.5.9), not a malformed request. Commonly
# emitted by reverse proxies sitting in front of self-hosted backends
# (llama.cpp / Ollama / vLLM) when a long generation outruns the proxy's
# request-read window. Route to the dedicated ``timeout`` reason (rebuild
# client + retry) instead of falling through to the generic 4xx bucket
# below, which would abort the turn on a retry-safe error the same way it
# aborts a 400 Bad Request.
if status_code == 408:
return result_fn(FailoverReason.timeout, retryable=True)
# Other 4xx — non-retryable
if 400 <= status_code < 500:
return result_fn(
@@ -1234,7 +997,6 @@ def _classify_400(
"encrypted content for item" in error_msg
and "could not be verified" in error_msg
)
or "could not decrypt the provided encrypted_content" in error_msg
):
return result_fn(
FailoverReason.invalid_encrypted_content,
@@ -1247,8 +1009,8 @@ def _classify_400(
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which historically
# sat in _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
@@ -1270,17 +1032,6 @@ def _classify_400(
should_fallback=True,
)
# Empty-provider-response advisories must not enter compression. They
# often mention "max_tokens" as a possible cause and used to match the
# bare overflow pattern, then thrash compress until "Cannot compress
# further" on an otherwise healthy session (custom endpoints / nano-gpt).
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
@@ -1368,7 +1119,15 @@ def _classify_by_error_code(
should_rotate_credential=True,
)
if code_lower in _BILLING_ERROR_CODES:
if code_lower in {
"insufficient_quota",
"billing_not_active",
"payment_required",
"insufficient_credits",
"no_usable_credits",
"balance_depleted",
"model_not_supported_on_free_tier",
}:
return result_fn(
FailoverReason.billing,
retryable=False,
@@ -1455,17 +1214,6 @@ def _classify_by_message(
should_fallback=True,
)
# Overloaded / server-busy patterns — must come BEFORE the rate_limit and
# billing checks so that a message-only "overloaded" (no 503/529 status,
# e.g. some Anthropic-compatible proxies) classifies as a transient
# overload (backoff + retry) instead of falling through to `unknown` or
# incorrectly triggering credential rotation.
if any(p in error_msg for p in _OVERLOADED_PATTERNS):
return result_fn(
FailoverReason.overloaded,
retryable=True,
)
# Billing patterns
if any(p in error_msg for p in _BILLING_PATTERNS):
return result_fn(
@@ -1484,15 +1232,6 @@ def _classify_by_message(
should_fallback=True,
)
# Empty-provider-response advisories (often mention "max_tokens") must
# retry without compression — see the matching 400-path guard above.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow patterns
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
@@ -1564,25 +1303,19 @@ def _extract_status_code(error: Exception) -> Optional[int]:
def _extract_error_body(error: Exception) -> dict:
"""Extract the structured error body from an SDK exception or its cause chain."""
current = error
for _ in range(5): # Match _extract_status_code() traversal depth.
body = getattr(current, "body", None)
if isinstance(body, dict):
return body
# Some errors have .response.json()
response = getattr(current, "response", None)
if response is not None:
try:
json_body = response.json()
if isinstance(json_body, dict):
return json_body
except Exception:
pass
cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
if cause is None or cause is current:
break
current = cause
"""Extract the structured error body from an SDK exception."""
body = getattr(error, "body", None)
if isinstance(body, dict):
return body
# Some errors have .response.json()
response = getattr(error, "response", None)
if response is not None:
try:
json_body = response.json()
if isinstance(json_body, dict):
return json_body
except Exception:
pass
return {}
@@ -1650,49 +1383,3 @@ def _extract_message(error: Exception, body: dict) -> str:
return msg.strip()[:500]
# Fallback to str(error)
return str(error)[:500]
def _is_openrouter_upstream_error(body: Any, provider: str) -> bool:
"""Detect OpenRouter's aggregator-wrapped upstream provider errors.
OpenRouter returns errors from upstream model providers (DeepSeek,
Anthropic, etc.) wrapped with the outer message "Provider returned error"
and the real error nested in ``metadata.raw``. This signal means the
user's OpenRouter key is healthy — the upstream provider is the one that
failed so credential rotation is the wrong recovery.
"""
if not isinstance(body, dict):
return False
provider_lower = (provider or "").strip().lower()
err = body.get("error")
if not isinstance(err, dict):
return False
outer_msg = str(err.get("message") or "").strip().lower()
if outer_msg != "provider returned error":
return False
# Require either the explicit OpenRouter provider OR the metadata shape
# that only OpenRouter produces (metadata.raw / metadata.provider_name).
if provider_lower == "openrouter":
return True
metadata = err.get("metadata")
if isinstance(metadata, dict) and (
"raw" in metadata or "provider_name" in metadata
):
return True
return False
def _extract_upstream_provider_name(body: Any) -> Optional[str]:
"""Pull the upstream provider name out of OpenRouter's error metadata."""
if not isinstance(body, dict):
return None
err = body.get("error")
if not isinstance(err, dict):
return None
metadata = err.get("metadata")
if not isinstance(metadata, dict):
return None
name = metadata.get("provider_name")
if isinstance(name, str) and name.strip():
return name.strip()
return None
-10
View File
@@ -1,13 +1,3 @@
class SSLConfigurationError(Exception):
"""Raised when SSL/TLS certificate bundle configuration fails."""
pass
class EmptyStreamError(RuntimeError):
"""Raised when a provider closes a stream without yielding a response."""
pass
class MoAPresetNotFoundError(ValueError):
"""Raised when a persisted MoA preset no longer exists in config."""
+9 -66
View File
@@ -46,9 +46,6 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
# Bitwarden Secrets Manager encrypted disk cache.
str(hermes_home / "cache" / "bws_cache.enc.json"),
str(hermes_root / "cache" / "bws_cache.enc.json"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
@@ -98,16 +95,16 @@ def get_safe_write_roots() -> set[str]:
return roots
def _classify_write_denial(path: str) -> Optional[str]:
"""Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed."""
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
home = os.path.realpath(os.path.expanduser("~"))
resolved = os.path.realpath(os.path.expanduser(str(path)))
if resolved in build_write_denied_paths(home):
return "credential"
return True
for prefix in build_write_denied_prefixes(home):
if resolved.startswith(prefix):
return "credential"
return True
mcp_tokens_dir_name = "mcp-tokens"
@@ -121,27 +118,16 @@ def _classify_write_denial(path: str) -> Optional[str]:
continue
for base_real in hermes_dirs:
# Session transcripts are application-owned state. Letting the agent's
# generic file tools rewrite state.db or legacy JSON snapshots can
# falsify conversation history and invalidate resume/compression state.
try:
if resolved == os.path.realpath(os.path.join(base_real, "state.db")):
return True
sessions_real = os.path.realpath(os.path.join(base_real, "sessions"))
if resolved == sessions_real or resolved.startswith(sessions_real + os.sep):
return True
except Exception:
pass
try:
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
return "credential"
return True
except Exception:
pass
try:
pairing_real = os.path.realpath(os.path.join(base_real, "pairing"))
if resolved == pairing_real or resolved.startswith(pairing_real + os.sep):
return "credential"
return True
except Exception:
pass
@@ -153,28 +139,9 @@ def _classify_write_denial(path: str) -> Optional[str]:
allowed = True
break
if not allowed:
return "safe_root"
return True
return None
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
return _classify_write_denial(path) is not None
def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]:
"""Return a user/model-facing error when writes to ``path`` are blocked."""
denial = _classify_write_denial(path)
if denial is None:
return None
if denial == "safe_root":
roots_display = os.pathsep.join(sorted(get_safe_write_roots()))
return (
f"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT "
f"({roots_display}). Unset the variable or add this path's directory prefix."
)
return f"{verb} denied: '{path}' is a protected system/credential file."
return False
# Common secret-bearing project-local environment file basenames.
@@ -326,7 +293,7 @@ def get_read_block_error(path: str) -> Optional[str]:
# .env contents — .env.example is the documented-shape substitute. The
# terminal tool can still ``cat .env``; this is defense-in-depth, not a
# boundary (see module docstring).
if resolved.name.lower() in _BLOCKED_PROJECT_ENV_BASENAMES:
if resolved.name in _BLOCKED_PROJECT_ENV_BASENAMES:
return (
f"Access denied: {path} is a secret-bearing environment file "
"and cannot be read to prevent credential leakage. "
@@ -337,30 +304,6 @@ def get_read_block_error(path: str) -> Optional[str]:
return None
def raise_if_read_blocked(path: str) -> None:
"""Raise ``ValueError`` if ``path`` is a denied Hermes read (see
:func:`get_read_block_error`), else return.
Shared chokepoint for provider input-loading sites that read a local
file the model/tool supplied (e.g. image-gen ``image_url`` /
``reference_image_urls`` paths). Centralizes the guard so every provider
enforces the same read boundary with identical semantics instead of each
open-coding the try/except block (#57698).
Best-effort by design: if ``agent.file_safety`` machinery is somehow
unavailable at the call site the guard no-ops rather than breaking local
image loading consistent with the defense-in-depth (not security
boundary) framing of the denylist itself. The blocking ``ValueError`` from
a real hit still propagates; only unexpected internal errors are swallowed.
"""
try:
blocked = get_read_block_error(path)
except Exception: # noqa: BLE001 - guard must never break local-file loading
return
if blocked:
raise ValueError(blocked)
# ---------------------------------------------------------------------------
# Cross-profile write guard (#TBD)
#
+10 -44
View File
@@ -27,18 +27,10 @@ from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent.bounded_response import read_streaming_error_body
from agent.gemini_schema import sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
try:
import hermes_cli as _hermes_cli
_HERMES_VERSION = str(_hermes_cli.__version__)
except Exception:
_HERMES_VERSION = "0.0.0"
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model
@@ -106,10 +98,7 @@ def probe_gemini_tier(
url,
params={"key": key},
json=payload,
headers={
"Content-Type": "application/json",
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
},
headers={"Content-Type": "application/json"},
)
except Exception as exc:
logger.debug("probe_gemini_tier: network error: %s", exc)
@@ -348,22 +337,6 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
if parts:
contents.append({"role": gemini_role, "parts": parts})
# Gemini's generateContent requires strict user/model alternation;
# consecutive same-role contents are rejected with HTTP 400 "Please ensure
# that multiturn requests alternate between user and model". The loop above
# emits one content per source message, so parallel tool calls (N tool
# results become N user functionResponse contents), back-to-back user turns,
# or merged assistant turns would each violate that. Merge adjacent
# same-role contents by concatenating their parts. For parallel calls this
# also produces the grouped multi-functionResponse turn Gemini expects.
merged_contents: List[Dict[str, Any]] = []
for content in contents:
if merged_contents and merged_contents[-1]["role"] == content["role"]:
merged_contents[-1]["parts"].extend(content["parts"])
else:
merged_contents.append(content)
contents = merged_contents
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
@@ -753,17 +726,14 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices:
return chunks
def gemini_http_error(
response: httpx.Response, *, body_text: Optional[str] = None
) -> GeminiAPIError:
def gemini_http_error(response: httpx.Response) -> GeminiAPIError:
status = response.status_code
body_text = ""
body_json: Dict[str, Any] = {}
if body_text is None:
try:
body_text = response.text
except Exception:
body_text = ""
body_text = body_text or ""
try:
body_text = response.text
except Exception:
body_text = ""
if body_text:
try:
parsed = json.loads(body_text)
@@ -911,11 +881,7 @@ class GeminiNativeClient:
"Content-Type": "application/json",
"Accept": "application/json",
"x-goog-api-key": self.api_key,
# Include Hermes client context following Gemini's partner
# integration guidance.
# See https://ai.google.dev/gemini-api/docs/partner-integration
"User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)",
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
"User-Agent": "hermes-agent (gemini-native)",
}
headers.update(self._default_headers)
return headers
@@ -986,8 +952,8 @@ class GeminiNativeClient:
try:
with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response:
if response.status_code != 200:
body_text = read_streaming_error_body(response)
raise gemini_http_error(response, body_text=body_text)
response.read()
raise gemini_http_error(response)
tool_call_indices: Dict[str, Dict[str, Any]] = {}
for event in _iter_sse_events(response):
for chunk in translate_stream_event(event, model, tool_call_indices):
-24
View File
@@ -87,30 +87,6 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
if any(not isinstance(item, str) for item in enum_val):
cleaned.pop("enum", None)
# Gemini validates ``required`` strictly against the same node's
# ``properties`` — GenerateContentRequest fails with HTTP 400
# "...items.required[0]: property is not defined" when a required name
# has no matching property in that node. MCP servers routinely emit
# this shape (e.g. the GitHub remote MCP's array item schemas carry
# ``required`` without ``properties``), and one bad tool schema fails
# the ENTIRE request before any model output. Filter ``required`` to
# names that exist in this node's ``properties`` and drop it when
# nothing valid remains. The tool handler still validates required
# fields at execution time, so this only removes what Gemini couldn't
# accept anyway. (Port of Kilo-Org/kilocode#11955.)
required_val = cleaned.get("required")
if isinstance(required_val, list):
props_val = cleaned.get("properties")
prop_names = set(props_val.keys()) if isinstance(props_val, dict) else set()
valid_required = [
name for name in required_val
if isinstance(name, str) and name in prop_names
]
if not valid_required:
cleaned.pop("required", None)
elif len(valid_required) != len(required_val):
cleaned["required"] = valid_required
return cleaned
+27 -254
View File
@@ -17,17 +17,13 @@ It reads ``agent.image_input_mode`` from config.yaml (``auto`` | ``native``
| ``text``, default ``auto``) and the active model's capability metadata.
In ``auto`` mode:
- If the active model reports ``supports_vision=True`` (via config
override or models.dev metadata), we attach natively vision-capable
main models should always see the original pixels, even when an
auxiliary vision backend is configured. That auxiliary backend then
acts as a *fallback* for sessions whose main model can't take images.
- Otherwise, if the user has explicitly configured ``auxiliary.vision``
(provider/model/base_url not ``auto``/empty), we route through the
text pipeline so the auxiliary vision backend can describe the image
for the text-only main model.
- Otherwise (non-vision model, no explicit override), we fall back to
text via the default vision_analyze flow.
- If the user has explicitly configured ``auxiliary.vision.provider``
(i.e. not ``auto`` and not empty), we assume they want the text pipeline
regardless of the main model they've opted in to a specific vision
backend for a reason (cost, quality, local-only, etc.).
- Otherwise, if the active model reports ``supports_vision=True`` in its
models.dev metadata, we attach natively.
- Otherwise (non-vision model, no explicit override), we fall back to text.
This keeps ``vision_analyze`` surfaced as a tool in every session skills
and agent flows that chain it (browser screenshots, deeper inspection of
@@ -189,8 +185,7 @@ def _supports_vision_override(
2. ``providers.<provider>.models.<model>.supports_vision``
(named custom providers ``provider`` may be the runtime-resolved
value ``"custom"`` and/or the user-declared name under
``model.provider``; both are tried. For ``custom:<name>`` syntax,
the stripped ``<name>`` is also tried as a provider key.)
``model.provider``; both are tried)
Returns None when no override is set, so the caller falls through to
models.dev. Returns False explicitly only when the user wrote a
@@ -210,16 +205,11 @@ 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, plus the stripped suffix from
# "custom:<name>" (where <name> is the key under providers:).
# both as candidate provider keys.
config_provider = str(model_cfg.get("provider") or "").strip()
# Extract the stripped name from "custom:<name>" if present
stripped_suffix = ""
if config_provider.startswith("custom:"):
stripped_suffix = config_provider[len("custom:"):]
providers_raw = cfg.get("providers")
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
for p in dict.fromkeys(filter(None, (provider, config_provider))):
entry_raw = providers_cfg.get(p)
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
models_raw = entry.get("models")
@@ -261,80 +251,6 @@ def _supports_vision_override(
return None
def _resolve_inference_base_url(
cfg: Optional[Dict[str, Any]],
provider: str,
) -> str:
"""Best-effort base URL for the active inference provider."""
try:
from agent.auxiliary_client import _runtime_main_value
runtime = str(_runtime_main_value("base_url") or "").strip()
runtime_provider = str(_runtime_main_value("provider") or "").strip().lower()
requested_provider = str(provider or "").strip().lower()
if runtime and (not requested_provider or requested_provider == runtime_provider):
return runtime
except Exception:
pass
if not isinstance(cfg, dict):
return ""
model_cfg_raw = cfg.get("model")
model_cfg: Dict[str, Any] = model_cfg_raw if isinstance(model_cfg_raw, dict) else {}
base_url = str(model_cfg.get("base_url") or "").strip()
if base_url:
return base_url
config_provider = str(model_cfg.get("provider") or "").strip()
candidate_names: set[str] = set()
for p in filter(None, (provider, config_provider)):
candidate_names.add(p)
if p.lower().startswith("custom:"):
candidate_names.add(p.split(":", 1)[1])
else:
candidate_names.add(f"custom:{p}")
providers_cfg = cfg.get("providers")
if isinstance(providers_cfg, dict):
for name in candidate_names:
entry = providers_cfg.get(name)
if isinstance(entry, dict):
bu = str(entry.get("base_url") or "").strip()
if bu:
return bu
custom_providers = cfg.get("custom_providers")
if isinstance(custom_providers, list):
lowered = {n.lower() for n in candidate_names}
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip()
if entry_name not in candidate_names and entry_name.lower() not in lowered:
continue
bu = str(entry_raw.get("base_url") or "").strip()
if bu:
return bu
return ""
def _should_probe_ollama_vision(provider: str, base_url: str) -> bool:
"""True when the active provider likely fronts a local Ollama server."""
p = (provider or "").strip().lower()
if p == "ollama":
return True
if not base_url:
return False
try:
from agent.model_metadata import detect_local_server_type
return detect_local_server_type(base_url) == "ollama"
except Exception:
return False
def _coerce_mode(raw: Any) -> str:
"""Normalize a config value into one of the valid modes."""
if not isinstance(raw, str):
@@ -348,10 +264,8 @@ def _coerce_mode(raw: Any) -> str:
def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
"""True when the user configured a specific auxiliary vision backend.
An explicit override means the user has a dedicated vision backend
available; it's used as a *fallback* when the main model can't take
images natively. In ``auto`` mode, native vision on a vision-capable
main model still wins over this fallback see issue #29135.
An explicit override means the user *wants* the text pipeline (they're
paying for a dedicated vision model), so we don't silently bypass it.
"""
if not isinstance(cfg, dict):
return False
@@ -388,33 +302,15 @@ def _lookup_supports_vision(
return override
if not provider or not model:
return None
caps = None
try:
from agent.models_dev import get_model_capabilities
caps = get_model_capabilities(provider, model)
except Exception as exc: # pragma: no cover - defensive
logger.debug("image_routing: caps lookup failed for %s:%s%s", provider, model, exc)
if caps is not None:
return bool(caps.supports_vision)
base_url = _resolve_inference_base_url(cfg, provider)
if not base_url and (provider or "").strip().lower() == "ollama":
base_url = "http://localhost:11434/v1"
if _should_probe_ollama_vision(provider, base_url):
try:
from agent.model_metadata import query_ollama_supports_vision
ollama_vision = query_ollama_supports_vision(model, base_url)
if ollama_vision is not None:
return ollama_vision
except Exception as exc: # pragma: no cover - defensive
logger.debug(
"image_routing: ollama vision probe failed for %s:%s%s",
provider,
model,
exc,
)
return None
return None
if caps is None:
return None
return bool(caps.supports_vision)
def decide_image_input_mode(
@@ -440,15 +336,13 @@ def decide_image_input_mode(
if mode_cfg == "text":
return "text"
# auto: prefer native vision when the main model supports it. An
# explicit auxiliary.vision config acts as a *fallback* for text-only
# main models — it should not preempt native vision on a model that
# can natively inspect the pixels (issue #29135).
# auto
if _explicit_aux_vision_override(cfg):
return "text"
supports = _lookup_supports_vision(provider, model, cfg)
if supports is True:
return "native"
if _explicit_aux_vision_override(cfg):
return "text"
return "text"
@@ -494,98 +388,14 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]:
# BMP: "BM"
if raw.startswith(b"BM"):
return "image/bmp"
# ISO-BMFF family (HEIC/HEIF/AVIF): bytes 4..8 == 'ftyp', major brand at 8..12
if len(raw) >= 12 and raw[4:8] == b"ftyp":
brand = raw[8:12]
if brand in {b"avif", b"avis"}:
return "image/avif"
if brand in {
b"heic", b"heix", b"hevc", b"hevx",
b"mif1", b"msf1", b"heim", b"heis",
}:
return "image/heic"
# TIFF: II*\0 (little-endian) or MM\0* (big-endian)
if raw[:4] in {b"II*\x00", b"MM\x00*"}:
return "image/tiff"
# ICO: 00 00 01 00 (reserved=0, type=1=icon)
if raw[:4] == b"\x00\x00\x01\x00":
return "image/x-icon"
# SVG: text-based, look for an <svg tag near the start (skip BOM/whitespace)
head = raw[:512].lstrip().lower()
if head.startswith(b"<?xml") or head.startswith(b"<svg"):
if b"<svg" in head:
return "image/svg+xml"
# HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc.
if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in {
b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis",
}:
return "image/heic"
return None
# Formats every major vision provider (Anthropic, OpenAI, Gemini, Bedrock)
# accepts natively. Anything outside this set has to be transcoded to PNG
# before we declare media_type, otherwise the provider returns HTTP 400
# ("Could not process image" / "Unsupported image media type") and the
# whole turn fails with no salvage path.
#
# Discord (and a few other chat platforms) freely accept attachments in
# formats outside this set -- AVIF screenshots from Chromium, HEIC from
# iPhones, TIFF from scanners, BMP from old Windows tools, ICO -- so users
# do hit this in practice. SVG is vector and Pillow cannot rasterize it;
# it is skipped (logged) rather than transcoded.
_UNIVERSALLY_SUPPORTED_MIMES = frozenset({
"image/png", "image/jpeg", "image/gif", "image/webp",
})
def _transcode_to_png(raw: bytes) -> Optional[bytes]:
"""Decode arbitrary image bytes with Pillow and re-encode as PNG.
Returns None if Pillow isn't installed or can't decode the input
(rare formats, corrupted bytes, missing optional decoder plugin for
HEIC/AVIF, or vector formats like SVG). Caller falls back to skipping
the image so the rest of the turn still works.
HEIC/HEIF and AVIF need optional Pillow plugins; we try to register
them on demand and swallow ImportError so a missing plugin just
looks like 'Pillow can't decode this' rather than crashing.
"""
try:
from PIL import Image
except ImportError:
logger.info(
"image_routing: Pillow not installed; cannot transcode "
"non-standard image format to PNG. Install with `pip install Pillow` "
"(and `pillow-heif` / `pillow-avif-plugin` for those formats)."
)
return None
# Optional plugin registration. Silent on failure: an unsupported
# format will just fall through to Image.open raising below.
try:
import pillow_heif # type: ignore
pillow_heif.register_heif_opener()
except Exception:
pass
try:
import pillow_avif # type: ignore # noqa: F401 -- registers AVIF on import
except Exception:
pass
try:
from io import BytesIO
with Image.open(BytesIO(raw)) as im:
# Pick an output mode PNG can serialise. Anything other than
# the standard set gets normalised to RGBA so transparency is
# preserved where the source had it.
if im.mode not in {"RGB", "RGBA", "L", "LA", "P"}:
im = im.convert("RGBA")
buf = BytesIO()
im.save(buf, format="PNG", optimize=False)
return buf.getvalue()
except Exception as exc:
logger.info(
"image_routing: Pillow could not transcode image to PNG -- %s", exc
)
return None
def _guess_mime(path: Path, raw: Optional[bytes] = None) -> str:
"""Return image MIME type for *path*.
@@ -621,52 +431,15 @@ def _file_to_data_url(path: Path) -> Optional[str]:
accept large images (OpenAI 49 MB+, Gemini 100 MB) don't pay a silent
quality tax just because one other provider is stricter.
Format compatibility IS handled here: if the sniffed MIME isn't one
of ``_UNIVERSALLY_SUPPORTED_MIMES`` (i.e. it's something like AVIF,
HEIC, BMP, TIFF, or ICO that some providers reject outright), we
transcode to PNG with Pillow before declaring media_type. This fixes
the user-visible "Could not process image" HTTP 400 from Anthropic on
Discord-attached AVIF/HEIC/BMP files.
Returns None if the file can't be read OR if the format isn't
universally supported AND Pillow can't transcode it (Pillow missing,
HEIC/AVIF plugin missing, vector format like SVG, corrupt bytes). The
caller reports those paths in ``skipped`` and the rest of the turn
proceeds.
Returns None only if the file can't be read (missing, permission
denied, etc.); the caller reports those paths in ``skipped``.
"""
try:
from agent.file_safety import raise_if_read_blocked
raise_if_read_blocked(str(path))
except ValueError as exc:
logger.warning("image_routing: blocked local image attachment %s -- %s", path, exc)
return None
except Exception:
# Keep attachment routing best-effort if the guard itself is unavailable.
pass
try:
raw = path.read_bytes()
except Exception as exc:
logger.warning("image_routing: failed to read %s%s", path, exc)
return None
mime = _guess_mime(path, raw=raw)
if mime not in _UNIVERSALLY_SUPPORTED_MIMES:
transcoded = _transcode_to_png(raw)
if transcoded is None:
logger.warning(
"image_routing: %s is %s which is not accepted by all major "
"vision providers and could not be transcoded to PNG; "
"skipping this attachment.",
path, mime,
)
return None
logger.info(
"image_routing: transcoded %s (%s) -> image/png for provider compatibility",
path.name, mime,
)
raw = transcoded
mime = "image/png"
b64 = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}"
+23 -194
View File
@@ -17,7 +17,6 @@ Usage:
"""
import json
import sqlite3
import time
from collections import Counter, defaultdict
from datetime import datetime
@@ -142,8 +141,8 @@ class InsightsEngine:
}
# Compute insights
models = self._compute_model_breakdown(sessions, cutoff, source)
overview = self._compute_overview(sessions, message_stats, models)
overview = self._compute_overview(sessions, message_stats)
models = self._compute_model_breakdown(sessions)
platforms = self._compute_platform_breakdown(sessions)
tools = self._compute_tool_breakdown(tool_usage)
skills = self._compute_skill_breakdown(skill_usage)
@@ -173,7 +172,7 @@ class InsightsEngine:
"message_count, tool_call_count, input_tokens, output_tokens, "
"cache_read_tokens, cache_write_tokens, billing_provider, "
"billing_base_url, billing_mode, estimated_cost_usd, "
"actual_cost_usd, cost_status, cost_source, api_call_count")
"actual_cost_usd, cost_status, cost_source")
# Pre-computed query strings — f-string evaluated once at class definition,
# not at runtime, so no user-controlled value can alter the query structure.
@@ -400,12 +399,7 @@ class InsightsEngine:
# Computation
# =========================================================================
def _compute_overview(
self,
sessions: List[Dict],
message_stats: Dict,
models: Optional[List[Dict]] = None,
) -> Dict:
def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:
"""Compute high-level overview statistics."""
total_input = sum(s.get("input_tokens") or 0 for s in sessions)
total_output = sum(s.get("output_tokens") or 0 for s in sessions)
@@ -437,21 +431,6 @@ class InsightsEngine:
else:
models_without_pricing.add(display)
if models:
total_cost = sum(float(m.get("cost") or 0.0) for m in models)
# Token totals likewise: the per-model breakdown includes
# auxiliary usage rows (vision/compression/titles — task
# dimension in session_model_usage, #23270) plus reconciled
# residuals, while the sessions counters carry main-loop usage
# only. Summing the breakdown keeps overview totals consistent
# with the per-model table and stops `hermes insights`
# undercounting aux spend (#58592, #9979).
total_input = sum(int(m.get("input_tokens") or 0) for m in models)
total_output = sum(int(m.get("output_tokens") or 0) for m in models)
total_cache_read = sum(int(m.get("cache_read_tokens") or 0) for m in models)
total_cache_write = sum(int(m.get("cache_write_tokens") or 0) for m in models)
total_tokens = total_input + total_output + total_cache_read + total_cache_write
# Session duration stats (guard against negative durations from clock drift)
durations = []
for s in sessions:
@@ -494,189 +473,39 @@ class InsightsEngine:
"included_cost_sessions": included_cost_sessions,
}
_GET_MODEL_USAGE_WITH_SOURCE = (
"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,"
" u.api_call_count, u.input_tokens, u.output_tokens,"
" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,"
" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,"
" u.cost_source, u.billing_mode"
" FROM session_model_usage u"
" JOIN sessions s ON s.id = u.session_id"
" WHERE s.started_at >= ? AND s.source = ?"
)
_GET_MODEL_USAGE_ALL = (
"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,"
" u.api_call_count, u.input_tokens, u.output_tokens,"
" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,"
" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,"
" u.cost_source, u.billing_mode"
" FROM session_model_usage u"
" JOIN sessions s ON s.id = u.session_id"
" WHERE s.started_at >= ?"
)
def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]:
"""Fetch per-model usage rows within the window (issue #51607).
Returns an empty list when the table is missing (e.g. a DB opened by
older code that never created it) so the caller can fall back to the
per-session aggregate.
"""
try:
if source:
cursor = self._conn.execute(
self._GET_MODEL_USAGE_WITH_SOURCE, (cutoff, source)
)
else:
cursor = self._conn.execute(self._GET_MODEL_USAGE_ALL, (cutoff,))
return [dict(row) for row in cursor.fetchall()]
except sqlite3.OperationalError:
return []
def _compute_model_breakdown(
self, sessions: List[Dict], cutoff: float, source: str = None
) -> List[Dict]:
"""Break down token usage and cost by model.
Tokens and cost are attributed per model from session_model_usage, so a
session that switched models mid-flight (via ``/model``) splits across
every model it used instead of dumping everything on the initial model
(issue #51607). Sessions without per-model rows — e.g. data written
before this table existed and not yet backfilled fall back to their
single recorded (model, billing_provider) aggregate so nothing is lost.
Tool calls aren't tied to a specific API invocation, so they stay
attributed to the session's recorded model.
"""
def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]:
"""Break down usage by model."""
model_data = defaultdict(lambda: {
"sessions": set(), "input_tokens": 0, "output_tokens": 0,
"sessions": 0, "input_tokens": 0, "output_tokens": 0,
"cache_read_tokens": 0, "cache_write_tokens": 0,
"reasoning_tokens": 0, "total_tokens": 0, "api_calls": 0,
"tool_calls": 0, "cost": 0.0, "actual_cost": 0.0,
"total_tokens": 0, "tool_calls": 0, "cost": 0.0,
})
def _accumulate(model, provider, base_url, session_id, inp, out,
cache_read, cache_write, reasoning, *,
stored_cost=None, actual_cost=None, cost_status=None):
model = model or "unknown"
for s in sessions:
model = s.get("model") or "unknown"
# Normalize: strip provider prefix for display
display_model = model.split("/")[-1] if "/" in model else model
d: Dict[str, Any] = model_data[display_model]
d["sessions"].add(session_id)
d = model_data[display_model]
d["sessions"] += 1
inp = s.get("input_tokens") or 0
out = s.get("output_tokens") or 0
cache_read = s.get("cache_read_tokens") or 0
cache_write = s.get("cache_write_tokens") or 0
d["input_tokens"] += inp
d["output_tokens"] += out
d["cache_read_tokens"] += cache_read
d["cache_write_tokens"] += cache_write
d["reasoning_tokens"] += reasoning
d["total_tokens"] += inp + out + cache_read + cache_write
if stored_cost is None:
estimate, status = _estimate_cost(
model, inp, out,
cache_read_tokens=cache_read, cache_write_tokens=cache_write,
provider=provider or None, base_url=base_url,
)
else:
estimate = float(stored_cost or 0.0)
status = cost_status or "unknown"
d["tool_calls"] += s.get("tool_call_count") or 0
estimate, status = _estimate_cost(s)
d["cost"] += estimate
d["actual_cost"] += float(actual_cost or 0.0)
d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["cost_status"] = status
if has_known_pricing(model, provider or None, base_url):
d["has_pricing"] = True
else:
d.setdefault("has_pricing", False)
return display_model
usage_rows = self._get_model_usage(cutoff, source)
usage_totals = defaultdict(lambda: {
"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0,
"cache_write_tokens": 0, "reasoning_tokens": 0,
"api_call_count": 0, "estimated_cost_usd": 0.0,
"actual_cost_usd": 0.0,
})
for r in usage_rows:
totals: Dict[str, Any] = usage_totals[r["session_id"]]
for key in (
"input_tokens", "output_tokens", "cache_read_tokens",
"cache_write_tokens", "reasoning_tokens", "api_call_count",
):
totals[key] += r[key] or 0
totals["estimated_cost_usd"] += r["estimated_cost_usd"] or 0.0
totals["actual_cost_usd"] += r["actual_cost_usd"] or 0.0
d = _accumulate(
r["model"], r["billing_provider"], r.get("billing_base_url"),
r["session_id"], r["input_tokens"] or 0, r["output_tokens"] or 0,
r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0,
r["reasoning_tokens"] or 0,
stored_cost=(
r["estimated_cost_usd"]
if r.get("cost_status") or r.get("cost_source")
else None
),
actual_cost=r["actual_cost_usd"],
cost_status=r.get("cost_status"),
)
model_data[d]["api_calls"] += r["api_call_count"] or 0
# Reconcile against the aggregate row. This covers legacy sessions,
# interrupted migrations, and absolute cumulative updates without
# double-counting already-attributed route deltas.
for s in sessions:
totals = usage_totals[s["id"]]
inp = max(0, (s.get("input_tokens") or 0) - totals["input_tokens"])
out = max(0, (s.get("output_tokens") or 0) - totals["output_tokens"])
cache_read = max(
0, (s.get("cache_read_tokens") or 0) - totals["cache_read_tokens"]
)
cache_write = max(
0, (s.get("cache_write_tokens") or 0) - totals["cache_write_tokens"]
)
residual_cost = max(
0.0, float(s.get("estimated_cost_usd") or 0.0)
- totals["estimated_cost_usd"],
)
residual_actual = max(
0.0, float(s.get("actual_cost_usd") or 0.0)
- totals["actual_cost_usd"],
)
residual_calls = max(
0, (s.get("api_call_count") or 0) - totals["api_call_count"]
)
if not (
inp or out or cache_read or cache_write or residual_cost
or residual_actual or residual_calls
):
continue
d = _accumulate(
s.get("model"), s.get("billing_provider"),
s.get("billing_base_url"), s["id"],
inp, out, cache_read, cache_write, 0,
stored_cost=residual_cost,
actual_cost=residual_actual,
cost_status=s.get("cost_status"),
)
residual_bucket: Dict[str, Any] = model_data[d]
residual_bucket["api_calls"] += residual_calls
# Tool calls are attributed by the session's recorded model.
for s in sessions:
tool_calls = s.get("tool_call_count") or 0
if not tool_calls:
continue
model = s.get("model") or "unknown"
display_model = model.split("/")[-1] if "/" in model else model
model_data[display_model]["tool_calls"] += tool_calls
result = []
for model, data in model_data.items():
entry = {"model": model, **data}
entry["sessions"] = len(data["sessions"])
# Models that surfaced only via tool-call attribution (no token
# rows) won't have these set by _accumulate — default them so the
# output shape is uniform for downstream/JSON consumers.
entry.setdefault("has_pricing", False)
entry.setdefault("cost_status", "unknown")
result.append(entry)
result = [
{"model": model, **data}
for model, data in model_data.items()
]
# Sort by tokens first, fall back to session count when tokens are 0
result.sort(key=lambda x: (x["total_tokens"], x["sessions"]), reverse=True)
return result
-108
View File
@@ -1,108 +0,0 @@
"""Turn-end guard for kanban workers.
Kanban workers must end with ``kanban_complete`` or ``kanban_block``. Models
(especially GLM / Qwen families) sometimes narrate the next step
("Let me write the report now") and stop with ``finish_reason=stop`` and no
tool calls. Hermes treats that as a clean exit ``rc=0`` dispatcher
``protocol_violation``.
This module is policy-only: when a kanban worker tries to finish without a
terminal board tool, return a bounded synthetic nudge so the conversation
loop continues instead of exiting.
"""
from __future__ import annotations
import os
from typing import Any, Iterable, Optional
_TERMINAL_KANBAN_TOOLS = frozenset({"kanban_complete", "kanban_block"})
_DEFAULT_MAX_ATTEMPTS = 2
def kanban_stop_nudge_enabled() -> bool:
"""Return whether the kanban stop-guard is active for this process.
On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless
``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it.
"""
env = os.environ.get("HERMES_KANBAN_STOP_NUDGE")
if env is not None and env.strip().lower() in {"0", "false", "no", "off"}:
return False
task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
return bool(task)
def _tool_call_name(tc: Any) -> str:
if isinstance(tc, dict):
fn = tc.get("function")
if isinstance(fn, dict):
return str(fn.get("name") or "")
return str(tc.get("name") or "")
fn = getattr(tc, "function", None)
if fn is not None:
return str(getattr(fn, "name", "") or "")
return str(getattr(tc, "name", "") or "")
def session_called_kanban_terminal(messages: Iterable[dict] | None) -> bool:
"""True if this conversation already invoked a terminal kanban tool."""
if not messages:
return False
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if _tool_call_name(tc) in _TERMINAL_KANBAN_TOOLS:
return True
elif role == "tool":
name = str(msg.get("name") or "")
if name in _TERMINAL_KANBAN_TOOLS:
return True
return False
def build_kanban_stop_nudge(
*,
messages: Iterable[dict] | None = None,
attempts: int = 0,
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
task_id: Optional[str] = None,
) -> Optional[str]:
"""Return a synthetic follow-up when a kanban worker exits without a terminal tool.
Returns ``None`` when the guard should not fire (not a kanban worker,
already completed/blocked, or nudge budget exhausted).
"""
if not kanban_stop_nudge_enabled():
return None
if attempts >= max_attempts:
return None
if session_called_kanban_terminal(messages):
return None
tid = (task_id or os.environ.get("HERMES_KANBAN_TASK") or "").strip() or "this task"
return (
"[System: You are a Hermes kanban worker. A plain-text reply is NOT a "
"terminal state for the board.\n\n"
f"Task `{tid}` is still `running`. Ending now without a board tool "
"causes a protocol violation (clean exit with no "
"`kanban_complete` / `kanban_block`).\n\n"
"Do this immediately in your next response — do not narrate intent:\n"
"1. Finish any remaining deliverable (write the required file(s) now).\n"
"2. Call `kanban_complete(summary=..., artifacts=[...])` if the work "
"is done, OR `kanban_block(reason=...)` if you are blocked.\n\n"
"Never end a turn with only a promise of future action. Repeated "
"protocol violations will block this task and require manual intervention.]"
)
__all__ = [
"build_kanban_stop_nudge",
"kanban_stop_nudge_enabled",
"session_called_kanban_terminal",
]
+8 -22
View File
@@ -117,29 +117,15 @@ def build_learn_prompt(user_request: str) -> str:
return (
"[/learn] The user wants you to learn a reusable skill from the "
"request below, and save it.\n\n"
f"THE REQUEST:\n{req}\n\n"
"The request is open-ended and may mix two kinds of content, in any "
"order: SOURCES to gather (directories, file paths, URLs, \"what we "
"just did\", pasted notes) AND REQUIREMENTS that shape the skill "
"(what to focus on, what to leave out, scope, naming, the angle to "
"take). Treat EVERY part of the request as load-bearing. In "
"particular, prose that comes after a path or link is NOT incidental "
"— it is the user telling you what they want from that source. A "
"request like `<url> focus on the auth flow, skip the deprecated "
"endpoints` means: gather the URL AND honor \"focus on auth, skip "
"deprecated\" as authoring requirements. Never fetch the first source "
"and ignore the rest.\n\n"
"source(s) they described below, and save it.\n\n"
f"WHAT TO LEARN FROM:\n{req}\n\n"
"Do this:\n"
"1. Gather every source the user named, using the tools you already "
"have — `read_file`/`search_files` for local files or directories, "
"`web_extract` for URLs, the current conversation history if they "
"referred to something you just did, and the text they pasted as-is. "
"If the request is ambiguous about scope, make a reasonable choice "
"and note it; do not stall.\n"
"1b. Apply every requirement, focus, and constraint in the request to "
"the skill you author — these govern what the SKILL.md covers and "
"emphasizes, not just which sources you read.\n"
"1. Gather the material. Resolve whatever the user named using the "
"tools you already have — `read_file`/`search_files` for local files "
"or directories, `web_extract` for URLs, the current conversation "
"history if they referred to something you just did, and the text "
"they pasted as-is. If the request is ambiguous about scope, make a "
"reasonable choice and note it; do not stall.\n"
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
"(action=\"create\"). Pick a sensible category. If the procedure needs "
"a non-trivial script, add it under the skill's `scripts/` with "
-328
View File
@@ -1,328 +0,0 @@
"""Assemble the "learning made visible" graph for desktop.
This graph is intentionally scoped to what a user actually learns over time:
- non-base, learned/profile skills (agent-created or used),
- memory chunks from ``MEMORY.md`` / ``USER.md`` as first-class nodes.
Skill links come from declared ``related_skills``. Memory-to-skill links are
derived from lexical overlap so the graph can answer "which learned skills are
connected to the things I remember?".
Run as a module to print edge-density stats against real data:
python -m agent.learning_graph
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
@dataclass
class SkillNode:
name: str
category: str
source: str = "profile"
timestamp: Optional[int] = None
use_count: int = 0
state: str = "active"
created_by: Optional[str] = None
pinned: bool = False
related: list[str] = field(default_factory=list)
def _frontmatter(text: str) -> dict[str, Any]:
try:
from agent.skill_utils import parse_frontmatter
fm, _ = parse_frontmatter(text)
return fm or {}
except Exception:
return {}
def _hermes_meta(fm: dict[str, Any]) -> dict[str, Any]:
"""``metadata.hermes`` as a dict, tolerant of the string-valued frontmatter
that ``parse_frontmatter``'s malformed-YAML fallback produces."""
meta = fm.get("metadata")
hermes = meta.get("hermes") if isinstance(meta, dict) else None
return hermes if isinstance(hermes, dict) else {}
def _related(fm: dict[str, Any]) -> list[str]:
raw = fm.get("related_skills") or _hermes_meta(fm).get("related_skills")
if isinstance(raw, list):
return [str(r).strip() for r in raw if str(r).strip()]
if isinstance(raw, str):
return [r.strip() for r in raw.strip("[]").split(",") if r.strip()]
return []
def _category(fm: dict[str, Any], skill_md: Path) -> str:
cat = fm.get("category") or _hermes_meta(fm).get("category")
if cat:
return str(cat)
# …/skills/<category>/<skill>/SKILL.md
parts = skill_md.parts
return parts[-3] if len(parts) >= 3 else "general"
def _iter_skill_files(roots: list[tuple[str, Path]]):
for source, root in roots:
if root.exists():
for path in root.rglob("SKILL.md"):
yield source, path
def _load_usage() -> dict[str, dict[str, Any]]:
try:
from tools.skill_usage import load_usage
return load_usage()
except Exception:
path = get_hermes_home() / "skills" / ".usage.json"
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def _to_int_ts(value: Any) -> Optional[int]:
try:
if value is None:
return None
if isinstance(value, (int, float)):
return int(value)
s = str(value).strip()
if not s:
return None
try:
return int(float(s))
except ValueError:
parsed = datetime.fromisoformat(s.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp())
except Exception:
return None
def _usage_timestamp(rec: dict[str, Any]) -> Optional[int]:
for key in ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at"):
ts = _to_int_ts(rec.get(key))
if ts is not None:
return ts
return None
def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]:
usage = _load_usage()
nodes: dict[str, SkillNode] = {}
for source, skill_md in _iter_skill_files(skill_roots):
if any(p in {".archive", ".hub", "node_modules", ".git"} for p in skill_md.parts):
continue
try:
fm = _frontmatter(skill_md.read_text(encoding="utf-8")[:4000])
except OSError:
continue
name = str(fm.get("name") or skill_md.parent.name).strip()
if not name or name in nodes:
continue
rec = usage.get(name, {})
last_activity = _usage_timestamp(rec)
file_ts = _to_int_ts(skill_md.stat().st_mtime)
nodes[name] = SkillNode(
name=name,
category=_category(fm, skill_md),
source=source,
timestamp=last_activity or file_ts,
use_count=int(rec.get("use_count", 0) or 0),
state=str(rec.get("state", "active") or "active"),
created_by=rec.get("created_by"),
pinned=bool(rec.get("pinned", False)),
related=_related(fm),
)
return nodes
def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]:
"""Undirected related_skills edges where BOTH endpoints exist (deduped)."""
seen: set[tuple[str, str]] = set()
edges: list[tuple[str, str]] = []
for node in nodes.values():
for target in node.related:
if target in nodes and target != node.name:
a, b = sorted((node.name, target))
key = (a, b)
if key not in seen:
seen.add(key)
edges.append(key)
return edges
def density_stats(nodes: dict[str, SkillNode], edges: list[tuple[str, str]]) -> dict[str, Any]:
linked: set[str] = set()
for a, b in edges:
linked.add(a)
linked.add(b)
cats: dict[str, int] = {}
for n in nodes.values():
cats[n.category] = cats.get(n.category, 0) + 1
n = len(nodes) or 1
return {
"nodes": len(nodes),
"related_edges": len(edges),
"edges_per_node": round(len(edges) / n, 3),
"linked_nodes": len(linked),
"isolated_pct": round(100 * (n - len(linked)) / n, 1),
"categories": len(cats),
"agent_created": sum(1 for x in nodes.values() if x.created_by == "agent"),
"used": sum(1 for x in nodes.values() if x.use_count > 0),
"top_categories": sorted(cats.items(), key=lambda kv: -kv[1])[:8],
}
def _memory_cards() -> list[dict[str, Any]]:
"""Freeform memory as readable cards.
``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each
chunk becomes one card. Every chunk is surfaced the graph shows everything.
"""
base = get_hermes_home() / "memories"
cards: list[dict[str, Any]] = []
for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")):
path = base / fname
try:
text = path.read_text(encoding="utf-8").strip()
file_ts = _to_int_ts(path.stat().st_mtime)
except OSError:
continue
for chunk_idx, chunk in enumerate(c.strip() for c in text.split("\n§\n")):
if not chunk:
continue
first = chunk.splitlines()[0].strip().lstrip("# ").strip()
cards.append(
{
"source": source,
"timestamp": file_ts + chunk_idx if file_ts is not None else None,
"title": (first[:80] + "") if len(first) > 80 else first,
"body": chunk[:1200],
}
)
return cards
def _tokenize(text: str) -> set[str]:
return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3}
def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNode]) -> list[tuple[str, str]]:
edges: list[tuple[str, str]] = []
skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills]
for idx, card in enumerate(memory_cards):
mem_id = f"memory:{card['source']}:{idx}"
text = f"{card.get('title', '')}\n{card.get('body', '')}".lower()
text_tokens = _tokenize(text)
scored: list[tuple[int, str]] = []
for skill, tokens, skill_name_lower in skill_meta:
score = 0
if skill_name_lower in text:
score += 6
score += len(tokens & text_tokens)
if score > 0:
scored.append((score, skill.name))
scored.sort(key=lambda x: (-x[0], x[1]))
for _, skill_name in scored[:4]:
edges.append((mem_id, skill_name))
return edges
def _skill_roots() -> list[tuple[str, Path]]:
repo = Path(__file__).resolve().parent.parent
home_skills = get_hermes_home() / "skills"
return [("base", repo / "skills"), ("profile", home_skills)]
def build_learning_graph() -> dict[str, Any]:
"""Full payload for the desktop learning panel.
Focus on what is profile-learned and actionable:
- skills that are NOT base-installed and show real learning signal
(agent-created or used),
- memory chunks as first-class graph nodes connected to those learned skills.
"""
all_skills = build_skill_nodes(_skill_roots())
learned_skills = {
name: node
for name, node in all_skills.items()
if node.source != "base" and (node.created_by == "agent" or node.use_count > 0)
}
skill_edges = build_edges(learned_skills)
memory_cards = _memory_cards()
memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values()))
edges = skill_edges + memory_edges
clusters: dict[str, int] = {}
for node in learned_skills.values():
clusters[node.category] = clusters.get(node.category, 0) + 1
if memory_cards:
clusters["memory"] = len(memory_cards)
graph_nodes = [
{
"id": n.name,
"label": n.name,
"kind": "skill",
"timestamp": n.timestamp,
"category": n.category,
"useCount": n.use_count,
"state": n.state,
"createdBy": n.created_by,
"pinned": n.pinned,
}
for n in learned_skills.values()
]
for i, card in enumerate(memory_cards):
graph_nodes.append(
{
"id": f"memory:{card['source']}:{i}",
"label": card["title"],
"kind": "memory",
"memorySource": card["source"],
"timestamp": card.get("timestamp"),
"category": "memory",
"useCount": 0,
"state": "active",
"createdBy": "memory",
"pinned": False,
}
)
return {
"nodes": graph_nodes,
"edges": [{"source": a, "target": b} for a, b in edges],
"clusters": [
{"category": c, "count": n}
for c, n in sorted(clusters.items(), key=lambda kv: -kv[1])
],
"memory": memory_cards,
"stats": {
**density_stats(learned_skills, skill_edges),
"memory_nodes": len(memory_cards),
"memory_skill_edges": len(memory_edges),
"learned_skills": len(learned_skills),
},
}
if __name__ == "__main__":
nodes = build_skill_nodes(_skill_roots())
print(json.dumps(density_stats(nodes, build_edges(nodes)), indent=2))
-659
View File
@@ -1,659 +0,0 @@
"""Terminal renderer for the learning timeline (learned skills + memories).
The desktop app (``apps/desktop/src/app/starmap``) paints a GPU radial
constellation; a terminal can't, so this is a *rendition* of the same data as a
timeline bar chart date rows, proportional skill/memory bars colored by the
day's dominant category, and a cumulative trajectory sparkline — plus per-slice
bucket metadata the TUI walks as a tree. The age gradient and complementary
memory ink are ported from the desktop source, not guessed.
Grids are emitted as style runs ``[text, style, alpha, hex?]`` so each
consumer maps the semantic style + brightness onto its own palette; the
optional 4th element overrides the base color (category heatmap). Pure,
stdlib-only.
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import Any, Iterable, Optional
# time-axis.ts LEAD_IN: the oldest node sits just off recency 0.
LEAD_IN = 0.06
# constants.ts AGE_GRADIENT — old quiet, recent bright.
AGE_OLD_INK = 0.42
AGE_MID_INK = 0.74
AGE_NEW_INK = 0.95
AGE_MID = 0.52
# Style keys consumers map to base colors (brightness = the run alpha).
STYLE_BG = "bg"
STYLE_SKILL = "skill"
STYLE_MEMORY = "memory"
STYLE_LABEL = "label"
STYLE_DIM = "dim"
# Legend glyphs mirror NODE_SHAPE (skill = circle, memory = diamond).
SKILL_GLYPH = ""
MEMORY_GLYPH = ""
_LABEL_KEYS = tuple("123456789abc")
Run = list # [text, style, alpha, hex?]
Row = list # list[Run]
Grid = list # list[Row]
def _to_ts(value: Any) -> Optional[float]:
try:
return None if value is None else float(value)
except (TypeError, ValueError):
return None
def _clamp(v: float, lo: float, hi: float) -> float:
return lo if v < lo else hi if v > hi else v
def _smoothstep(p: float) -> float:
p = _clamp(p, 0.0, 1.0)
return p * p * (3 - 2 * p)
def recency_ink(rec: float) -> float:
"""Port of geometry.ts ``recencyInk`` — smoothstep age → ink alpha."""
t = _clamp(rec, 0.0, 1.0)
if t <= AGE_MID:
return AGE_OLD_INK + (AGE_MID_INK - AGE_OLD_INK) * _smoothstep(t / AGE_MID)
return AGE_MID_INK + (AGE_NEW_INK - AGE_MID_INK) * _smoothstep((t - AGE_MID) / (1 - AGE_MID))
def format_date(ts: Optional[float]) -> str:
if not ts:
return "unknown"
try:
dt = datetime.fromtimestamp(float(ts), tz=timezone.utc)
return f"{dt.day} {dt.strftime('%b %Y')}"
except (ValueError, OSError, OverflowError):
return "unknown"
def compute_recency(nodes: list[dict[str, Any]]) -> dict[str, Any]:
"""Port of time-axis.ts ``computeRecency`` (id → recency ratio, timed flag)."""
known = [t for t in (_to_ts(n.get("timestamp")) for n in nodes) if t is not None]
min_ts = min(known) if known else None
max_ts = max(known) if known else None
timed = min_ts is not None and max_ts is not None and max_ts > min_ts
ordered = sorted(
nodes,
key=lambda n: (
_to_ts(n.get("timestamp")) if _to_ts(n.get("timestamp")) is not None else math.inf,
str(n.get("id", "")),
),
)
last = max(len(ordered) - 1, 1)
ord_ratio = {str(n.get("id", "")): (i / last if len(ordered) > 1 else 0.0) for i, n in enumerate(ordered)}
rec: dict[str, float] = {}
for n in nodes:
nid = str(n.get("id", ""))
ts = _to_ts(n.get("timestamp"))
if timed and ts is not None and min_ts is not None and max_ts is not None:
ratio = (ts - min_ts) / (max_ts - min_ts)
else:
ratio = ord_ratio.get(nid, 0.0)
rec[nid] = LEAD_IN + (1 - LEAD_IN) * _clamp(ratio, 0.0, 1.0)
return {"rec": rec, "timed": timed, "minTs": min_ts, "maxTs": max_ts}
def _date_at(rec: dict[str, Any], reveal: float) -> Optional[float]:
if not rec.get("timed"):
return None
lo, hi = rec.get("minTs"), rec.get("maxTs")
if lo is None or hi is None:
return None
return round(lo + _clamp(reveal, 0, 1) * (hi - lo))
# ── Color: ported from color.ts so memory ink + age fade match the desktop ──
def hex_to_rgb(s: str) -> tuple[int, int, int]:
s = s.strip().lstrip("#")
if len(s) == 3:
s = "".join(c * 2 for c in s)
try:
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
except (ValueError, IndexError):
return 255, 215, 0
def rgb_to_hex(c: tuple) -> str:
return "#{:02X}{:02X}{:02X}".format(*(int(_clamp(v, 0, 255)) for v in c))
def mix_rgb(a: tuple, b: tuple, t: float) -> tuple[int, int, int]:
p = _clamp(t, 0.0, 1.0)
return tuple(round(a[i] + (b[i] - a[i]) * p) for i in range(3)) # type: ignore[return-value]
def _rgb_to_hsl(c: tuple) -> tuple[float, float, float]:
r, g, b = (x / 255 for x in c)
mx, mn = max(r, g, b), min(r, g, b)
light = (mx + mn) / 2
d = mx - mn
if not d:
return 0.0, 0.0, light
s = d / (2 - mx - mn) if light > 0.5 else d / (mx + mn)
if mx == r:
h = (g - b) / d + (6 if g < b else 0)
elif mx == g:
h = (b - r) / d + 2
else:
h = (r - g) / d + 4
return h * 60, s, light
def _hsl_to_rgb(h: float, s: float, light: float) -> tuple[int, int, int]:
hue = ((h % 360) + 360) % 360
c = (1 - abs(2 * light - 1)) * s
x = c * (1 - abs(((hue / 60) % 2) - 1))
m = light - c / 2
if hue < 60:
r, g, b = c, x, 0.0
elif hue < 120:
r, g, b = x, c, 0.0
elif hue < 180:
r, g, b = 0.0, c, x
elif hue < 240:
r, g, b = 0.0, x, c
elif hue < 300:
r, g, b = x, 0.0, c
else:
r, g, b = c, 0.0, x
return round((r + m) * 255), round((g + m) * 255), round((b + m) * 255)
def _complementary_ink(c: tuple) -> tuple[int, int, int]:
h, s, light = _rgb_to_hsl(c)
return _hsl_to_rgb(h + 165, max(s, 0.5), _clamp(light, 0.5, 0.7))
def derive_palette(primary_hex: str, *, dark: bool = True) -> dict[str, str]:
"""Port of color.ts ``computePalette`` (the bits a terminal needs)."""
primary = hex_to_rgb(primary_hex)
base = (255, 255, 255) if dark else (0, 0, 0)
bg = (8, 8, 12) if dark else (250, 250, 250)
return {
"primary": primary_hex,
# Memories are drillable → primary "clickable" ink; skills are dead-ends
# → muted complement.
"memory": rgb_to_hex(mix_rgb(primary, base, 0.12 if dark else 0.18)),
"skill": rgb_to_hex(mix_rgb(_complementary_ink(primary), bg, 0.45)),
"label": rgb_to_hex(mix_rgb(base, bg, 0.35)),
"dim": rgb_to_hex(mix_rgb(base, bg, 0.7)),
"bg": rgb_to_hex(bg),
}
def _node_score(node: dict[str, Any], rec: float) -> float:
"""Pick which visible objects deserve map markers + label rows."""
if node.get("kind") == "memory":
return 3.5 + rec
use = float(node.get("useCount", 0) or 0)
return rec * 2 + math.sqrt(max(0.0, use)) + (2.0 if node.get("pinned") else 0.0)
def _node_label(node: dict[str, Any]) -> str:
text = str(node.get("label") or node.get("id") or "unknown").strip()
return text if len(text) <= 26 else text[:23].rstrip() + ""
def _node_meta(node: dict[str, Any]) -> str:
if node.get("kind") == "memory":
source = "profile memory" if node.get("memorySource") == "profile" else "memory"
return f"{source} · {format_date(_to_ts(node.get('timestamp')))}"
bits = [str(node.get("category") or "skill"), format_date(_to_ts(node.get("timestamp")))]
count = int(node.get("useCount", 0) or 0)
if count:
bits.append(f"x{count}")
if node.get("pinned"):
bits.append("pinned")
return " · ".join(bits)
# ── Timeline chart frame ─────────────────────────────────────────────────────
class _ChartBucket:
__slots__ = ("label", "ts", "skills", "memories", "nodes", "rec")
def __init__(self, label: str, ts: float):
self.label = label
self.ts = ts
self.skills = 0
self.memories = 0
self.nodes: list[dict[str, Any]] = []
self.rec = 1.0
@property
def total(self) -> int:
return self.skills + self.memories
def _period_key(ts: float, granularity: str) -> tuple[int, ...]:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
if granularity == "day":
return (dt.year, dt.month, dt.day)
if granularity == "month":
return (dt.year, dt.month)
return (dt.year,)
def _period_label(ts: float, granularity: str) -> str:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
if granularity == "day":
return f"{dt.day} {dt.strftime('%b')}"
if granularity == "month":
return dt.strftime("%b %Y")
return dt.strftime("%Y")
def _build_chart_buckets(nodes: list[dict[str, Any]], rec: dict[str, Any], max_rows: int) -> list[_ChartBucket]:
"""Timeline rows: finest date granularity that fits, oldest → newest."""
if not nodes:
return []
if not rec["timed"]:
ordered = sorted(nodes, key=lambda n: rec["rec"].get(str(n.get("id", "")), 0.0))
n_bins = min(max_rows, max(1, len(ordered)))
buckets = [_ChartBucket(f"#{i + 1}", float(i)) for i in range(n_bins)]
for node in ordered:
idx = int(_clamp(math.floor(rec["rec"].get(str(node.get("id", "")), 0.0) * n_bins), 0, n_bins - 1))
b = buckets[idx]
b.nodes.append(node)
if node.get("kind") == "memory":
b.memories += 1
else:
b.skills += 1
return buckets
chosen: Optional[list[_ChartBucket]] = None
for granularity in ("day", "month", "year"):
groups: dict[tuple[int, ...], _ChartBucket] = {}
for node in nodes:
ts = _to_ts(node.get("timestamp"))
if ts is None:
continue
key = _period_key(ts, granularity)
bucket = groups.get(key)
if bucket is None:
bucket = _ChartBucket(_period_label(ts, granularity), ts)
groups[key] = bucket
bucket.nodes.append(node)
if node.get("kind") == "memory":
bucket.memories += 1
else:
bucket.skills += 1
# For short spans, keep the useful day-by-day graph even when the caller
# asked for fewer rows; terminal scrollback is better than collapsing a
# month of activity into one unreadable bar.
if len(groups) <= max_rows or (granularity == "day" and len(groups) <= 32):
chosen = [groups[key] for key in sorted(groups)]
break
if chosen is None:
# If even yearly buckets overflow, fall back to even time bins.
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
n_bins = max(1, max_rows)
chosen = []
for i in range(n_bins):
ts = min_ts + (i / max(1, n_bins - 1)) * (max_ts - min_ts) if min_ts and max_ts else float(i)
chosen.append(_ChartBucket(format_date(ts), ts))
for node in nodes:
r = rec["rec"].get(str(node.get("id", "")), 0.0)
idx = int(_clamp(math.floor(r * n_bins), 0, n_bins - 1))
b = chosen[idx]
b.nodes.append(node)
if node.get("kind") == "memory":
b.memories += 1
else:
b.skills += 1
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
span = (max_ts - min_ts) if min_ts is not None and max_ts is not None and max_ts > min_ts else 0
for bucket in chosen:
bucket.rec = LEAD_IN + (1 - LEAD_IN) * ((bucket.ts - min_ts) / span) if span else 1.0
return chosen
def _bucket_label_node(bucket: _ChartBucket) -> Optional[dict[str, Any]]:
if not bucket.nodes:
return None
return max(bucket.nodes, key=lambda node: _node_score(node, _to_ts(node.get("timestamp")) or bucket.ts))
def _bucket_nodes(bucket: _ChartBucket, memory_lookup: Optional[dict[str, dict[str, Any]]] = None) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
# Chronological within the slice so the TUI tree reads oldest → newest.
ordered = sorted(bucket.nodes, key=lambda n: _to_ts(n.get("timestamp")) or bucket.ts)
for node in ordered:
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
raw_label = str(node.get("label") or node.get("id") or "unknown").strip()
memory = (memory_lookup or {}).get(str(node.get("id", "")))
out.append(
{
"id": str(node.get("id", "")),
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
"label": _node_label(node),
"fullLabel": raw_label,
"meta": _node_meta(node),
"body": str(memory.get("body", "")) if memory else "",
"style": style,
}
)
return out
def _bucket_rows(buckets: list[_ChartBucket], payload: dict[str, Any]) -> list[dict[str, Any]]:
cmap = category_color_map(payload)
memory_lookup = {
f"memory:{card.get('source')}:{idx}": card
for idx, card in enumerate(payload.get("memory", []) or [])
if isinstance(card, dict)
}
rows: list[dict[str, Any]] = []
for idx, bucket in enumerate(buckets):
cat = _bucket_category(bucket)
rows.append(
{
"index": idx,
"label": bucket.label,
"date": format_date(bucket.ts),
"skills": bucket.skills,
"memories": bucket.memories,
"total": bucket.total,
"category": cat,
"color": cmap.get(cat) if cat else None,
"nodes": _bucket_nodes(bucket, memory_lookup),
}
)
return rows
def _category_counts(payload: dict[str, Any]) -> list[tuple[str, int]]:
clusters = [
(str(c.get("category")), int(c.get("count", 0)))
for c in payload.get("clusters", []) or []
if c.get("category") and c.get("category") != "memory"
]
if clusters:
return clusters
counts: dict[str, int] = {}
for node in payload.get("nodes", []):
if node.get("kind") == "memory":
continue
cat = str(node.get("category") or "skill")
counts[cat] = counts.get(cat, 0) + 1
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
def category_color_map(payload: dict[str, Any]) -> dict[str, str]:
"""Deterministic, evenly-spread hue per skill category (theme-independent)."""
clusters = _category_counts(payload)
n = max(1, len(clusters))
# Golden-angle hue spacing so adjacent categories never collide in color.
return {cat: rgb_to_hex(_hsl_to_rgb((i * 137.508) % 360, 0.55, 0.62)) for i, (cat, _c) in enumerate(clusters)}
def category_legend(payload: dict[str, Any], limit: int = 4) -> list[dict[str, Any]]:
cmap = category_color_map(payload)
cats = _category_counts(payload)
shown = cats[:limit]
hidden = max(0, len(cats) - len(shown))
return [
{"glyph": "", "color": cmap.get(cat, ""), "label": f"{cat} ({count})"}
for cat, count in shown
] + ([{"glyph": "·", "color": "", "label": f"+{hidden}"}] if hidden else [])
def _bucket_category(bucket: _ChartBucket) -> Optional[str]:
counts: dict[str, int] = {}
for node in bucket.nodes:
if node.get("kind") == "memory":
continue
cat = str(node.get("category") or "skill")
counts[cat] = counts.get(cat, 0) + 1
return max(counts, key=lambda k: counts[k]) if counts else None
def _trajectory_row(buckets: list[_ChartBucket], width: int, reveal: float) -> Row:
"""Cumulative learning curve as a compact star-path sparkline."""
if not buckets:
return []
total = sum(b.total for b in buckets) or 1
visible = int(_clamp(math.ceil(reveal * len(buckets)), 0, len(buckets)))
acc = 0
points: list[int] = []
for b in buckets[:visible]:
acc += b.total
points.append(round((acc / total) * (width - 1)))
cells = [" "] * width
last = 0
for p in points:
for x in range(min(last, p), max(last, p) + 1):
if 0 <= x < width and cells[x] == " ":
cells[x] = "·"
if 0 <= p < width:
cells[p] = ""
last = p
return [["trajectory ", STYLE_LABEL, 0.55], ["".join(cells), STYLE_SKILL, 0.48]]
def render_graph(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, reveal: float = 1.0) -> dict[str, Any]:
"""Render one timeline frame at ``reveal`` (0→1).
Date rows with proportional skill/memory bars colored by the day's dominant
category, numbered markers tied to label rows, and a cumulative trajectory
sparkline underneath.
"""
reveal = _clamp(reveal, 0.0, 1.0)
cols = max(44, cols)
rows = max(14, rows)
nodes = list(payload.get("nodes", []))
if not nodes:
placeholder = [["no learning yet — keep using Hermes and it maps out here", STYLE_DIM, 0.7]]
return {"grid": [placeholder], "date": "", "reveal": reveal, "visible": 0}
rec = compute_recency(nodes)
cmap = category_color_map(payload)
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3))
n_buckets = len(buckets)
visible_bucket_count = int(_clamp(math.ceil(reveal * n_buckets), 0, n_buckets))
max_total = max((b.total for b in buckets), default=1) or 1
label_w = min(9, max(len(b.label) for b in buckets))
bar_w = max(14, cols - label_w - 16)
grid: Grid = []
labels: list[dict[str, Any]] = []
visible = 0
for i, bucket in enumerate(buckets):
if i >= visible_bucket_count:
grid.append([])
continue
visible += bucket.total
ink = recency_ink(bucket.rec)
bar_len = max(1, round((bucket.total / max_total) * bar_w)) if bucket.total else 0
skill_len = round((bucket.skills / bucket.total) * bar_len) if bucket.total else 0
if bucket.skills and skill_len == 0:
skill_len = 1
memory_len = bar_len - skill_len
if bucket.memories and memory_len == 0 and bar_len > 1:
memory_len = 1
skill_len = bar_len - 1
node = _bucket_label_node(bucket)
marker = ""
if node and len(labels) < 6:
marker = _LABEL_KEYS[len(labels)]
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
labels.append(
{
"key": marker,
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
"label": _node_label(node),
"meta": _node_meta(node),
"style": style,
"alpha": round(ink, 3),
}
)
cat = _bucket_category(bucket)
cat_hex = cmap.get(cat) if cat else None
row: Row = [[f"{bucket.label:>{label_w}} ", STYLE_LABEL, ink], ["", STYLE_DIM, 0.55]]
if marker:
row.append([marker, STYLE_LABEL, 0.95])
elif bucket.total:
head_hex = cat_hex if bucket.skills else None
row.append(["" if bucket.skills else "", STYLE_SKILL if bucket.skills else STYLE_MEMORY, ink, head_hex])
if skill_len:
# Bar colored by the day's dominant category — a learning heatmap.
row.append(["" * skill_len, STYLE_SKILL, ink, cat_hex])
if memory_len:
if memory_len == 1:
mem_trail = ""
else:
mem_trail = "" + ("" * (memory_len - 2)) + ""
row.append([mem_trail, STYLE_MEMORY, max(0.65, ink)])
if bar_len < bar_w:
# Empty space keeps counts aligned; starmap texture lives in the
# trajectory row below, where it reads as signal rather than noise.
row.append([" " * (bar_w - bar_len), STYLE_BG, 1.0])
row.append([" ", STYLE_BG, 1.0])
row.append([str(bucket.skills), STYLE_SKILL, max(0.72, ink)])
if bucket.memories:
row.append(["+", STYLE_DIM, 0.6])
row.append([str(bucket.memories), STYLE_MEMORY, max(0.72, ink)])
if i == visible_bucket_count - 1:
row.append([" ◀ now", STYLE_LABEL, 0.9])
elif bucket.total == max_total and max_total > 1:
row.append([" ☄ peak", STYLE_LABEL, 0.75])
grid.append(row)
# Cumulative learning trajectory underneath the rows.
grid.append([[(" " * (label_w + 2)), STYLE_BG, 1.0], *_trajectory_row(buckets, max(12, cols - label_w - 13), reveal)])
return {
"grid": grid,
"date": format_date(_date_at(rec, reveal)),
"reveal": reveal,
"visible": visible,
"labels": labels,
}
# ── Trimmings ──────────────────────────────────────────────────────────────
def build_legend(payload: dict[str, Any]) -> list[dict[str, Any]]:
nodes = payload.get("nodes", [])
skills = sum(1 for n in nodes if n.get("kind") != "memory")
memories = sum(1 for n in nodes if n.get("kind") == "memory")
return [
{"glyph": SKILL_GLYPH, "style": STYLE_SKILL, "label": f"skills ({skills})"},
{"glyph": MEMORY_GLYPH, "style": STYLE_MEMORY, "label": f"memories ({memories})"},
]
def axis_labels(payload: dict[str, Any]) -> dict[str, str]:
rec = compute_recency(list(payload.get("nodes", [])))
if not rec["timed"]:
return {"start": "oldest", "end": "now"}
return {"start": format_date(rec.get("minTs")), "end": format_date(rec.get("maxTs"))}
def _peak_day(payload: dict[str, Any]) -> Optional[str]:
counts: dict[tuple[int, ...], int] = {}
reps: dict[tuple[int, ...], float] = {}
for node in payload.get("nodes", []):
ts = _to_ts(node.get("timestamp"))
if ts is None:
continue
key = _period_key(ts, "day")
counts[key] = counts.get(key, 0) + 1
reps[key] = ts
if not counts:
return None
best = max(counts, key=lambda k: counts[k])
return f"busiest day {_period_label(reps[best], 'day')} · {counts[best]} learned"
def build_summary(payload: dict[str, Any]) -> list[str]:
stats = payload.get("stats", {}) or {}
lines: list[str] = []
learned = stats.get("learned_skills", stats.get("nodes", 0))
mem = stats.get("memory_nodes", 0)
edges = stats.get("related_edges", 0)
lines.append(f"{learned} learned skills · {mem} memories · {edges} skill links")
extra = []
if stats.get("memory_skill_edges"):
extra.append(f"{stats['memory_skill_edges']} memory↔skill links")
peak = _peak_day(payload)
if peak:
extra.append(peak)
if extra:
lines.append(" · ".join(extra))
return lines
def _merge_runs(cells: Iterable[Run]) -> Row:
out: Row = []
for run in cells:
text, style, alpha = run[0], run[1], (run[2] if len(run) > 2 else 1.0)
hex_override = run[3] if len(run) > 3 else None
prev_hex = out[-1][3] if out and len(out[-1]) > 3 else None
if out and out[-1][1] == style and abs(out[-1][2] - alpha) < 1e-6 and prev_hex == hex_override:
out[-1][0] += text
else:
merged: Run = [text, style, alpha]
if hex_override:
merged.append(hex_override)
out.append(merged)
return out
def render_frames(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, frames: int = 48) -> dict[str, Any]:
"""Pre-render a full play-through (reveal 0→1) plus static legend/summary."""
frames = max(2, min(frames, 240))
nodes = list(payload.get("nodes", []))
rec = compute_recency(nodes)
# Mirror render_graph's bucketing so the interactive row list lines up with
# what the user sees.
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3)) if nodes else []
out_frames = []
for i in range(frames):
reveal = i / (frames - 1)
frame = render_graph(payload, cols=cols, rows=rows, reveal=reveal)
out_frames.append(
{
"reveal": frame["reveal"],
"date": frame["date"],
"visible": frame["visible"],
"grid": frame["grid"],
"labels": frame.get("labels", []),
}
)
return {
"frames": out_frames,
"legend": build_legend(payload),
"categories": category_legend(payload),
"buckets": _bucket_rows(buckets, payload),
"summary": build_summary(payload),
"axis": axis_labels(payload),
"count": len(payload.get("nodes", [])),
"cols": cols,
"rows": rows,
}
-206
View File
@@ -1,206 +0,0 @@
"""User-initiated edit/delete for journey nodes (learned skills + memories).
The journey graph (``agent.learning_graph``) gives every node a stable id:
- **skills** the skill name (e.g. ``"debugging-hermes-desktop"``)
- **memories** ``memory:<source>:<index>`` where ``source`` is ``memory``
(``MEMORY.md``) or ``profile`` (``USER.md``) and ``index`` is the node's
position in the combined card list (``MEMORY.md`` cards first, then
``USER.md``).
This module maps a node id back to its on-disk home and performs the mutation,
shared by the CLI (``hermes journey delete|edit``), the TUI ``/journey`` overlay
(gateway RPCs), and the desktop GUI (REST). Deleting a skill *archives* it
(recoverable via ``hermes curator restore``); deleting a memory rewrites its
file. Pure stdlib + existing skill/memory helpers.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
_MEMORY_FILES = {"memory": "MEMORY.md", "profile": "USER.md"}
def parse_node_kind(node_id: str) -> str:
return "memory" if node_id.startswith("memory:") else "skill"
def _memories_dir() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "memories"
def _parse_memory_id(node_id: str) -> tuple[str, int]:
"""``memory:<source>:<index>`` → (source, global_index)."""
parts = node_id.split(":", 2)
if len(parts) != 3 or parts[0] != "memory" or parts[1] not in _MEMORY_FILES:
raise ValueError(f"bad memory node id: {node_id!r}")
try:
return parts[1], int(parts[2])
except ValueError as exc:
raise ValueError(f"bad memory node id: {node_id!r}") from exc
def _memory_local_index(source: str, global_index: int) -> int:
"""Global card index → position within the source's own file.
``_memory_cards`` emits all ``MEMORY.md`` cards before ``USER.md`` cards, so
a profile card's local index is its global index minus the memory count.
"""
from agent.learning_graph import _memory_cards
cards = _memory_cards()
if not 0 <= global_index < len(cards):
raise IndexError(f"memory index {global_index} out of range")
if cards[global_index].get("source") != source:
raise ValueError("memory node id is stale — refresh the graph")
if source == "memory":
return global_index
return global_index - sum(1 for c in cards if c.get("source") == "memory")
def _locate_memory(source: str, gidx: int) -> tuple[Path, list[str], int]:
"""Resolve a memory card to its file, all §-delimited entries, and local index.
Entries come from ``MemoryStore._read_file`` the same parser the memory
tool uses so journey indices stay aligned with what the graph renders.
"""
from tools.memory_tool import MemoryStore
path = _memories_dir() / _MEMORY_FILES[source]
if not path.exists():
raise ValueError(f"{path.name} not found")
chunks = MemoryStore._read_file(path)
local = _memory_local_index(source, gidx)
if not 0 <= local < len(chunks):
raise ValueError("memory node id is stale — refresh the graph")
return path, chunks, local
# ── Inspect (edit prefill) ──────────────────────────────────────────────────
def node_detail(node_id: str) -> dict[str, Any]:
"""Current content for an edit prefill. ``content`` is the full SKILL.md
(skills) or the raw memory chunk (memories)."""
try:
return _node_detail(node_id)
except (ValueError, IndexError) as exc:
return {"ok": False, "message": str(exc)}
def _node_detail(node_id: str) -> dict[str, Any]:
if parse_node_kind(node_id) == "memory":
source, gidx = _parse_memory_id(node_id)
_, chunks, local = _locate_memory(source, gidx)
body = chunks[local].strip()
return {"ok": True, "kind": "memory", "id": node_id, "label": body.splitlines()[0][:80], "content": body}
from tools.skill_manager_tool import _find_skill
found = _find_skill(node_id)
if not found:
return {"ok": False, "message": f"skill '{node_id}' not found"}
skill_md = Path(found["path"]) / "SKILL.md"
if not skill_md.exists():
return {"ok": False, "message": f"SKILL.md missing for '{node_id}'"}
return {
"ok": True,
"kind": "skill",
"id": node_id,
"label": node_id,
"content": skill_md.read_text(encoding="utf-8"),
}
# ── Delete ──────────────────────────────────────────────────────────────────
def delete_node(node_id: str) -> dict[str, Any]:
try:
return _delete_memory(node_id) if parse_node_kind(node_id) == "memory" else _delete_skill(node_id)
except (ValueError, IndexError) as exc:
return {"ok": False, "message": str(exc)}
def _delete_skill(name: str) -> dict[str, Any]:
from tools import skill_usage
if skill_usage.get_record(name).get("pinned"):
return {"ok": False, "message": f"'{name}' is pinned — unpin it first (hermes curator unpin {name})"}
ok, message = skill_usage.archive_skill(name)
if ok:
_clear_skill_cache()
return {"ok": ok, "message": f"archived '{name}' — restore with: hermes curator restore {name}" if ok else message}
def _delete_memory(node_id: str) -> dict[str, Any]:
source, gidx = _parse_memory_id(node_id)
path, chunks, local = _locate_memory(source, gidx)
del chunks[local]
_write_memory(path, chunks)
return {"ok": True, "message": f"deleted memory from {path.name}"}
# ── Edit ────────────────────────────────────────────────────────────────────
def edit_node(node_id: str, content: str) -> dict[str, Any]:
try:
return _edit_memory(node_id, content) if parse_node_kind(node_id) == "memory" else _edit_skill(node_id, content)
except (ValueError, IndexError) as exc:
return {"ok": False, "message": str(exc)}
def _edit_skill(name: str, content: str) -> dict[str, Any]:
from tools.skill_manager_tool import _edit_skill as _do_edit
result = _do_edit(name, content)
if result.get("success"):
_clear_skill_cache()
return {"ok": True, "message": f"updated '{name}'"}
return {"ok": False, "message": result.get("error", "edit failed")}
def _edit_memory(node_id: str, content: str) -> dict[str, Any]:
source, gidx = _parse_memory_id(node_id)
body = content.strip()
if not body:
return {"ok": False, "message": "empty memory — use delete to remove it"}
path, chunks, local = _locate_memory(source, gidx)
chunks[local] = body
_write_memory(path, chunks)
return {"ok": True, "message": f"updated memory in {path.name}"}
# ── Helpers ─────────────────────────────────────────────────────────────────
def _write_memory(path: Path, chunks: list[str]) -> None:
"""Atomic temp-file + rename via the memory tool, so a concurrent reader
never sees a half-written file (and the §-join stays single-sourced)."""
from tools.memory_tool import MemoryStore
MemoryStore._write_file(path, [c.strip() for c in chunks if c.strip()])
def _clear_skill_cache() -> None:
try:
from agent.prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache(clear_snapshot=True)
except Exception:
pass
-12
View File
@@ -20,17 +20,6 @@ _LM_VALID_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
# Map them onto the OpenAI-compatible request vocabulary.
_LM_EFFORT_ALIASES = {"off": "none", "on": "medium"}
# Hermes' generic effort ladder grew past LM Studio's vocabulary ("max",
# "ultra"). Clamp the stronger generic levels onto LM Studio's ceiling: left
# alone they miss _LM_VALID_EFFORTS, keep the initialized "medium" default and
# are thereby conflated with unparseable input, so asking for more reasoning
# yields less than "xhigh". Mirrors the ceiling clamp every other provider
# applies (see agent/transports/codex.py).
#
# Deliberately separate from _LM_EFFORT_ALIASES: that mapping is also applied
# to the model's published allowed_options, which must not be rewritten.
_LM_EFFORT_CLAMP = {"max": "xhigh", "ultra": "xhigh"}
def resolve_lmstudio_effort(
reasoning_config: Optional[dict],
@@ -50,7 +39,6 @@ def resolve_lmstudio_effort(
else:
raw = (reasoning_config.get("effort") or "").strip().lower()
raw = _LM_EFFORT_ALIASES.get(raw, raw)
raw = _LM_EFFORT_CLAMP.get(raw, raw)
if raw in _LM_VALID_EFFORTS:
effort = raw
if allowed_options:
+71 -148
View File
@@ -18,15 +18,9 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
Implementation notes:
- All per-document state lives in one :class:`_DocState` keyed by
absolute path. Freshness is tracked with **document versions**,
not timestamps: every didChange bumps ``version``, and each stored
push/pull result is tagged with the version it describes. A
result is fresh iff its tag >= the version being waited on, so a
didChange implicitly invalidates everything older no clearing,
no clock comparisons, no race windows. This is what prevents
"ghost diagnostics": a slow server's leftovers from the previous
edit can never masquerade as a verdict on the current content.
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
- Whole-document sync. Even when the server advertises incremental
sync, we send a single ``contentChanges`` entry replacing the
@@ -51,7 +45,6 @@ import asyncio
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote
@@ -131,40 +124,6 @@ def _end_position(text: str) -> Dict[str, int]:
return {"line": last_line, "character": last_col}
@dataclass
class _DocState:
"""Everything the client tracks for one open document.
``version`` is the LSP document version we last sent (didOpen=0,
each didChange +1). It doubles as the freshness token: stored
push/pull results are tagged with the version they describe
(``push_version`` / ``pull_version``), and a result is *fresh*
iff its tag has caught up to ``version``. Bumping the version on
didChange therefore invalidates all older results implicitly
no store-clearing, no timestamps.
``push_version``/``pull_version`` start at -1 = "no data yet".
Servers that echo a document version in publishDiagnostics get
exact tagging; those that don't are credited with the current
version at receipt time (a push observed after we sent the
change describes the changed content or newer).
"""
version: int = 0
text: str = ""
push: List[Dict[str, Any]] = field(default_factory=list)
pull: List[Dict[str, Any]] = field(default_factory=list)
push_version: int = -1
pull_version: int = -1
seed_seen: bool = False
def fresh_push(self, version: Optional[int] = None) -> bool:
return self.push_version >= (self.version if version is None else version)
def fresh_pull(self, version: Optional[int] = None) -> bool:
return self.pull_version >= (self.version if version is None else version)
class LSPClient:
"""Async LSP client tied to one server process and one workspace root.
@@ -227,10 +186,18 @@ class LSPClient:
# is silently dropped by default.
}
# Per-document state (version, text, diagnostic stores, and
# their freshness tags), keyed by absolute file path (NOT URI).
# See _DocState for the version-based freshness model.
self._docs: Dict[str, _DocState] = {}
# Tracked file state — required for didChange version bumps.
self._files: Dict[str, Dict[str, Any]] = {}
# Diagnostic stores, keyed by file path (NOT URI).
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
# Per-path "last published" time so wait-for-fresh logic works.
self._published: Dict[str, float] = {}
# Per-path version of the latest push (matches our didChange
# version when the server respects it).
self._published_version: Dict[str, int] = {}
# First-push seen flag, for typescript-style seed-on-first-push.
self._first_push_seen: Set[str] = set()
# Capability registrations — only diagnostic ones are tracked.
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
@@ -296,13 +263,6 @@ class LSPClient:
cmd = self._win_wrap_cmd(cmd)
try:
# start_new_session=True detaches the LSP server into its own
# process group / session. Without this, the LSP server inherits
# the gateway's pgid (= TUI parent PID). When mcp_tool's
# _kill_orphaned_mcp_children races with LSP spawn and sweeps the
# gateway's child set, it captures the LSP PID, records the
# inherited pgid, and killpg() then kills the TUI parent itself.
# See tui_gateway_crash.log "killpg → SIGTERM received" stacks.
self._proc = await asyncio.create_subprocess_exec(
cmd[0],
*cmd[1:],
@@ -311,7 +271,6 @@ class LSPClient:
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=self._cwd,
start_new_session=True,
)
except FileNotFoundError as e:
raise LSPProtocolError(
@@ -680,25 +639,25 @@ class LSPClient:
if not isinstance(diagnostics, list):
diagnostics = []
version = params.get("version")
loop_time = asyncio.get_event_loop().time()
doc = self._docs.setdefault(path, _DocState(version=-1))
if self._seed_first_push and not doc.seed_seen:
# First push: seed the store WITHOUT a freshness tag. It
# arrives before the user-triggered didChange could've
# produced fresh diagnostics, so it must never satisfy a
# waiter — it's baseline data only.
doc.seed_seen = True
doc.push = diagnostics
if self._seed_first_push and path not in self._first_push_seen:
# First push: seed without firing the event so a waiter
# doesn't resolve on the very first push (which arrives
# before the user-triggered didChange could've produced
# fresh diagnostics).
self._first_push_seen.add(path)
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
return
doc.seed_seen = True
doc.push = diagnostics
# Tag with the echoed document version when the server provides
# one; otherwise credit the current version — a push observed
# after we sent the change describes the changed content (or
# newer). Note doc.version is -1 for never-opened paths
# (e.g. relatedDocuments spillover), keeping them unfresh.
doc.push_version = version if isinstance(version, int) else doc.version
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
self._first_push_seen.add(path)
# Bump the monotonic push counter and wake every waiter. We
# keep the Event sticky-set so any wait already in progress
# resolves; waiters re-check their predicate after waking and
@@ -727,16 +686,16 @@ class LSPClient:
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
uri = file_uri(abs_path)
doc = self._docs.get(abs_path)
existing = self._files.get(abs_path)
if doc is not None and doc.version >= 0:
if existing is not None:
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
await self._send_notification(
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
)
new_version = doc.version + 1
old_text = doc.text
new_version = existing["version"] + 1
old_text = existing["text"]
content_changes: List[Dict[str, Any]]
if self._sync_kind == 2:
content_changes = [
@@ -757,11 +716,7 @@ class LSPClient:
"contentChanges": content_changes,
},
)
# Bumping the version is the whole invalidation story:
# every stored result tagged with an older version is now
# stale by definition (see _DocState).
doc.version = new_version
doc.text = text
self._files[abs_path] = {"version": new_version, "text": text}
return new_version
# First open: didChangeWatchedFiles CREATED + didOpen.
@@ -769,9 +724,12 @@ class LSPClient:
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
)
# Fresh doc state — anything stashed under this path by a
# pre-open push (relatedDocuments spillover etc.) is discarded.
self._docs[abs_path] = _DocState(version=0, text=text)
# Clear any stale push/pull entries — fresh open should start
# from scratch.
self._push_diagnostics.pop(abs_path, None)
self._pull_diagnostics.pop(abs_path, None)
self._published.pop(abs_path, None)
self._published_version.pop(abs_path, None)
await self._send_notification(
"textDocument/didOpen",
{
@@ -783,6 +741,7 @@ class LSPClient:
}
},
)
self._files[abs_path] = {"version": 0, "text": text}
return 0
async def save_file(self, path: str) -> None:
@@ -802,19 +761,12 @@ class LSPClient:
async def _pull_document_diagnostics(self, path: str) -> None:
"""Send ``textDocument/diagnostic`` for one file.
Stores results into the doc's pull store, tagged with the
document version captured at request send time. If a didChange
races past the in-flight request, the version bump makes the
stored result stale automatically no explicit invalidation.
Silently no-ops on errors (server may not support the pull
endpoint).
Stores results into :attr:`_pull_diagnostics`. Silently
no-ops on errors (server may not support the pull endpoint).
"""
abs_path = os.path.abspath(path)
doc = self._docs.get(abs_path)
sent_version = doc.version if doc else -1
try:
params: Dict[str, Any] = {
"textDocument": {"uri": file_uri(abs_path)}
"textDocument": {"uri": file_uri(os.path.abspath(path))}
}
result = await self._send_request_with_retry(
"textDocument/diagnostic",
@@ -828,9 +780,7 @@ class LSPClient:
return
items = result.get("items")
if isinstance(items, list):
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
doc.pull = items
doc.pull_version = sent_version
self._pull_diagnostics[os.path.abspath(path)] = items
related = result.get("relatedDocuments")
if isinstance(related, dict):
for uri, sub in related.items():
@@ -838,11 +788,7 @@ class LSPClient:
continue
sub_items = sub.get("items")
if isinstance(sub_items, list):
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
rel.pull = sub_items
# Same send-anchored tagging: fresh only if that
# doc hasn't changed since the request went out.
rel.pull_version = rel.version
self._pull_diagnostics[uri_to_path(uri)] = sub_items
async def wait_for_diagnostics(
self,
@@ -850,36 +796,22 @@ class LSPClient:
version: int,
*,
mode: str = "document",
timeout: Optional[float] = None,
) -> bool:
) -> None:
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
``mode`` is ``"document"`` (5s budget, document pulls) or
``"full"`` (10s budget, also workspace pulls). ``timeout``
overrides the mode's default budget when provided — this is
how the user's ``lsp.wait_timeout`` config reaches the wait
loop (slow servers like tsserver on big projects need more
than the 5s default).
Returns ``True`` when *fresh* diagnostics arrived (a push at
or after our didChange, or a pull answered after it) and
``False`` on timeout. Callers must treat ``False`` as "no
data", NOT as "no errors" — the diagnostic stores may still
hold stale entries from the previous edit at that point.
Best-effort never throws if the server doesn't support pull
diagnostics; we still get the push side.
``"full"`` (10s budget, also workspace pulls). Best-effort
returns silently on timeout. Does NOT throw if the server
doesn't support pull diagnostics; we still get the push side.
"""
if timeout is not None and timeout > 0:
budget = timeout
else:
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
deadline = asyncio.get_event_loop().time() + budget
abs_path = os.path.abspath(path)
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return False
return
# Concurrent: document pull + push wait.
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
@@ -898,24 +830,26 @@ class LSPClient:
pass
# If we got a fresh push for our version, we're done.
doc = self._docs.get(abs_path)
if doc and doc.fresh_push(version):
return True
current_v = self._published_version.get(abs_path)
if abs_path in self._published and (
current_v is None or current_v >= version
):
return
# Pull may have answered for the current version — that's
# also success.
if doc and doc.fresh_pull(version):
return True
# Pull may have populated _pull_diagnostics — that's also
# success.
if abs_path in self._pull_diagnostics:
return
# Loop until budget runs out.
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
deadline = asyncio.get_event_loop().time() + timeout
baseline = self._push_counter
while True:
doc = self._docs.get(path)
if doc and doc.fresh_push(version):
current_v = self._published_version.get(path)
if path in self._published and (current_v is None or current_v >= version):
# Debounce — wait a tick in case more diagnostics arrive
# immediately after. TS often emits in pairs. We
# snapshot the counter so we wake on a *new* push, not
@@ -946,28 +880,17 @@ class LSPClient:
except asyncio.TimeoutError:
continue
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
"""Return current merged + deduped diagnostics for one file.
Diagnostics from push and pull stores are concatenated and
deduplicated by ``(severity, code, message, range)`` content
key. Empty list if the server hasn't published anything.
With ``fresh_only=True``, a store only contributes when its
version tag has caught up to the document's current version —
stale leftovers from the previous edit cycle are excluded.
This is what report paths should use: after an edit, "stale
errors" and "no errors" must not be conflated.
"""
doc = self._docs.get(os.path.abspath(path))
if doc is None:
return []
if fresh_only:
return _dedupe(
doc.push if doc.fresh_push() else [],
doc.pull if doc.fresh_pull() else [],
)
return _dedupe(doc.push, doc.pull)
abs_path = os.path.abspath(path)
push = self._push_diagnostics.get(abs_path) or []
pull = self._pull_diagnostics.get(abs_path) or []
return _dedupe(push, pull)
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+7 -10
View File
@@ -102,11 +102,6 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
# Lua — manual (LuaLS is platform-specific binaries from GitHub
# releases; complex enough that we punt to the user)
"lua-language-server": {"strategy": "manual", "pkg": "", "bin": "lua-language-server"},
# PowerShell — PowerShellEditorServices ships as a GitHub release
# zip driven by a pwsh bootstrap script, not a single binary. We
# require a manual bundle install and probe for the pwsh host so
# `hermes lsp status` reports the host's presence.
"powershell": {"strategy": "manual", "pkg": "", "bin": "pwsh"},
}
@@ -348,15 +343,17 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
pip_target.mkdir(parents=True, exist_ok=True)
try:
logger.info("[install] pip install --target %s %s", pip_target, pkg)
from hermes_cli.tools_config import _pip_install
proc = _pip_install(
["--target", str(pip_target), "--quiet", pkg],
proc = subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
check=False,
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
)
return None
except (subprocess.TimeoutExpired, OSError) as e:
+10 -41
View File
@@ -292,10 +292,7 @@ class LSPService:
if not self.enabled_for(file_path):
return
try:
# Outer join budget must exceed the inner wait budget or a
# slow-but-alive server gets falsely marked broken.
t = max(8.0, self._wait_timeout + 3.0)
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
self._delta_baseline[os.path.abspath(file_path)] = diags or []
except Exception as e: # noqa: BLE001
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
@@ -344,7 +341,7 @@ class LSPService:
try:
t = timeout if timeout is not None else self._wait_timeout + 2.0
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
except asyncio.TimeoutError as e:
eventlog.log_timeout(server_id, file_path)
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
@@ -356,17 +353,6 @@ class LSPService:
self._mark_broken_for_file(file_path, e)
return []
if diags is None:
# The server is alive but never produced diagnostics for the
# post-edit content within the wait budget (common for
# tsserver on large projects). Report "no data" rather than
# whatever stale state is in the stores — surfacing the
# previous edit's errors as if they were current is the
# ghost-diagnostics bug. The server is NOT marked broken:
# slow is not dead, and the next edit may well succeed.
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
return []
abs_path = os.path.abspath(file_path)
if delta:
baseline = self._delta_baseline.get(abs_path) or []
@@ -466,43 +452,26 @@ class LSPService:
return []
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
except Exception as e: # noqa: BLE001
logger.debug("snapshot open/wait failed: %s", e)
return []
self._last_used[(client.server_id, client.workspace_root)] = time.time()
if not fresh:
# No fresh data for the pre-edit content — an empty baseline
# is safe: worst case the delta filter removes less, never
# more. Never seed the baseline from stale stores.
return []
return list(client.diagnostics_for(file_path, fresh_only=True))
return list(client.diagnostics_for(file_path))
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
"""Open + wait for FRESH diagnostics.
Returns the fresh diagnostic list, or ``None`` when the server
never produced post-change data within the wait budget. The
distinction matters: ``[]`` means "server checked the new
content, it's clean", ``None`` means "no verdict" — the caller
must not substitute stale data for either.
"""
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
client = await self._get_or_spawn(file_path)
if client is None:
return None
return []
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.save_file(file_path)
fresh = await client.wait_for_diagnostics(
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
)
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
except Exception as e: # noqa: BLE001
logger.debug("open/wait failed for %s: %s", file_path, e)
return None
return []
self._last_used[(client.server_id, client.workspace_root)] = time.time()
if not fresh:
return None
return list(client.diagnostics_for(file_path, fresh_only=True))
return list(client.diagnostics_for(file_path))
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
ws, gated = resolve_workspace_for_file(file_path)
@@ -513,7 +482,7 @@ class LSPService:
client = self._clients.get((srv.server_id, ws))
if client is None:
return []
return list(client.diagnostics_for(file_path, fresh_only=True))
return list(client.diagnostics_for(file_path))
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
srv = find_server_for_file(file_path)
+1 -1
View File
@@ -91,7 +91,7 @@ async def read_message(reader: asyncio.StreamReader) -> Optional[dict]:
header_bytes += len(line)
if header_bytes > 8192:
raise LSPProtocolError(
"LSP header block exceeded 8 KiB without terminator"
f"LSP header block exceeded 8 KiB without terminator"
)
line = line[:-2] # strip CRLF
if not line:
+6 -58
View File
@@ -8,7 +8,6 @@ OpenCode's ``lsp/diagnostic.ts`` and Claude Code's
"""
from __future__ import annotations
import html
from typing import Any, Dict, List
# Severity-1 only by default — warnings/info/hints would flood the
@@ -19,65 +18,18 @@ DEFAULT_SEVERITIES = frozenset({1}) # ERROR only
MAX_PER_FILE = 20
MAX_TOTAL_CHARS = 4000
# Per-field caps for diagnostic content sourced from the language server.
# These bound the length of any single attacker-controlled identifier that
# can ride into the model's tool output via an LSP diagnostic message.
MAX_MESSAGE_CHARS = 300
MAX_CODE_CHARS = 80
MAX_SOURCE_CHARS = 80
def _sanitize_field(value: Any, *, limit: int) -> str:
"""Make a language-server field safe to embed in a tool-result block.
Diagnostic ``message``, ``code``, and ``source`` originate from a
language server that has just parsed user-controlled source code, so
they're untrusted from the agent's point of view. A hostile repo can
place instruction-shaped text inside identifier names, type aliases,
or import paths so the resulting diagnostic echoes that text back
into the ``<diagnostics>`` block the model reads.
This helper:
* Collapses CR/LF so a raw newline can't synthesize a new line in the
formatted block.
* Drops non-printable ASCII control characters that have no business
in a single-line summary.
* Caps length per-field so a long identifier can't push past the
block boundary.
* HTML-escapes ``< > &`` so the result can't close ``<diagnostics>``
early or open a new tag.
Returns ``""`` for ``None`` / empty so the surrounding format string
naturally omits the part (mirrors the prior ``if code not in {None,
""}`` check at call sites).
"""
if value is None:
return ""
raw = str(value)
# Collapse newlines so identifier text with raw \n can't fake new lines.
raw = raw.replace("\r", " ").replace("\n", " ")
# Drop ASCII control chars; keep regular spaces.
raw = "".join(ch for ch in raw if ch == " " or ch.isprintable())
raw = raw.strip()[:limit]
return html.escape(raw, quote=False)
def format_diagnostic(d: Dict[str, Any]) -> str:
"""One-line representation of a single diagnostic.
``message``, ``code``, and ``source`` are sanitized before
interpolation see ``_sanitize_field``.
"""
"""One-line representation of a single diagnostic."""
sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR")
rng = d.get("range") or {}
start = rng.get("start") or {}
line = int(start.get("line", 0)) + 1
col = int(start.get("character", 0)) + 1
msg = _sanitize_field(d.get("message"), limit=MAX_MESSAGE_CHARS)
code = _sanitize_field(d.get("code"), limit=MAX_CODE_CHARS)
code_part = f" [{code}]" if code else ""
source = _sanitize_field(d.get("source"), limit=MAX_SOURCE_CHARS)
msg = str(d.get("message") or "").rstrip()
code = d.get("code")
code_part = f" [{code}]" if code not in {None, ""} else ""
source = d.get("source")
source_part = f" ({source})" if source else ""
return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}"
@@ -105,11 +57,7 @@ def report_for_file(
body = "\n".join(lines)
if extra > 0:
body += f"\n... and {extra} more"
# quote=True escapes both ``"`` and ``&`` so a crafted file name like
# ``foo"><script`` can't break out of the ``file="..."`` attribute and
# synthesize new tags inside the tool output.
safe_path = html.escape(file_path, quote=True)
return f"<diagnostics file=\"{safe_path}\">\n{body}\n</diagnostics>"
return f"<diagnostics file=\"{file_path}\">\n{body}\n</diagnostics>"
def truncate(s: str, *, limit: int = MAX_TOTAL_CHARS) -> str:
-147
View File
@@ -102,9 +102,6 @@ LANGUAGE_BY_EXT: Dict[str, str] = {
".zig": "zig",
".zon": "zig",
".dockerfile": "dockerfile",
".ps1": "powershell",
".psm1": "powershell",
".psd1": "powershell",
}
@@ -679,131 +676,6 @@ def _spawn_astro(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
)
_PSES_BUNDLE_WARNED = False
def _find_pses_bundle(ctx: ServerContext) -> Optional[str]:
"""Locate the PowerShellEditorServices module bundle directory.
PSES ships as a GitHub release zip (not an npm/go/pip package), so
there's no auto-install recipe — the user downloads it and points us
at the extracted bundle. Resolution order:
1. ``command`` override in config (``lsp.servers.powershell.command``)
the FIRST element is treated as the bundle path when it's a
directory. This is the documented config knob.
2. ``init_overrides["powershell"]["bundlePath"]``.
3. ``PSES_BUNDLE_PATH`` env var.
4. ``<HERMES_HOME>/lsp/PowerShellEditorServices`` staging dir (where a
user-run unzip would naturally land).
Returns the bundle directory containing ``PowerShellEditorServices/``,
or ``None`` when it can't be found.
"""
candidates: List[str] = []
override = ctx.binary_overrides.get("powershell")
if override and override[0]:
candidates.append(override[0])
init = ctx.init_overrides.get("powershell", {})
if isinstance(init, dict) and init.get("bundlePath"):
candidates.append(str(init["bundlePath"]))
env_path = os.environ.get("PSES_BUNDLE_PATH")
if env_path:
candidates.append(env_path)
home = os.environ.get("HERMES_HOME") or os.path.join(
os.path.expanduser("~"), ".hermes"
)
candidates.append(os.path.join(home, "lsp", "PowerShellEditorServices"))
for cand in candidates:
if not cand:
continue
# Accept either the bundle root or the inner module dir.
start_script = os.path.join(
cand, "PowerShellEditorServices", "Start-EditorServices.ps1"
)
if os.path.isfile(start_script):
return cand
inner = os.path.join(cand, "Start-EditorServices.ps1")
if os.path.isfile(inner):
return os.path.dirname(cand)
return None
def _spawn_powershell_es(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
"""Spawn PowerShellEditorServices over stdio.
Unlike the single-binary servers, PSES is a PowerShell module driven
by a bootstrap script. We need both a PowerShell host (``pwsh`` for
PowerShell 7+, or Windows ``powershell``) and the PSES module bundle.
The bundle is manual-install (release zip) see ``_find_pses_bundle``.
"""
pwsh = _which("pwsh", "powershell")
if pwsh is None:
return None
bundle = _find_pses_bundle(ctx)
if bundle is None:
global _PSES_BUNDLE_WARNED
if not _PSES_BUNDLE_WARNED:
_PSES_BUNDLE_WARNED = True
logger.warning(
"powershell: pwsh found but the PowerShellEditorServices "
"bundle is missing. Download the release zip from "
"https://github.com/PowerShell/PowerShellEditorServices/releases, "
"extract it, and either set lsp.servers.powershell.command "
"to the bundle path or unzip it to "
"<HERMES_HOME>/lsp/PowerShellEditorServices."
)
return None
start_script = os.path.join(
bundle, "PowerShellEditorServices", "Start-EditorServices.ps1"
)
# Session details file: PSES writes connection info here on startup.
session_path = os.path.join(
hermes_lsp_session_dir(), f"pses-session-{os.getpid()}.json"
)
log_path = os.path.join(hermes_lsp_session_dir(), "pses.log")
inner = (
f"& '{start_script}' "
f"-BundledModulesPath '{bundle}' "
f"-LogPath '{log_path}' "
f"-SessionDetailsPath '{session_path}' "
f"-FeatureFlags @() -AdditionalModules @() "
f"-HostName Hermes -HostProfileId hermes -HostVersion 1.0.0 "
f"-Stdio -LogLevel Normal"
)
return SpawnSpec(
command=[
pwsh,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
inner,
],
workspace_root=root,
cwd=root,
env=ctx.env_overrides.get("powershell", {}),
initialization_options={
k: v
for k, v in ctx.init_overrides.get("powershell", {}).items()
if k != "bundlePath"
},
)
def hermes_lsp_session_dir() -> str:
"""Return (and create) the dir for PSES session/log scratch files."""
home = os.environ.get("HERMES_HOME") or os.path.join(
os.path.expanduser("~"), ".hermes"
)
d = os.path.join(home, "lsp", "pses")
os.makedirs(d, exist_ok=True)
return d
def _resolve_override(ctx: ServerContext, server_id: str) -> Optional[str]:
"""User can pin a binary path in config."""
override = ctx.binary_overrides.get(server_id)
@@ -951,18 +823,6 @@ def _root_java(file_path: str, workspace: str) -> Optional[str]:
)
def _root_powershell(file_path: str, workspace: str) -> Optional[str]:
# PowerShell projects rarely have a universal root marker. Use the
# PSScriptAnalyzer settings file when present, otherwise fall back to
# the git workspace root (nearest_root does exact-name matching only,
# so no globs here).
return _root_or_workspace(
file_path,
workspace,
["PSScriptAnalyzerSettings.psd1"],
)
# ---------------------------------------------------------------------------
# the registry
# ---------------------------------------------------------------------------
@@ -1152,13 +1012,6 @@ SERVERS: List[ServerDef] = [
build_spawn=_spawn_jdtls,
description="Java — Eclipse JDT Language Server",
),
ServerDef(
server_id="powershell",
extensions=(".ps1", ".psm1", ".psd1"),
resolve_root=_root_powershell,
build_spawn=_spawn_powershell_es,
description="PowerShell — PowerShellEditorServices (manual bundle)",
),
]
+11 -52
View File
@@ -4,86 +4,45 @@ from __future__ import annotations
from typing import Any, Sequence
from agent.redact import redact_sensitive_text
def summarize_manual_compression(
before_messages: Sequence[dict[str, Any]],
after_messages: Sequence[dict[str, Any]],
before_tokens: int,
after_tokens: int,
*,
compression_state: Any = None,
) -> dict[str, Any]:
"""Return consistent user-facing feedback for manual compression."""
before_count = len(before_messages)
after_count = len(after_messages)
noop = list(after_messages) == list(before_messages)
aborted = (
compression_state is not None
and getattr(compression_state, "_last_compress_aborted", False) is True
)
fallback_used = (
compression_state is not None
and getattr(compression_state, "_last_summary_fallback_used", False) is True
)
failure_reason = (
getattr(compression_state, "_last_summary_error", None)
if compression_state is not None
else None
)
if not isinstance(failure_reason, str) or not failure_reason.strip():
failure_reason = None
if aborted:
headline = f"Compression aborted: {before_count} messages preserved"
elif fallback_used:
headline = (
f"Compressed with fallback: {before_count}{after_count} messages"
)
elif noop:
if noop:
headline = f"No changes from compression: {before_count} messages"
if after_tokens == before_tokens:
token_line = (
f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
)
else:
token_line = (
f"Approx request size: ~{before_tokens:,}"
f"~{after_tokens:,} tokens"
)
else:
headline = f"Compressed: {before_count}{after_count} messages"
if noop and after_tokens == before_tokens:
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
else:
token_line = (
f"Approx request size: ~{before_tokens:,}"
f"~{after_tokens:,} tokens"
)
note = None
if aborted:
note = "Summary generation failed; no messages were removed."
elif fallback_used:
dropped_count = getattr(
compression_state, "_last_summary_dropped_count", None
)
if not isinstance(dropped_count, int) or isinstance(dropped_count, bool):
dropped_count = max(before_count - after_count, 0)
note = (
"Summary generation failed; Hermes used limited fallback context "
f"and removed {dropped_count} message(s)."
)
elif not noop and after_count < before_count and after_tokens > before_tokens:
if not noop and after_count < before_count and after_tokens > before_tokens:
note = (
"Note: fewer messages can still raise this estimate when "
"compression rewrites the transcript into denser summaries."
)
if failure_reason and (aborted or fallback_used):
# This text crosses a user-facing UI boundary. Never let a disabled
# global redaction preference expose credentials embedded in provider
# exception text.
safe_reason = redact_sensitive_text(failure_reason.strip(), force=True)
note = f"{note} Reason: {safe_reason}"
return {
"noop": noop,
"aborted": aborted,
"fallback_used": fallback_used,
"headline": headline,
"token_line": token_line,
"note": note,
+57 -207
View File
@@ -30,7 +30,7 @@ import logging
import re
import inspect
import threading
from concurrent.futures import Future, ThreadPoolExecutor, wait
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional
from agent.memory_provider import MemoryProvider
@@ -44,7 +44,6 @@ logger = logging.getLogger(__name__)
# teardown indefinitely — the worker threads are daemon, so anything still
# running past this window dies with the interpreter.
_SYNC_DRAIN_TIMEOUT_S = 5.0
_EXTERNAL_PREFETCH_TIMEOUT_S = 8.0
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
@@ -358,19 +357,10 @@ class MemoryManager:
provider is allowed. Failures in one provider never block the other.
"""
def __init__(self, *, external_prefetch_timeout: Optional[float] = None) -> None:
def __init__(self) -> None:
self._providers: List[MemoryProvider] = []
self._tool_to_provider: Dict[str, MemoryProvider] = {}
self._has_external: bool = False # True once a non-builtin provider is added
self._external_prefetch_timeout = (
_EXTERNAL_PREFETCH_TIMEOUT_S
if external_prefetch_timeout is None
else float(external_prefetch_timeout)
)
if self._external_prefetch_timeout <= 0:
raise ValueError("external_prefetch_timeout must be positive")
self._external_prefetch_threads: Dict[str, threading.Thread] = {}
self._external_prefetch_lock = threading.Lock()
# Background executor for end-of-turn sync/prefetch. Lazily created on
# first use so the common builtin-only path spawns no extra threads.
# A single worker serializes a provider's writes (turn N must land
@@ -378,16 +368,6 @@ class MemoryManager:
# _submit_background() and the sync_all/queue_prefetch_all rationale.
self._sync_executor: Optional[ThreadPoolExecutor] = None
self._sync_executor_lock = threading.Lock()
# Futures are tracked by durability class so shutdown can give writes
# a bounded FIFO drain, then explicitly report anything abandoned.
self._background_futures: Dict[Future, str] = {}
self._shutting_down = False
self._shutdown_drain_state: Dict[str, Any] = {
"status": "not_started",
"abandoned_writes": 0,
"abandoned_prefetches": 0,
"active_tasks": 0,
}
# -- Registration --------------------------------------------------------
@@ -524,7 +504,7 @@ class MemoryManager:
parts = []
for provider in self._providers:
try:
result = self._prefetch_provider(provider, clean_query, session_id=session_id)
result = provider.prefetch(clean_query, session_id=session_id)
if result and result.strip():
parts.append(result)
except Exception as e:
@@ -534,56 +514,6 @@ class MemoryManager:
)
return "\n\n".join(parts)
def _prefetch_provider(
self, provider: MemoryProvider, query: str, *, session_id: str = ""
) -> str:
if provider.name == "builtin":
return provider.prefetch(query, session_id=session_id)
result_box: Dict[str, str] = {}
error_box: Dict[str, Exception] = {}
def _run() -> None:
try:
result_box["value"] = provider.prefetch(query, session_id=session_id) or ""
except Exception as exc: # pragma: no cover - re-raised by caller
error_box["value"] = exc
thread = threading.Thread(
target=_run,
daemon=True,
name=f"memory-prefetch-{provider.name}",
)
with self._external_prefetch_lock:
existing = self._external_prefetch_threads.get(provider.name)
if existing is not None:
if existing.is_alive():
logger.debug(
"Memory provider '%s' prefetch is still running; skipping this turn",
provider.name,
)
return ""
self._external_prefetch_threads.pop(provider.name, None)
self._external_prefetch_threads[provider.name] = thread
thread.start()
thread.join(self._external_prefetch_timeout)
if thread.is_alive():
logger.warning(
"Memory provider '%s' prefetch timed out after %.1fs; skipping it until "
"the stuck call returns",
provider.name,
self._external_prefetch_timeout,
)
return ""
with self._external_prefetch_lock:
if self._external_prefetch_threads.get(provider.name) is thread:
self._external_prefetch_threads.pop(provider.name, None)
if error_box:
raise error_box["value"]
return result_box.get("value", "")
def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
"""Queue background prefetch on all providers for the next turn.
@@ -609,7 +539,7 @@ class MemoryManager:
provider.name, e,
)
self._submit_background(_run, kind="prefetch")
self._submit_background(_run)
# -- Sync ----------------------------------------------------------------
@@ -685,59 +615,43 @@ class MemoryManager:
# -- Background dispatch -------------------------------------------------
def _submit_background(self, fn, *, kind: str = "write") -> None:
"""Queue ``fn`` on the serialized worker and track its durability class."""
def _submit_background(self, fn) -> None:
"""Run ``fn`` on the manager's background worker.
The executor is created lazily and shared across calls. If the
executor can't be created or has already been shut down, ``fn``
runs inline as a last-resort fallback losing the async benefit
but never losing the write itself. ``fn`` must do its own
per-provider error handling; this wrapper only guards executor
plumbing.
"""
executor = self._get_sync_executor()
if executor is None:
if self._shutting_down:
logger.warning("Memory manager is shutting down; rejecting late %s task", kind)
return
# Creation failure outside shutdown: preserve the historical
# fail-safe behavior and run the operation inline.
# Executor unavailable (shut down / creation failed) — run
# inline rather than drop the work. Slow, but correct.
try:
fn()
except Exception as e: # pragma: no cover - fn guards internally
logger.debug("Inline memory background task failed: %s", e)
return
try:
# Make submit+tracking atomic with the shutdown snapshot. The
# callback is attached after releasing the lock because an already
# completed future invokes callbacks synchronously.
with self._sync_executor_lock:
if self._shutting_down:
logger.warning("Memory manager is shutting down; rejecting late %s task", kind)
return
future = executor.submit(fn)
self._background_futures[future] = kind
future.add_done_callback(self._forget_background_future)
executor.submit(fn)
except RuntimeError:
if self._shutting_down:
logger.warning("Memory manager shut down during %s submission; task rejected", kind)
return
# Executor was shut down between the get and the submit
# (teardown race). Fall back to inline.
try:
fn()
except Exception as e: # pragma: no cover - fn guards internally
logger.debug("Inline memory background task failed: %s", e)
def _forget_background_future(self, future: Future) -> None:
with self._sync_executor_lock:
self._background_futures.pop(future, None)
def _get_sync_executor(self) -> Optional[ThreadPoolExecutor]:
"""Lazily create the single-worker background executor."""
if self._shutting_down:
return None
if self._sync_executor is not None:
return self._sync_executor
with self._sync_executor_lock:
if self._shutting_down:
return None
if self._sync_executor is None:
try:
# Daemon workers (see tools.daemon_pool): a provider wedged
# on a network call must never block interpreter exit.
from tools.daemon_pool import DaemonThreadPoolExecutor
self._sync_executor = DaemonThreadPoolExecutor(
self._sync_executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="mem-sync",
)
@@ -864,55 +778,6 @@ class MemoryManager:
exc_info=True,
)
def commit_session_boundary_async(
self,
messages: List[Dict[str, Any]],
*,
new_session_id: str,
parent_session_id: str = "",
reason: str = "new_session",
) -> None:
"""Queue old-session extraction + provider rebinding as ONE serialized task.
Session rotation (/new) must deliver ``on_session_end`` (end-of-session
extraction an LLM-bound call that can take seconds) strictly BEFORE
``on_session_switch`` (which rebinds provider-internal ``_session_id`` /
turn buffers to the new session). Running extraction inline blocked the
/new command for the whole LLM round-trip (#16454); running it on an
ad-hoc thread raced the inline switch providers key off internal
state, so a late ``on_session_end`` ran against post-switch bindings
(transcript misattributed to the new session id, double-ingest of the
old turn buffer, new-session buffers cleared).
Submitting BOTH hooks as one task on the manager's single background
worker gives both properties at a single chokepoint: the caller returns
immediately, and the worker's FIFO order serializes end→switch against
every other provider write (per-turn ``sync_all``, prefetches), which
already share the same worker. If the executor is unavailable,
``_submit_background`` degrades to inline execution the pre-#16454
synchronous behavior, slow but correct.
"""
if not self._providers:
return
snapshot = list(messages or [])
def _run() -> None:
try:
self.on_session_end(snapshot)
except Exception as e: # pragma: no cover - on_session_end guards per-provider
logger.warning("Session-boundary extraction failed: %s", e)
try:
self.on_session_switch(
new_session_id,
parent_session_id=parent_session_id,
reset=True,
reason=reason,
)
except Exception as e: # pragma: no cover - on_session_switch guards per-provider
logger.warning("Session-boundary switch failed: %s", e)
self._submit_background(_run)
def on_session_switch(
self,
new_session_id: str,
@@ -1150,66 +1015,51 @@ class MemoryManager:
provider.name, e,
)
@property
def shutdown_drain_state(self) -> Dict[str, Any]:
"""Snapshot of the most recent bounded shutdown drain outcome."""
with self._sync_executor_lock:
return dict(self._shutdown_drain_state)
def _drain_sync_executor(self) -> None:
"""Give queued FIFO work a bounded chance, then abandon explicitly."""
"""Shut down the background executor, waiting briefly for drain.
Bounded by ``_SYNC_DRAIN_TIMEOUT_S``: a wedged provider must never
hang process/session teardown. We stop accepting new work and
cancel anything still queued, then wait at most the drain timeout
for the currently-running task on a watcher thread. The worker is
daemon, so an over-running task dies with the interpreter.
"""
with self._sync_executor_lock:
self._shutting_down = True
executor = self._sync_executor
self._sync_executor = None
tracked = dict(self._background_futures)
self._shutdown_drain_state = {
"status": "draining" if executor is not None else "drained",
"abandoned_writes": 0,
"abandoned_prefetches": 0,
"active_tasks": sum(not future.done() for future in tracked),
}
if executor is None:
return
# shutdown(wait=False) closes submission without touching the FIFO.
# Waiting on the tracked futures lets the real single-worker executor
# run every queued write/boundary task in order up to the deadline.
executor.shutdown(wait=False, cancel_futures=False)
_, pending = wait(tuple(tracked), timeout=_SYNC_DRAIN_TIMEOUT_S)
if not pending:
with self._sync_executor_lock:
self._shutdown_drain_state.update(status="drained", active_tasks=0)
try:
# Stop accepting new work and drop anything still queued, but
# do NOT block here — cancel_futures cancels not-yet-started
# tasks; the in-flight one keeps running on its daemon thread.
executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
# Older Python without cancel_futures kwarg.
try:
executor.shutdown(wait=False)
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor shutdown failed: %s", e)
return
abandoned_writes = 0
abandoned_prefetches = 0
active_tasks = 0
for future in pending:
kind = tracked[future]
if future.cancel():
if kind == "prefetch":
abandoned_prefetches += 1
else:
abandoned_writes += 1
else:
active_tasks += 1
with self._sync_executor_lock:
self._shutdown_drain_state.update(
status="timed_out",
abandoned_writes=abandoned_writes,
abandoned_prefetches=abandoned_prefetches,
active_tasks=active_tasks,
)
logger.warning(
"Memory shutdown drain timed out after %.2fs; abandoning %d queued "
"memory write(s) and %d queued prefetch(es); %d active task(s) remain detached",
_SYNC_DRAIN_TIMEOUT_S,
abandoned_writes,
abandoned_prefetches,
active_tasks,
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor shutdown failed: %s", e)
return
# Give an in-flight sync a bounded chance to finish on a watcher
# thread so we don't block the caller past the drain timeout.
drainer = threading.Thread(
target=lambda: self._bounded_executor_wait(executor),
daemon=True,
name="mem-sync-drain",
)
drainer.start()
drainer.join(timeout=_SYNC_DRAIN_TIMEOUT_S)
@staticmethod
def _bounded_executor_wait(executor: ThreadPoolExecutor) -> None:
try:
executor.shutdown(wait=True)
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor drain wait failed: %s", e)
def initialize_all(self, session_id: str, **kwargs) -> None:
"""Initialize all providers.
+89 -1019
View File
File diff suppressed because it is too large Load Diff
-167
View File
@@ -1,167 +0,0 @@
"""Full MoA turn trace persistence (opt-in via config ``moa.save_traces``).
When enabled, every Mixture-of-Agents turn that actually runs the reference
fan-out (a cache MISS in ``MoAChatCompletions.create``) appends one JSON line
to ``<hermes_home>/moa-traces/<session_id>.jsonl``. The record is the TRUE
FULL turn the exact messages array each reference model received (system
prompt + advisory view, not the truncated display preview), each reference's
full output, and the exact messages array the aggregator received (including
the injected reference-context guidance block) plus its output when available
so a run can be audited end-to-end offline: what every model saw, what every
model said, and what it cost.
This is a side-channel trace. It is NOT the conversation ``messages`` table and
never enters message history or replay MoA references are advisory side-calls
with their own system prompt, not conversation turns, so persisting them as
message rows would corrupt role alternation / replay. Traces live in their own
files, keyed by session id, and are safe to delete.
Cost model note: gated OFF by default. When off, the only overhead is the
``_traces_enabled()`` config read (cheap) no file I/O, no serialization.
"""
from __future__ import annotations
import json
import logging
import os
import time
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
def _traces_enabled_and_dir() -> Optional[Path]:
"""Return the trace directory if ``moa.save_traces`` is on, else None.
Reads config lazily per call (config is cheap to load and this only runs on
a cache-MISS MoA turn, i.e. once per user turn, not per tool iteration).
``moa.trace_dir`` overrides the default ``<hermes_home>/moa-traces/``.
"""
try:
from hermes_cli.config import load_config
moa_cfg = (load_config() or {}).get("moa") or {}
except Exception: # pragma: no cover - defensive: never break a turn over tracing
return None
if not moa_cfg.get("save_traces"):
return None
override = moa_cfg.get("trace_dir")
if override:
base = Path(os.path.expandvars(os.path.expanduser(str(override))))
else:
base = get_hermes_home() / "moa-traces"
return base
def _sanitize_session_id(session_id: Optional[str]) -> str:
"""Make a session id safe as a filename component."""
if not session_id:
return "unknown-session"
return "".join(c if (c.isalnum() or c in "-_.") else "_" for c in str(session_id))
def _slot_trace(acct: Any, label: str) -> dict[str, Any]:
"""Render one reference's _RefAccounting into a full trace dict.
Includes the FULL input messages the reference received and its FULL
output not the truncated display preview.
"""
usage = getattr(acct, "usage", None)
usage_dict: dict[str, Any] = {}
if usage is not None:
usage_dict = {
"input_tokens": getattr(usage, "input_tokens", 0),
"output_tokens": getattr(usage, "output_tokens", 0),
"cache_read_tokens": getattr(usage, "cache_read_tokens", 0),
"cache_write_tokens": getattr(usage, "cache_write_tokens", 0),
"reasoning_tokens": getattr(usage, "reasoning_tokens", 0),
}
return {
"label": label,
"model": getattr(acct, "model", None),
"provider": getattr(acct, "provider", None),
"temperature": getattr(acct, "temperature", None),
"input_messages": getattr(acct, "messages", None),
"output": getattr(acct, "output", None),
"usage": usage_dict,
"cost_usd": getattr(acct, "cost_usd", None),
"cost_status": getattr(acct, "cost_status", None),
"cost_source": getattr(acct, "cost_source", None),
}
def save_moa_turn(
*,
session_id: Optional[str],
preset_name: str,
reference_outputs: list[tuple[str, str, Any]],
aggregator_label: str,
aggregator_model: Optional[str],
aggregator_provider: Optional[str],
aggregator_temperature: Any,
aggregator_input_messages: Any,
aggregator_output: Optional[str],
aggregator_streamed: bool,
) -> None:
"""Append one full MoA turn record to the session's trace JSONL, if enabled.
Best-effort: any failure is logged at debug and swallowed tracing must
never break a live turn. Called once per turn on a reference cache MISS.
``aggregator_output`` is the aggregator's synthesized text. On the
non-streaming path (eval / quiet-mode / subagents) it was captured inline
at call time. On the streaming path it is captured after the fact from the
caller's resolved assistant text (``aggregator_output_fallback`` in
``consume_and_save_trace``) so the trace is self-contained either way; if
that resolved text was unavailable, it falls back to None and the record
points at the session store via ``output_location``.
"""
base = _traces_enabled_and_dir()
if base is None:
return
try:
base.mkdir(parents=True, exist_ok=True)
path = base / f"{_sanitize_session_id(session_id)}.jsonl"
# output_location tells an offline reader where the acting text lives:
# embedded here when we have it (both non-streaming inline capture and
# streaming after-the-fact capture), else the session-db assistant row.
_have_output = bool(aggregator_output)
if not aggregator_streamed:
_output_location = "inline"
elif _have_output:
_output_location = "inline_from_stream"
else:
_output_location = "assistant_message_in_session_db"
record = {
"ts": time.time(),
"session_id": session_id,
"preset": preset_name,
"references": [
_slot_trace(acct, label)
for label, _text, acct in reference_outputs
],
"aggregator": {
"label": aggregator_label,
"model": aggregator_model,
"provider": aggregator_provider,
"temperature": aggregator_temperature,
"input_messages": aggregator_input_messages,
"output": aggregator_output,
"streamed": aggregator_streamed,
# Where the aggregator's acting output lives for this record.
# "inline" — non-streaming inline capture
# "inline_from_stream" — streamed, then captured from the
# caller's resolved assistant text
# "assistant_message_in_session_db" — streamed and the resolved
# text was unavailable at flush time
"output_location": _output_location,
},
}
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
except Exception as exc: # pragma: no cover - tracing must never break a turn
logger.debug("MoA trace write failed (session=%s): %s", session_id, exc)
+141 -980
View File
File diff suppressed because it is too large Load Diff
+2 -33
View File
@@ -15,9 +15,6 @@ and MoonshotAI/kimi-cli#1595:
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
the parent. Presence of both causes "type should be defined in anyOf
items instead of the parent schema".
3. Every object schema must carry a ``required`` array, even an empty one.
Standard JSON Schema allows omitting it; Moonshot 400s with
"required must be an array".
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
@@ -133,32 +130,9 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
else:
repaired.pop("enum")
# Rule 4: object schemas must carry a `required` array, even when empty.
if repaired.get("type") == "object":
repaired = _ensure_required_array(repaired)
return repaired
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
"""Guarantee an object schema carries a ``required`` array (Moonshot rule).
Standard JSON Schema lets you omit ``required`` when nothing is required;
Moonshot 400s on that ("required must be an array"). Ensure the key is a
list. When ``properties`` is known, prune ``required`` entries that don't
name a real property defensive against dangling names, which Moonshot
also rejects. Mutates and returns ``node``.
"""
props = node.get("properties")
req = node.get("required")
if isinstance(req, list):
if isinstance(props, dict):
node["required"] = [r for r in req if r in props]
else:
node["required"] = []
return node
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
node_type = node.get("type")
@@ -200,18 +174,17 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
applied. Input is not mutated.
"""
if not isinstance(parameters, dict):
return {"type": "object", "properties": {}, "required": []}
return {"type": "object", "properties": {}}
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
if not isinstance(repaired, dict):
return {"type": "object", "properties": {}, "required": []}
return {"type": "object", "properties": {}}
# Top-level must be an object schema
if repaired.get("type") != "object":
repaired["type"] = "object"
if "properties" not in repaired:
repaired["properties"] = {}
_ensure_required_array(repaired)
return repaired
@@ -259,10 +232,6 @@ def is_moonshot_model(model: str | None) -> bool:
tail = bare.rsplit("/", 1)[-1]
if tail.startswith("kimi-") or tail == "kimi":
return True
# Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus dated /
# suffixed variants like ``k3.1`` or ``k3-turbo``).
if tail == "k3" or tail.startswith(("k3.", "k3-")):
return True
# Vendor-prefixed forms commonly used on aggregators
if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"):
return True

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