Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15fe7df17a |
+3
-5
@@ -417,9 +417,9 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed.
|
||||
# Install with: pip install faster-whisper
|
||||
# Model downloads automatically on first use (~150 MB for "base").
|
||||
# To use cloud providers instead, set GROQ_API_KEY, VOICE_TOOLS_OPENAI_KEY, or ELEVENLABS_API_KEY above.
|
||||
# Provider priority: local > groq > openai > mistral > xai > elevenlabs
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai | mistral | xai | elevenlabs
|
||||
# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.
|
||||
# Provider priority: local > groq > openai
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai
|
||||
|
||||
# =============================================================================
|
||||
# STT ADVANCED OVERRIDES (optional)
|
||||
@@ -427,12 +427,10 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Override default STT models per provider (normally set via stt.model in config.yaml)
|
||||
# STT_GROQ_MODEL=whisper-large-v3-turbo
|
||||
# STT_OPENAI_MODEL=whisper-1
|
||||
# STT_ELEVENLABS_MODEL=scribe_v2
|
||||
|
||||
# Override STT provider endpoints (for proxies or self-hosted instances)
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# ELEVENLABS_STT_BASE_URL=https://api.elevenlabs.io/v1
|
||||
|
||||
# =============================================================================
|
||||
# MICROSOFT TEAMS INTEGRATION
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file ui-tui/package-lock.json ui-tui/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
|
||||
|
||||
use flake
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Gate: workflow_dispatch is already restricted to users with write access,
|
||||
# but we want ADMIN-only. Explicitly check the triggering actor's repo
|
||||
# permission via the API and fail fast for anyone below admin.
|
||||
authorize:
|
||||
name: Authorize (admins only)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check actor is a repo admin
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
perm=$(gh api \
|
||||
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission')
|
||||
echo "Actor '${ACTOR}' has permission: ${perm}"
|
||||
if [ "${perm}" != "admin" ]; then
|
||||
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
|
||||
exit 1
|
||||
fi
|
||||
echo "Authorized: '${ACTOR}' is an admin."
|
||||
|
||||
build:
|
||||
name: Hermes-Setup.exe
|
||||
needs: authorize
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
# Required for OIDC auth to Azure (azure/login federated credentials).
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Cache Rust targets
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/bootstrap-installer/src-tauri
|
||||
|
||||
- name: Build installer
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
|
||||
with:
|
||||
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
|
||||
# Sign both the raw exe and the bundled NSIS installer.
|
||||
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
|
||||
files-folder-filter: exe
|
||||
files-folder-recurse: true
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Upload NSIS installer
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-installer
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
- name: Upload raw exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-exe
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe
|
||||
@@ -3,9 +3,11 @@ name: Contributor Attribution Check
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
paths:
|
||||
# Only run when code files change (not docs-only PRs)
|
||||
- '*.py'
|
||||
- '**/*.py'
|
||||
- '.github/workflows/contributor-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,21 +20,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
@@ -22,12 +22,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-vercel:
|
||||
# Triggered automatically on release publish (production cuts) and
|
||||
# manually via `gh workflow run deploy-site.yml` when an out-of-band
|
||||
# main commit needs to ship live before the next release tag — e.g.
|
||||
# a skills-index PR that doesn't touch website/** paths and so
|
||||
# doesn't auto-deploy via the deploy-docs path.
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Vercel Deploy
|
||||
|
||||
@@ -26,10 +26,6 @@ on:
|
||||
|
||||
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
|
||||
@@ -75,8 +71,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -155,8 +149,6 @@ jobs:
|
||||
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
|
||||
@@ -200,35 +192,10 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
# read (cache-from) on every event and written (cache-to) on
|
||||
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
|
||||
# the whole job — unlike the gha cache backend's short-lived Azure SAS
|
||||
# token, which expired mid-build on slow cold-cache arm64 runs and
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for smoke testing.
|
||||
#
|
||||
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
|
||||
# they pull warm layers pushed by the most recent main build but never
|
||||
# write, so rapid PR pushes don't race on cache writes or pollute the
|
||||
# cache ref. This restores warm-cache speed to arm64 PR builds (which
|
||||
# were running fully uncached and were ~45% slower than amd64, making
|
||||
# them the job most often cancelled on supersede).
|
||||
#
|
||||
# Registry cache (type=registry on ghcr.io) is used instead of the gha
|
||||
# cache that previously broke here: its credential is the job-lifetime
|
||||
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, smoke test, cache read-only PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
# 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 (arm64, smoke test)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
@@ -236,26 +203,8 @@ jobs:
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
|
||||
# Main/release builds read AND write the registry cache so the digest
|
||||
# push below reuses layers from this smoke-test build, and so the next
|
||||
# PR/main build starts warm.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
- name: Smoke test image
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
@@ -279,11 +228,9 @@ jobs:
|
||||
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
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
|
||||
@@ -4,10 +4,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
- 'web/package-lock.json'
|
||||
- 'web/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -27,9 +27,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json.
|
||||
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||
# so Nix builds never stay broken.
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash
|
||||
# update commit directly to main so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
@@ -109,8 +109,8 @@ jobs:
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'package-lock.json' 'package.json' \
|
||||
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
||||
'web/package-lock.json' 'web/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
|
||||
@@ -37,16 +37,23 @@ jobs:
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
# When the flake check fails, run a targeted diagnostic to see if
|
||||
- name: Build package
|
||||
id: build
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix build --print-build-logs
|
||||
|
||||
# When the real Nix build fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
|
||||
if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
@@ -81,25 +88,30 @@ jobs:
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the flake check passed outright (no
|
||||
# Clear the sticky comment when either the build passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (check failed for a non-hash reason).
|
||||
# (build failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
runner.os == 'Linux' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
steps.flake.outcome == 'success')
|
||||
(steps.flake.outcome == 'success' && steps.build.outcome == 'success'))
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if flake check failed
|
||||
if: steps.flake.outcome == 'failure'
|
||||
- name: Final fail if build or flake failed
|
||||
if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix flake check failed. See logs above."
|
||||
echo "::error::Nix build/flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Evaluate flake (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: nix flake show --json > /dev/null
|
||||
|
||||
@@ -28,6 +28,7 @@ on:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'website/package.json'
|
||||
- 'website/package-lock.json'
|
||||
- '.github/workflows/osv-scanner.yml'
|
||||
@@ -38,6 +39,7 @@ on:
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'website/package-lock.json'
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
@@ -60,6 +62,6 @@ jobs:
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
scan-args: |-
|
||||
--lockfile=uv.lock
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=ui-tui/package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
fail-on-vuln: false
|
||||
|
||||
@@ -3,9 +3,15 @@ name: Supply Chain Audit
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- '**/*.pth'
|
||||
- '**/setup.py'
|
||||
- '**/setup.cfg'
|
||||
- '**/sitecustomize.py'
|
||||
- '**/usercustomize.py'
|
||||
- '**/__init__.pth'
|
||||
- 'pyproject.toml'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -21,44 +27,8 @@ permissions:
|
||||
# advisory-only workflow instead.
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -177,24 +147,10 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -255,16 +211,3 @@ jobs:
|
||||
run: |
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
-24
@@ -63,10 +63,6 @@ environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
@@ -82,23 +78,7 @@ mini-swe-agent/
|
||||
.nix-stamps/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
# skills.json + skills-meta.json are build artifacts emitted by
|
||||
# website/scripts/extract-skills.py during prebuild — keep them out of
|
||||
# git for the same reason as skills-index.json (large, generated, change
|
||||
# every build).
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
.codex/
|
||||
.cursor/
|
||||
.gemini/
|
||||
.zed/
|
||||
.mcp.json
|
||||
opencode.json
|
||||
config/mcporter.json
|
||||
|
||||
hermes_cli/tui_dist/*
|
||||
hermes_cli/scripts/
|
||||
docs/superpowers/*
|
||||
@@ -106,7 +86,3 @@ docs/superpowers/*
|
||||
# also created in-repo when an agent operates in this checkout). Plans, audit
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
|
||||
|
||||
**Never give up on the right solution.**
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
@@ -49,8 +47,8 @@ hermes-agent/
|
||||
│ ├── hermes-achievements/ # Gamified achievement tracking
|
||||
│ ├── observability/ # Metrics / traces / logs plugin
|
||||
│ ├── image_gen/ # Image-generation providers
|
||||
│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
|
||||
│ # strike-freedom-cockpit, ...
|
||||
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
|
||||
│ # spotify, strike-freedom-cockpit, ...
|
||||
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
|
||||
├── skills/ # Built-in skills bundled with the repo
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
@@ -68,29 +66,6 @@ hermes-agent/
|
||||
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
|
||||
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
|
||||
|
||||
## TypeScript Style
|
||||
|
||||
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
|
||||
|
||||
- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
|
||||
- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
|
||||
- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
|
||||
- Do not pass state through three components when the leaf can subscribe to the atom.
|
||||
- Keep persistence beside the atom that owns it.
|
||||
- Keep route roots thin. They compose routes and shell; they should not become controllers.
|
||||
- No monolithic hooks. A hook should own one narrow job.
|
||||
- Prefer colocated action modules over hidden god hooks.
|
||||
- If a callback is pure side effect, use the terse void form:
|
||||
`onState={st => void setGatewayState(st)}`.
|
||||
- Async UI handlers should make intent explicit:
|
||||
`onClick={() => void save()}`.
|
||||
- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
|
||||
- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
|
||||
- Table-driven beats condition ladders when mapping ids, routes, or views.
|
||||
- `src/app` owns routes, pages, and page-specific components.
|
||||
- `src/store` owns shared atoms.
|
||||
- `src/lib` owns shared pure helpers.
|
||||
|
||||
## File Dependency Chain
|
||||
|
||||
```
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
|
||||
- Document handling, web research, common dev workflows, system administration
|
||||
- Used regularly by a wide range of people
|
||||
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
|
||||
|
||||
|
||||
+7
-74
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -73,17 +73,7 @@ RUN set -eu; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
|
||||
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
|
||||
# #34192: backward-compat shim for orchestration templates that still\
|
||||
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
|
||||
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
|
||||
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
|
||||
# of this file), but external wrappers pinned to /usr/bin/tini will\
|
||||
# crash with 'tini: No such file or directory' on startup. The shim\
|
||||
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
|
||||
# PID-1 reaper without behavior change for users on the current\
|
||||
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
|
||||
ln -sf /init /usr/bin/tini
|
||||
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
@@ -113,8 +103,8 @@ WORKDIR /opt/hermes
|
||||
# ui-tui/package.json. Copying the tree up front lets npm resolve the
|
||||
# workspace to real content instead of stopping at a bare package.json.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY web/package.json web/
|
||||
COPY ui-tui/package.json ui-tui/
|
||||
COPY web/package.json web/package-lock.json web/
|
||||
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
@@ -131,6 +121,8 @@ ENV npm_config_install_links=false
|
||||
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
(cd web && npm install --prefer-offline --no-audit) && \
|
||||
(cd ui-tui && npm install --prefer-offline --no-audit) && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Layer-cached Python dependency install ----------
|
||||
@@ -195,29 +187,6 @@ RUN chmod -R a+rX /opt/hermes && \
|
||||
# this a fast (~1s) egg-link creation with no resolution or downloads.
|
||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||
|
||||
# ---------- Bake build-time git revision ----------
|
||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||
# container always returns nothing — meaning `hermes dump` reports
|
||||
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
|
||||
# That makes support triage from container bug reports impossible:
|
||||
# we can't tell which commit the user is actually running.
|
||||
#
|
||||
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
|
||||
# /opt/hermes/.hermes_build_sha at build time, and have
|
||||
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
|
||||
# banner.get_git_banner_state() try the baked SHA first, then fall back
|
||||
# to live `git rev-parse` for source installs (unchanged behaviour).
|
||||
#
|
||||
# The arg is optional — local `docker build` without --build-arg simply
|
||||
# omits the file, and the runtime falls back to live-git lookup. CI
|
||||
# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
|
||||
# every published image has it.
|
||||
ARG HERMES_GIT_SHA=
|
||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
||||
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
|
||||
fi
|
||||
|
||||
# ---------- s6-overlay service wiring ----------
|
||||
# Static services declared at build time: main-hermes + dashboard.
|
||||
# Per-profile gateway services are registered dynamically at runtime by
|
||||
@@ -243,50 +212,14 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
|
||||
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
|
||||
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
|
||||
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
|
||||
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
|
||||
# nix/packaged-release path the launcher was designed for.
|
||||
#
|
||||
# Why this is required (not just an optimization): the root package-lock.json
|
||||
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
|
||||
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
|
||||
# never `npm install`ed here). So the actualized node_modules permanently
|
||||
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
|
||||
# every launch, and the runtime `npm install` it triggers (a) can never
|
||||
# converge against the partial monorepo and (b) races itself across concurrent
|
||||
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
|
||||
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
||||
# check. (A separate launcher hardening is tracked independently.)
|
||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||
# command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends
|
||||
# up root-owned and unreadable to the supervised gateway (UID 10000).
|
||||
# The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and
|
||||
# transparently re-exec's the real venv binary via `s6-setuidgid hermes`
|
||||
# when invoked as root. Non-root callers (supervised processes,
|
||||
# `--user hermes`, etc.) hit the short-circuit path with no overhead.
|
||||
# Recursion is impossible because the shim exec's the venv binary by
|
||||
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
||||
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
||||
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
||||
|
||||
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
||||
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
||||
# same for the container's main process, but `docker exec` and our
|
||||
# cont-init.d scripts don't pass through the wrapper. Expose the venv
|
||||
# bin globally so `docker exec <container> hermes ...` and any
|
||||
# subprocess that doesn't activate the venv first still find hermes.
|
||||
#
|
||||
# /opt/hermes/bin is prepended ahead of the venv so the privilege-drop
|
||||
# shim wins PATH resolution. The shim's last act is to exec the venv
|
||||
# binary by absolute path, so this PATH ordering is transparent to
|
||||
# every other consumer.
|
||||
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
||||
ENV PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
||||
RUN mkdir -p /opt/data
|
||||
VOLUME [ "/opt/data" ]
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
# 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
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
|
||||
@@ -36,9 +36,9 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (native, PowerShell)
|
||||
### Windows (native, PowerShell) — Early Beta
|
||||
|
||||
> **Heads up:** Native Windows runs Hermes without WSL — CLI, gateway, TUI, and tools all work natively. If you'd rather use WSL2, the Linux/macOS one-liner above works there too. Found a bug? Please [file issues](https://github.com/NousResearch/hermes-agent/issues).
|
||||
> **Heads up:** Native Windows support is **early beta**. It installs and runs, but hasn't been road-tested as broadly as our Linux/macOS/WSL2 paths. Please [file issues](https://github.com/NousResearch/hermes-agent/issues) when you hit rough edges. For the most battle-tested Windows setup today, run the Linux/macOS one-liner above inside **WSL2**.
|
||||
|
||||
Run this in PowerShell:
|
||||
|
||||
@@ -46,13 +46,13 @@ Run this in PowerShell:
|
||||
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
|
||||
```
|
||||
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
|
||||
If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git.
|
||||
If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git.
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
> **Windows:** Native Windows is supported as an **early beta** — the PowerShell one-liner above installs everything, but expect rough edges and please file issues when you hit them. If you'd rather use WSL2 (our most battle-tested Windows path), the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
|
||||
After installation:
|
||||
|
||||
@@ -104,17 +104,17 @@ You can still bring your own keys per-tool whenever you want — the gateway is
|
||||
|
||||
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
|
||||
|
||||
| Action | CLI | Messaging platforms |
|
||||
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
| Start fresh conversation | `/new` or `/reset` | `/new` or `/reset` |
|
||||
| Change model | `/model [provider:model]` | `/model [provider:model]` |
|
||||
| Set a personality | `/personality [name]` | `/personality [name]` |
|
||||
| Retry or undo the last turn | `/retry`, `/undo` | `/retry`, `/undo` |
|
||||
| Compress context / check usage | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
|
||||
| Browse skills | `/skills` or `/<skill-name>` | `/<skill-name>` |
|
||||
| Interrupt current work | `Ctrl+C` or send a new message | `/stop` or send a new message |
|
||||
| Platform-specific status | `/platforms` | `/status`, `/sethome` |
|
||||
| Action | CLI | Messaging platforms |
|
||||
|---------|-----|---------------------|
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
| Start fresh conversation | `/new` or `/reset` | `/new` or `/reset` |
|
||||
| Change model | `/model [provider:model]` | `/model [provider:model]` |
|
||||
| Set a personality | `/personality [name]` | `/personality [name]` |
|
||||
| Retry or undo the last turn | `/retry`, `/undo` | `/retry`, `/undo` |
|
||||
| Compress context / check usage | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
|
||||
| Browse skills | `/skills` or `/<skill-name>` | `/<skill-name>` |
|
||||
| Interrupt current work | `Ctrl+C` or send a new message | `/stop` or send a new message |
|
||||
| Platform-specific status | `/platforms` | `/status`, `/sethome` |
|
||||
|
||||
For the full command lists, see the [CLI guide](https://hermes-agent.nousresearch.com/docs/user-guide/cli) and the [Messaging Gateway guide](https://hermes-agent.nousresearch.com/docs/user-guide/messaging).
|
||||
|
||||
@@ -124,23 +124,23 @@ For the full command lists, see the [CLI guide](https://hermes-agent.nousresearc
|
||||
|
||||
All documentation lives at **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
|
||||
|
||||
| Section | What's Covered |
|
||||
| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| [Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Install → setup → first conversation in 2 minutes |
|
||||
| [CLI Usage](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Commands, keybindings, personalities, sessions |
|
||||
| [Configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Config file, providers, models, all options |
|
||||
| [Messaging Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
|
||||
| [Security](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Command approval, DM pairing, container isolation |
|
||||
| [Tools & Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ tools, toolset system, terminal backends |
|
||||
| [Skills System](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Procedural memory, Skills Hub, creating skills |
|
||||
| [Memory](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Persistent memory, user profiles, best practices |
|
||||
| [MCP Integration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Connect any MCP server for extended capabilities |
|
||||
| [Cron Scheduling](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Scheduled tasks with platform delivery |
|
||||
| [Context Files](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Project context that shapes every conversation |
|
||||
| [Architecture](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Project structure, agent loop, key classes |
|
||||
| [Contributing](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Development setup, PR process, code style |
|
||||
| [CLI Reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | All commands and flags |
|
||||
| [Environment Variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Complete env var reference |
|
||||
| Section | What's Covered |
|
||||
|---------|---------------|
|
||||
| [Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Install → setup → first conversation in 2 minutes |
|
||||
| [CLI Usage](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Commands, keybindings, personalities, sessions |
|
||||
| [Configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Config file, providers, models, all options |
|
||||
| [Messaging Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
|
||||
| [Security](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Command approval, DM pairing, container isolation |
|
||||
| [Tools & Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ tools, toolset system, terminal backends |
|
||||
| [Skills System](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Procedural memory, Skills Hub, creating skills |
|
||||
| [Memory](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Persistent memory, user profiles, best practices |
|
||||
| [MCP Integration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Connect any MCP server for extended capabilities |
|
||||
| [Cron Scheduling](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Scheduled tasks with platform delivery |
|
||||
| [Context Files](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Project context that shapes every conversation |
|
||||
| [Architecture](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Project structure, agent loop, key classes |
|
||||
| [Contributing](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Development setup, PR process, code style |
|
||||
| [CLI Reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | All commands and flags |
|
||||
| [Environment Variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Complete env var reference |
|
||||
|
||||
---
|
||||
|
||||
@@ -160,7 +160,6 @@ hermes claw migrate --overwrite # Overwrite existing conflicts
|
||||
```
|
||||
|
||||
What gets imported:
|
||||
|
||||
- **SOUL.md** — persona file
|
||||
- **Memories** — MEMORY.md and USER.md entries
|
||||
- **Skills** — user-created skills → `~/.hermes/skills/openclaw-imports/`
|
||||
|
||||
+34
-32
@@ -3,73 +3,75 @@
|
||||
**Release Date:** May 16, 2026
|
||||
**Since v0.13.0:** 808 commits · 633 merged PRs · 1393 files changed · 165,061 insertions · 545 issues closed (12 P0, 50 P1) · 215 community contributors (including co-authors)
|
||||
|
||||
> The Foundation Release — Hermes Agent installs and runs anywhere now. Native Windows ships in early beta with a full PowerShell installer story, a `pip install hermes-agent` wheel lands on PyPI, lazy-deps reshape what `pip install hermes-agent` actually pulls down, the supply-chain checker scans every install/upgrade for unsafe versions, and a new OpenAI-compatible local proxy lets Codex / Aider / Cline talk to OAuth-only providers (Claude Pro, ChatGPT Pro, SuperGrok). The cold-start wave shaves ~19 seconds off `hermes` launch, browser-tool CDP calls run 180x faster, and `hermes tools` All-Platforms drops from 14s to under 1.5s. Two new messaging platforms (LINE and SimpleX Chat) and a Microsoft Graph foundation (Teams pipeline + webhook adapter) land alongside `/handoff` that finally transfers sessions live, `vision_analyze` passing pixels through to vision-capable models, `x_search` as a first-class tool, LSP semantic diagnostics on every `write_file` / `patch`, a unified pluggable `video_generate`, a `computer_use` cua-driver backend, cross-session 1-hour Claude prompt caching, a per-turn file-mutation verifier, plus 9 new optional skills. 50+ P1 closures, 12 P0 closures.
|
||||
> The Foundation Release — Hermes installs and runs anywhere, ships with the things you actually want to use, and stops shipping the things you don't. xAI Grok lands as a SuperGrok OAuth provider with grok-4.3 bumped to a 1M context window. A new OpenAI-compatible local proxy turns any OAuth-authed Hermes provider — Claude Pro, ChatGPT Pro, SuperGrok — into an endpoint that Codex / Aider / Cline / Continue can hit. `x_search` lands as a first-class X (Twitter) search tool with OAuth-or-API-key auth. The Microsoft Teams stack is wired end-to-end (Graph auth + webhook listener + pipeline runtime + outbound delivery). A debloating wave makes installs dramatically lighter — heavyweight backends now lazy-install on first use, the `[all]` extras drop everything covered by lazy-deps, and a tiered install falls back when a wheel rejects on your platform. `pip install hermes-agent` works from PyPI. The cold-start wave shaves ~19 seconds off `hermes` launch. Browser CDP calls are 180x faster. Two new messaging platforms (LINE + SimpleX Chat) bring the total to 22. Cross-session 1-hour Claude prompt caching, `/handoff` that actually transfers sessions live, native button UI for `clarify` on Telegram and Discord, Discord channel history backfill, LSP semantic diagnostics on every write, a unified pluggable `video_generate`, a `computer_use` cua-driver backend that finally works with non-Anthropic providers, clickable URLs in any terminal, Zed ACP Registry integration via `uvx`, native Windows beta, 9 new optional skills, OpenRouter Pareto Code router, huggingface/skills as a trusted default tap. 12 P0 + 50 P1 closures.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Native Windows support (early beta)** — full PowerShell installer, native subprocess/PTY paths, taskkill-based process management, MinGit auto-install, Microsoft Store python stub detection, foreground Ctrl+C preservation, taskkill+ps2 fallback, npm prefix handling, and ~40 follow-up Windows-only fixes across CLI / gateway / TUI / curator / tools. Hermes finally runs natively on `cmd.exe` and PowerShell, no WSL required. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561), [#22130](https://github.com/NousResearch/hermes-agent/pull/22130), [#22752](https://github.com/NousResearch/hermes-agent/pull/22752), [#26618](https://github.com/NousResearch/hermes-agent/pull/26618), and many more)
|
||||
- **xAI Grok via SuperGrok OAuth — and grok-4.3 jumps to a 1M context window** — If you pay for SuperGrok, you can now use Grok inside Hermes by signing in with your xAI account — no API key, no separate billing. The wire-through also bumps grok-4.3 to a 1M token context window, so you can drop whole codebases or research corpora into a single prompt. Includes proper handling for entitlement errors and an SSH-to-tunnel docs page for when you're SSH'd into a remote box and need to complete the OAuth flow. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534), [#26664](https://github.com/NousResearch/hermes-agent/pull/26664), [#26644](https://github.com/NousResearch/hermes-agent/pull/26644), [#26592](https://github.com/NousResearch/hermes-agent/pull/26592))
|
||||
|
||||
- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. One command, no clone, no git, no shell installer. Wheel includes the Ink TUI bundle and shell launcher. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593))
|
||||
- **OpenAI-compatible local proxy for OAuth providers** — Run `hermes proxy` and you get a `http://localhost:port` endpoint that speaks the OpenAI API but is backed by whichever OAuth provider you're signed into — Claude Pro, ChatGPT Pro, SuperGrok. Now any tool that expects an OpenAI-compatible endpoint (Codex CLI, Aider, Cline, Continue, your custom scripts) just works with your existing subscription, no API key required. One subscription, every tool. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969))
|
||||
|
||||
- **Cold-start performance wave — ~19s off `hermes` launch** — skills cache, lazy Feishu import, no Nous HTTP at startup, plus PEP-562 lazy adapter imports (QQ, Yuanbao, Teams, Google Chat), deferred `fal_client` / `google-cloud` / `httpx` loads, models.dev disk-cache-first lookup, parallel doctor API checks, eager-skip plugin discovery on built-in subcommands, `hermes tools` All-Platforms drops from 14s to <1.5s, welcome banner skipped on `chat -q`. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341))
|
||||
- **`x_search` — first-class X (Twitter) search tool** — The agent can now search X directly without installing a skill or wiring up a custom integration. Search the timeline, find threads, surface specific posts — straight from the chat. Auth with either your X OAuth login or an API key, whichever you have. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763))
|
||||
|
||||
- **180x faster `browser_console` evaluations** — routed through the supervisor's persistent CDP WebSocket instead of spawning a fresh DevTools session per call. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226))
|
||||
- **Microsoft Teams — end-to-end** — Hermes can now read messages from Teams and post back. The full Microsoft Graph stack lands together: auth + client foundation, a webhook listener that receives Teams events, a pipeline plugin runtime, and outbound delivery. Wire up the bot once, then chat to your agent from any Teams channel, DM, or group. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024))
|
||||
|
||||
- **Supply-chain advisory checker + lazy-deps framework + tiered install fallback** — every `pip install` / `hermes update` scans dependencies against an advisory list, lazy-deps replace heavy import-time loads with first-use installs, and the installer falls back through extras tiers when a wheel rejects on the target platform. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220))
|
||||
- **Debloating wave — lighter installs, less you don't use** — A clean `pip install hermes-agent` used to pull down everything: every messaging adapter SDK, every image-gen SDK, every voice/TTS provider, whether you used them or not. Now those heavy backends (Slack / Matrix / Feishu / DingTalk adapters, hindsight client, codex app-server, Pixverse / Camofox / image-gen SDKs, voice/TTS providers) install automatically the first time you actually use them. The `[all]` extras drop everything covered by lazy-deps, the installer falls back through tiers when a wheel doesn't fit your platform, and a supply-chain advisory checker scans every install for unsafe versions. Faster installs, smaller disk footprint, fewer transitive vulnerabilities. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220), [#24515](https://github.com/NousResearch/hermes-agent/pull/24515), [#25014](https://github.com/NousResearch/hermes-agent/pull/25014), [#25038](https://github.com/NousResearch/hermes-agent/pull/25038), [#25766](https://github.com/NousResearch/hermes-agent/pull/25766), [#21818](https://github.com/NousResearch/hermes-agent/pull/21818))
|
||||
|
||||
- **OpenAI-compatible local proxy** — `hermes proxy` exposes any OAuth-authed provider (Claude Pro, ChatGPT Pro, SuperGrok) as an OpenAI-compatible endpoint that Codex / Aider / Cline / VS Code Continue can hit. Your subscription, your tools. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969))
|
||||
- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. No more cloning the repo or running shell installers — one pip command and you're running. The wheel ships with the Ink TUI bundle and the shell launcher, so the full experience comes out of the box. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593), [#26148](https://github.com/NousResearch/hermes-agent/pull/26148))
|
||||
|
||||
- **Cross-session 1-hour Claude prompt cache** — Anthropic / OpenRouter / Nous Portal now share a 1h prefix cache across sessions for Claude models. Fast resume, fast `/new`, lower cost on repeat work. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828))
|
||||
- **Cross-session 1h Claude prompt cache** — When you use Claude through Anthropic, OpenRouter, or Nous Portal, the prompt prefix (system prompt, skills, memory) now caches for an hour across sessions. Start a `/new` session and the first response comes back faster and cheaper because the cache is still warm from your last session. Background memory review hits the cache too, so it's not paying full price every turn. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828), [#25434](https://github.com/NousResearch/hermes-agent/pull/25434), [#24778](https://github.com/NousResearch/hermes-agent/pull/24778))
|
||||
|
||||
- **Two new messaging platforms — LINE + SimpleX Chat** — LINE Messaging API lands as a first-class platform, SimpleX Chat salvages #2558 onto the modern adapter spec. Hermes is now on 22 platforms. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232))
|
||||
- **180x faster `browser_console` evaluations** — When the agent uses the browser tool to inspect a page or run JavaScript, those calls now share one persistent connection to Chrome instead of spinning up a new DevTools session every time. The difference is huge: things that used to take a couple of seconds per call return in milliseconds. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226))
|
||||
|
||||
- **Microsoft Graph foundation — Teams pipeline + webhook adapter** — `msgraph` auth/client foundation, webhook listener platform, Teams pipeline plugin runtime, and Teams outbound delivery via the existing adapter — Hermes can now read and post to Teams. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024))
|
||||
- **Cold-start performance wave — ~19 seconds off `hermes` launch** — Running `hermes` used to make you wait through a chunk of import overhead and network calls before you saw a prompt. Now the launch path is mostly deferred: heavy adapters only load when you use them, model catalogs come from disk cache first, doctor checks run in parallel, and `chat -q` skips the welcome banner entirely. The `hermes tools` All-Platforms screen alone dropped from 14 seconds to under 1.5 seconds. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341))
|
||||
|
||||
- **`/handoff` actually transfers the session live** — the agent's active session moves to a different model / persona / profile mid-conversation, with messages, tool history, and context preserved. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395))
|
||||
- **Two new messaging platforms — LINE + SimpleX Chat** — LINE is huge in Japan, Korea, and Taiwan, and now Hermes runs natively on the LINE Messaging API. SimpleX Chat is the privacy-focused decentralized messenger with no user IDs — also wired up as a first-class platform. That brings Hermes to 22 messaging platforms total, so wherever you and your team chat, the agent can be there. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232))
|
||||
|
||||
- **`x_search` — first-class X (Twitter) search tool** — gated tool with OAuth-or-API-key auth, no skill needed to query the timeline. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763))
|
||||
- **`/handoff` actually transfers the session live** — Switching models or personalities mid-conversation used to mean losing context or starting over. Now `/handoff` moves your active session — every message, every tool call, every piece of context — to the target model, persona, or profile, live, without dropping anything. Mid-debugging hand off from a fast model to a deep-reasoning one, or pass a session between profiles for different parts of a task. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395))
|
||||
|
||||
- **`vision_analyze` returns pixels to vision-capable models** — when the active model can see, `vision_analyze` now hands the image straight through instead of falling back to a text description. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955))
|
||||
- **Native button UI for `clarify` on Telegram and Discord** — When the agent uses the `clarify` tool to ask you a multiple-choice question, it now shows real platform-native buttons on Telegram and Discord instead of asking you to type back the option number. Tap the button, the agent gets your answer. Especially nice on mobile. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485))
|
||||
|
||||
- **LSP semantic diagnostics on every write** — `write_file` and `patch` now run real language-server diagnostics on the post-edit file (delta-only) and surface real errors before they ship downstream. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978))
|
||||
- **Discord channel history backfill (default on)** — When Hermes joins a Discord channel or thread for the first time, it now reads the recent message history so it knows what's been said before it responds. No more "what are we talking about?" — the agent has the context that's already on screen for everyone else. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984))
|
||||
|
||||
- **Per-turn file-mutation verifier footer** — after every turn that wrote files, the agent gets a verifier footer summarizing what actually changed on disk — catches silent overwrites and "wrote it but it didn't land" bugs. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498))
|
||||
- **`vision_analyze` returns pixels to vision-capable models** — When you point the agent at an image with `vision_analyze` and the active model can actually see (GPT-5, Claude, Gemini, Grok-vision), Hermes now passes the raw pixels straight to the model instead of converting them to a text description first. You get the model's actual visual reasoning instead of a degraded text-summary round-trip. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955))
|
||||
|
||||
- **Unified `video_generate` with pluggable provider backends** — single tool, any backend. Drop in a new video provider as a plugin, no core changes. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126))
|
||||
- **Per-turn file-mutation verifier footer** — After every turn that wrote or edited files, the agent now gets a short footer summarizing exactly what changed on disk — the file paths, the line counts, the actual delta. That means the agent catches its own mistakes when a write didn't land or got silently overwritten, instead of confidently telling you "I added the function" when the file wasn't actually saved. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498))
|
||||
|
||||
- **`computer_use` cua-driver backend** — proper focus-safe ops, non-Anthropic provider support, refresh on `hermes update`. Computer-use is no longer locked to a single SDK. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063))
|
||||
- **LSP semantic diagnostics on every write** — When the agent uses `write_file` or `patch`, Hermes now runs a real language server against the edited file and surfaces any new errors back to the agent before the next turn. Type errors, undefined symbols, missing imports — caught immediately. Goes way beyond v0.13.0's basic Python/JSON/YAML/TOML linting because it's actual semantic analysis. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978))
|
||||
|
||||
- **xAI Grok OAuth provider — SuperGrok via subscription** — sign in with your xAI account, talk to Grok models from Hermes. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534))
|
||||
- **Unified `video_generate` with pluggable provider backends** — One tool, any video model. Hermes ships with the obvious backends already, but you can drop in a new video provider as a plugin without touching core. So when a new video model lands next month, it can be a one-file plugin instead of a fork. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126))
|
||||
|
||||
- **Clarify with buttons — native inline keyboards on Telegram + Discord** — the `clarify` tool renders multi-choice prompts as platform-native buttons instead of typed responses. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485))
|
||||
- **`computer_use` cua-driver backend — works with non-Anthropic models now** — Computer-use (the agent controlling your mouse and keyboard to drive GUI apps) used to be locked to Anthropic's SDK. The new cua-driver backend works with non-Anthropic providers too, has proper focus-safe operations, and refreshes itself on `hermes update`. Now any vision-capable model can drive your desktop. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063))
|
||||
|
||||
- **Discord channel history backfill (default on)** — Hermes reads recent channel history when joining a thread so it actually knows what's been said. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984))
|
||||
- **Clickable URLs in any terminal** — Links in agent output are now real OSC8 hyperlinks with hover-highlight in any terminal that supports them. Click to open in your browser — no more copy-paste-trim of long URLs from the transcript. Just works in iTerm2, Kitty, Ghostty, modern Windows Terminal, etc. (@OutThisLife) ([#25071](https://github.com/NousResearch/hermes-agent/pull/25071), [#24013](https://github.com/NousResearch/hermes-agent/pull/24013))
|
||||
|
||||
- **Watchers skill — RSS / HTTP JSON / GitHub polling via cron `no_agent` mode** — skill recipes that wire change-detection sources directly into cron's script-only watchdog mode. ([#21881](https://github.com/NousResearch/hermes-agent/pull/21881))
|
||||
- **Zed ACP Registry — `uvx` install in one click** — Hermes is now listed in Zed's Agent Client Protocol registry, so Zed users can install it with one click. The install path uses `uvx` so there's no npm dependency. `hermes acp --setup-browser` bootstraps the browser tools for registry-driven installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234))
|
||||
|
||||
- **Zed ACP Registry integration + uvx distribution** — Hermes is in the Zed registry, installable via `uvx` (no npm). Plus `hermes acp --setup-browser` bootstraps browser tools for registry installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234))
|
||||
- **OpenRouter Pareto Code router with `min_coding_score` knob** — OpenRouter's "Pareto" router automatically picks the cheapest model that meets a minimum quality bar. The new `min_coding_score` config lets you set that bar for coding tasks specifically — Hermes routes to the most affordable model that's at least that good at code. Stop paying for top-tier models when a mid-tier one would do. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838))
|
||||
|
||||
- **OpenRouter Pareto Code router** — wire a new OpenRouter router with `min_coding_score` knob. Pick the cheapest model that meets your quality bar. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838))
|
||||
- **NovitaAI as a new model provider** — NovitaAI joins the provider lineup, giving you another option for open-source model hosting (Llama, Qwen, DeepSeek, etc.) with their pricing and rate limits. (salvage #7219) (@kshitijk4poor) ([#25507](https://github.com/NousResearch/hermes-agent/pull/25507))
|
||||
|
||||
- **Optional codex app-server runtime for OpenAI/Codex models** — drives the OpenAI Codex CLI under the hood for OpenAI/Codex paths, with session reuse, wedge retirement, and OAuth refresh classification. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769))
|
||||
- **Codex app-server runtime for OpenAI/Codex models** — An optional runtime that drives OpenAI's Codex CLI under the hood when you're using OpenAI or Codex paths. You get session reuse, automatic retirement of wedged sessions, and proper OAuth refresh classification — the kind of plumbing that makes long agentic runs not fall over. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769))
|
||||
|
||||
- **`hermes-skills/huggingface` as a trusted default tap** — community skills index from huggingface.co/skills is available by default in the Skills Hub. ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219))
|
||||
- **`huggingface/skills` as a trusted default tap** — The community skills index hosted at huggingface.co/skills is now wired into the Skills Hub by default. So when somebody publishes a useful skill there, you can install it from your own `hermes skills` browser without any extra config. (closes #2549) ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219))
|
||||
|
||||
- **9 new optional skills** — Hyperliquid (perp/spot trading via SDK + REST) (@kshitijk4poor & Hermes), Yahoo Finance market data, api-testing (REST/GraphQL debug), unified EVM multi-chain skill (folds #25291 + #2010 + base/), darwinian-evolver, osint-investigation (closes #355), pinggy-tunnel, watchers (RSS/HTTP/GitHub via cron), Notion overhaul for the Developer Platform (May 2026). ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612))
|
||||
- **9 new optional skills** — Hyperliquid (perp + spot trading via the SDK and REST API), Yahoo Finance (live market data, fundamentals, historicals), api-testing (REST + GraphQL debug recipes), unified EVM multi-chain (one skill covers Ethereum + L2s + Base), darwinian-evolver (evolutionary prompt/skill tuning), osint-investigation (OSINT recipes for people / domains / orgs), pinggy-tunnel (expose local services to the public internet), watchers (polls RSS / HTTP JSON / GitHub via cron `no_agent` mode for change detection), and a full Notion overhaul for the May 2026 Developer Platform. ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612))
|
||||
|
||||
- **API server exposes run approval events** — long-running runs surface approval requests over the API stream, no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899))
|
||||
- **API server exposes run approval events** — If you're driving Hermes programmatically through the HTTP API, long-running runs no longer silently hang when the agent hits an approval-required command. The approval request now surfaces on the API stream so your client can prompt the user and reply — no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899))
|
||||
|
||||
- **`/subgoal` — user-added criteria appended to active `/goal`** — layer extra success criteria onto a running goal loop. The judge sees them in the prompt, no behavior change when subgoals are empty. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449))
|
||||
- **Plugins can run any LLM call via `ctx.llm` + replace built-in tools via `tool_override`** — If you're writing a Hermes plugin, you now get first-class access to make LLM calls through the active provider and credentials — no manual client wiring. The new `tool_override` flag lets a plugin swap out a built-in tool with its own implementation cleanly. Plugin authors get the same model-routing and auth plumbing the core agent uses. (closes #11049) ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759))
|
||||
|
||||
- **Plugins can run any LLM call via `ctx.llm`** — plugins get a first-class hook to make their own LLM requests through the active provider/credentials, no manual wiring. Plus `tool_override` flag for replacing built-in tools. ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759))
|
||||
- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — Two new free web-search backends join Tavily, SearXNG, and Exa. Brave Search has a generous free tier; DDGS is the DuckDuckGo scraper that needs no key at all. Pick whichever fits your budget and rate-limit needs. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337))
|
||||
|
||||
- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — two new free search backends alongside Tavily / SearXNG / Exa. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337))
|
||||
- **Sudo brute-force block + 3 dangerous-command bypasses closed + tool-error sanitization** — The approval gate now blocks `sudo -S` brute-force attempts and classifies stdin-fed or askpass-stripped sudo invocations as DANGEROUS. Three known bypasses of dangerous-command detection are closed (inspired by Claude Code's command-detection work). And tool error strings are now sanitized before being re-injected into the model context, so a malicious file or remote service can't pass instructions to your agent through error output. ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736), [#26829](https://github.com/NousResearch/hermes-agent/pull/26829), [#26823](https://github.com/NousResearch/hermes-agent/pull/26823))
|
||||
|
||||
- **Sudo brute-force block + sudo-stdin/askpass DANGEROUS classification** — closes the `sudo -S` brute-force avenue; approval gates classify stdin-fed and askpass-stripped sudo invocations as dangerous. (salvages of #22194 + #21128) ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736))
|
||||
- **`/subgoal` — user-added criteria appended to an active `/goal`** — When you've got a `/goal` running (the persistent Ralph-loop goal where the agent keeps going until criteria are met), you can now use `/subgoal <text>` to layer extra success criteria onto it mid-run. The judge factors your new criteria into the done-or-keep-going decision without restarting the loop. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449))
|
||||
|
||||
- **Provider rename — Alibaba Cloud → Qwen Cloud, picker reorder** — matches what the world calls it. Existing config keys still work. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835))
|
||||
- **Provider rename — Alibaba Cloud → Qwen Cloud** — The Alibaba Cloud provider is renamed to Qwen Cloud in the picker and config to match what the rest of the world calls it. Existing config keys still work — no breaking changes — but the UI matches the actual brand now. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835))
|
||||
|
||||
- **Native Windows support (early beta)** — Hermes now runs natively on `cmd.exe` and PowerShell without WSL. A full PowerShell installer handles MinGit auto-install, Microsoft Store python stub detection, and the foreground Ctrl+C dance. There's still rough edges (this is the "early beta" stamp) — ~40 follow-up Windows-only fixes already landed in the window — but the basic loop works end-to-end on a clean Windows box. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561))
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
# Hermes Agent v0.15.0 (v2026.5.28)
|
||||
|
||||
**Release Date:** May 28, 2026
|
||||
**Since v0.14.0:** 1,302 commits · 747 merged PRs · 1,746 files changed · 282,712 insertions · 36,699 deletions · 560+ issues closed (15 P0, 65 P1, 19 security-tagged) · 321 community contributors (including co-authors)
|
||||
|
||||
> **The Velocity Release.** Hermes gets dramatically faster — to start, to run, to ship work, and to grow. The 16,083-line `run_agent.py` collapses to 3,821 (-76%) across 14 cohesive `agent/*` modules. Kanban grew into a real multi-agent platform across 104 PRs — orchestrator auto-decomposition, swarm topology, scheduled tasks, worktree-per-task, per-task model overrides. The cold-start perf wave keeps going: another second shaved off launch, 47% fewer per-conversation function calls, `hermes --version` flipping the head-to-head benchmark against Codex CLI. `session_search` is 4,500× faster and free now. Promptware defense lands against Brainworm-class attacks. Bitwarden Secrets Manager replaces N per-provider API keys with one bootstrap token. Skill bundles let one slash command load a whole workflow. The Ink TUI gets a multi-session orchestrator. Two new image_gen providers (Krea 2 Medium + Large, FAL ported to plugin), the Nous-approved MCP catalog with an interactive picker, an OpenHands orchestration skill, ntfy as the 23rd messaging platform, and a deep xAI integration round (Web Search plugin, xai-oauth `hermes proxy` upstream, retired-May-15 model detection + `hermes migrate xai`, natural TTS speech-tag pauses, base_url leak guard, OpenAI-style execution guidance for Grok). 15 P0 + 65 P1 closures alongside.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **The Big Refactor — `run_agent.py` is no longer 16,000 lines** — The file at the heart of Hermes — the agent conversation loop — has been reduced from 16,083 lines to 3,821 (-76%), with the extracted code redistributed across 14 cohesive modules under `agent/`. Behavior is unchanged: every extraction keeps a thin forwarder on `AIAgent`, every test patch path still works, every external caller is compatible. The reason you care: future Hermes development moves faster, plugin authors can finally grep the codebase, and the file that took 90 seconds to load in your editor opens in a blink. ([#27248](https://github.com/NousResearch/hermes-agent/pull/27248))
|
||||
|
||||
- **Kanban grew into a real multi-agent platform — 104 PRs end to end** — Triage auto-decomposes one task into a tree of sub-tasks. `hermes kanban swarm` creates a full Swarm v1 graph in one command — root, parallel workers, gated verifier, gated synthesizer, shared blackboard. Tasks support per-task model overrides (cheap models for boilerplate, expensive ones for hard sub-tasks), board-level default workdirs, per-task worktree paths and branches, scheduled start times, configurable claim TTL, retry fingerprinting, stale-task detection, respawn guards, and a drag-to-delete trash zone. Workers report through `/workers/active`, `/runs/{id}`, and `/inspect` endpoints. ([#27572](https://github.com/NousResearch/hermes-agent/pull/27572), [#28443](https://github.com/NousResearch/hermes-agent/pull/28443), [#28364](https://github.com/NousResearch/hermes-agent/pull/28364), [#28394](https://github.com/NousResearch/hermes-agent/pull/28394), [#28462](https://github.com/NousResearch/hermes-agent/pull/28462), [#28384](https://github.com/NousResearch/hermes-agent/pull/28384), [#28467](https://github.com/NousResearch/hermes-agent/pull/28467), [#28455](https://github.com/NousResearch/hermes-agent/pull/28455), [#28452](https://github.com/NousResearch/hermes-agent/pull/28452), [#28432](https://github.com/NousResearch/hermes-agent/pull/28432), [#28468](https://github.com/NousResearch/hermes-agent/pull/28468), [#28420](https://github.com/NousResearch/hermes-agent/pull/28420))
|
||||
|
||||
- **Cold-start perf wave keeps going — another second saved, 47% fewer per-turn function calls** — Three new optimization rounds: defer `openai._base_client` import (-240ms / -17MB on every CLI invocation), hot-path optimizations cut 47% of per-conversation function calls (399k → 213k for 31-turn chat), defer compression-feasibility check (-170 to -290ms on every agent construction), adaptive subprocess polling (-195ms per tool call, 1+ second per turn). Termux cold start drops from 2.9s to 0.8s. `hermes --version` cold drops 63% (701ms → 258ms), flipping the head-to-head benchmark against Codex CLI from 5/11 wins to 6/11. ([#28864](https://github.com/NousResearch/hermes-agent/pull/28864), [#28866](https://github.com/NousResearch/hermes-agent/pull/28866), [#28957](https://github.com/NousResearch/hermes-agent/pull/28957), [#29006](https://github.com/NousResearch/hermes-agent/pull/29006), [#29419](https://github.com/NousResearch/hermes-agent/pull/29419), [#30121](https://github.com/NousResearch/hermes-agent/pull/30121), [#30609](https://github.com/NousResearch/hermes-agent/pull/30609), [#31968](https://github.com/NousResearch/hermes-agent/pull/31968))
|
||||
|
||||
- **`session_search` rebuilt — no LLM, no cost, 4,500× faster** — The old `session_search` was an aux-LLM-powered tool that cost ~$0.30/call and took ~30 seconds to summarize three sessions, sometimes confabulating when the right session wasn't even in the FTS5 hit list. The new shape is one tool with three modes (discovery, scroll, browse) inferred from which args are set — no `mode` parameter, no aux-LLM, no config knob, no companion skill. Discovery is ~20ms instead of ~90s; scroll is ~1ms. Searching your past sessions for context is now free and instant. ([#27590](https://github.com/NousResearch/hermes-agent/pull/27590))
|
||||
|
||||
- **Promptware defense — Brainworm-class attacks blocked at three chokepoints** — Inspired by recent Brainworm / Promptware Kill Chain research (Origin HQ, arxiv 2601.09625), Hermes now defends the context window against prompt-injection attacks that try to hijack the agent via tool output, recalled memory, or stored skills. Single source of truth (`tools/threat_patterns.py`) with ~15 new Brainworm/C2 patterns; recalled memory is scanned at load time; tool results get delimiter markers so a malicious file or remote service can't impersonate Hermes' own system content. Paired with a new `security-guidance` plugin that pattern-matches dangerous code writes. ([#32269](https://github.com/NousResearch/hermes-agent/pull/32269), [#33131](https://github.com/NousResearch/hermes-agent/pull/33131), [#9151](https://github.com/NousResearch/hermes-agent/pull/9151))
|
||||
|
||||
- **Bitwarden Secrets Manager — one bootstrap token replaces every per-provider API key** — Stop keeping plaintext API keys in `~/.hermes/.env`. Install Bitwarden Secrets Manager (`bws` auto-installs lazily on first use), point Hermes at it with one bootstrap token (`BWS_ACCESS_TOKEN`), and every credential you need comes from Bitwarden at startup. Rotate a key in the Bitwarden web app and the rotation actually takes effect — Bitwarden defaults to source-of-truth so its values overwrite matching env vars on startup. Flip `secrets.bitwarden.override_existing: false` to invert. EU Cloud and self-hosted Bitwarden server URLs supported. Detected credentials are now labeled with their source so you can see at a glance which keys came from Bitwarden vs. the local env. ([#30035](https://github.com/NousResearch/hermes-agent/pull/30035), [#31378](https://github.com/NousResearch/hermes-agent/pull/31378), [#30364](https://github.com/NousResearch/hermes-agent/pull/30364))
|
||||
|
||||
- **ntfy as the 23rd messaging platform — push notifications without an account** — ntfy is the self-hostable push-notification service with no signup, no API key, just a topic URL. Hermes now adapts to it as a platform plugin (zero edits to core), so your agent can send you push notifications from any cron job, kanban task completion, or chat `send_message` — to your phone, your watch, your desktop, your homelab. (salvages [#30625](https://github.com/NousResearch/hermes-agent/pull/30625) → originally [#4043](https://github.com/NousResearch/hermes-agent/pull/4043)) ([#30867](https://github.com/NousResearch/hermes-agent/pull/30867))
|
||||
|
||||
- **Skill bundles — `/<name>` loads multiple skills at once** — A skill bundle is a named group of skills that loads them all together with one slash command. Set up your "writing day" bundle (humanizer + ideation + obsidian + youtube-content) and `/writing-day` activates all four for the session. Skills Hub now has health checks, a freshness badge, and a watchdog cron. Three new optional skills land: `code-wiki` (Karpathy's LLM-Wiki, persistent indexed dev wiki), `openhands` (delegate to OpenHands for parallel coding agents), and `web-pentest` (OWASP-style web pentest recipes). ([#28373](https://github.com/NousResearch/hermes-agent/pull/28373), [#32345](https://github.com/NousResearch/hermes-agent/pull/32345), [#32240](https://github.com/NousResearch/hermes-agent/pull/32240), [#32261](https://github.com/NousResearch/hermes-agent/pull/32261), [#32265](https://github.com/NousResearch/hermes-agent/pull/32265))
|
||||
|
||||
- **TUI session orchestrator — multiple live sessions in one TUI window** — The Ink TUI gained an active-session switcher overlay. List, switch between, refresh, and close multiple live process-local sessions without leaving the TUI; dispatch a new session with a session-scoped model picker. Plus a wave of TUI polish — mouse-tracking DEC mode presets, scrollback preservation across branches and termux, slash-dropdown fixes, x.com link rendering, and CJK / IME input rendering improvements. (salvages [#27642](https://github.com/NousResearch/hermes-agent/pull/27642)) ([#32980](https://github.com/NousResearch/hermes-agent/pull/32980), [#30084](https://github.com/NousResearch/hermes-agent/pull/30084))
|
||||
|
||||
- **Two new image_gen providers — Krea 2 Medium + Large, FAL ported to plugin** — Krea joins the image_gen lineup as a built-in plugin: `Krea 2 Medium` ($0.03) and `Krea 2 Large` ($0.06), auto-discovered, selectable via `hermes tools` → Image Generation → Krea. Available through both the native Krea plugin and the FAL.ai catalog. The FAL.ai backend got pulled out of the monolithic image-generation tool into `plugins/image_gen/fal/`, completing the four-way architectural parity already established by web, browser, and video_gen — new image providers are now one file, not a fork. ([#33236](https://github.com/NousResearch/hermes-agent/pull/33236), [#30380](https://github.com/NousResearch/hermes-agent/pull/30380), [#33506](https://github.com/NousResearch/hermes-agent/pull/33506))
|
||||
|
||||
- **Nous-approved MCP catalog with interactive picker** — A curated catalog of Nous-vetted MCP servers, mirroring the optional-skills shape. Run `hermes mcp` and you get an interactive picker; install with one keystroke, credentials prompted at install time and written to `~/.hermes/.env`. Ships with the n8n manifest first. Closes the discovery gap that left users hunting GitHub for trusted MCP servers. ([#30870](https://github.com/NousResearch/hermes-agent/pull/30870))
|
||||
|
||||
- **OpenHands orchestration skill** — A new optional skill under `optional-skills/autonomous-ai-agents/openhands/` lets the agent delegate coding tasks to the OpenHands CLI alongside `claude-code`, `codex`, and `opencode`. OpenHands is the model-agnostic member of that family — any LiteLLM-supported provider works (OpenAI, Anthropic, OpenRouter, your own), so you can route a sub-task to the cheapest model that can finish it. Drop-in worker for kanban swarms and `/delegate` flows. (closes [#477](https://github.com/NousResearch/hermes-agent/issues/477)) ([#32261](https://github.com/NousResearch/hermes-agent/pull/32261))
|
||||
|
||||
- **Deep xAI integration round — Web Search plugin, OAuth proxy upstream, May 15 retirement detection, natural TTS, security hardening** — Six interlocking xAI improvements:
|
||||
- **xAI Web Search** lands as a `plugins/web/xai/` provider, slots alongside Brave / Tavily / Exa / SearXNG / DDGS / Firecrawl — reuses your existing Grok OAuth or `XAI_API_KEY` credentials, no new env vars. ([#29042](https://github.com/NousResearch/hermes-agent/pull/29042))
|
||||
- **`hermes proxy` gains an xAI upstream** — your local OpenAI-compatible endpoint can now be backed by SuperGrok OAuth, no PKCE-refresh code to write in your client. ([#28356](https://github.com/NousResearch/hermes-agent/pull/28356))
|
||||
- **May 15 model retirement detection** — `grok-4`, `grok-4-fast{,-reasoning,-non-reasoning}`, `grok-3`, `grok-code-fast-1`, `grok-imagine-image-pro` etc. are detected in doctor and chat startup, with `hermes migrate xai` to one-shot config migration to the supported model. No more silent 404s after the retirement date. ([#29277](https://github.com/NousResearch/hermes-agent/pull/29277))
|
||||
- **Opt-in `auto_speech_tags`** for xAI TTS — inserts light `[pause]` tags between paragraphs and sentences for more natural-sounding voice replies. Default OFF. ([#29376](https://github.com/NousResearch/hermes-agent/pull/29376))
|
||||
- **`xai-oauth` `base_url` pinned to `x.ai` origin** — closes a silent credential-leak vector where `XAI_BASE_URL` could repoint OAuth-authenticated inference to an attacker-controlled host. ([#28952](https://github.com/NousResearch/hermes-agent/pull/28952))
|
||||
- **OpenAI-style execution guidance applied to Grok models** — Grok and xai-oauth now get the same family-specific execution discipline block GPT/Codex have, so the model stops claiming completion without tool calls and stops suggesting workarounds instead of using existing tools. ([#27797](https://github.com/NousResearch/hermes-agent/pull/27797))
|
||||
- Plus `x_search` degraded-results surfacing, tier-gated 403 with API-key fallback, PKCE `code_challenge` round-trip fix, dead-token quarantine on terminal refresh failure, MiniMax-style short-token refresh on per-request, and `WKE=unauthenticated` honor at both classifier sites. ([#29484](https://github.com/NousResearch/hermes-agent/pull/29484), [#28351](https://github.com/NousResearch/hermes-agent/pull/28351), [#27560](https://github.com/NousResearch/hermes-agent/pull/27560), [#28116](https://github.com/NousResearch/hermes-agent/pull/28116), [#30619](https://github.com/NousResearch/hermes-agent/pull/30619), [#30872](https://github.com/NousResearch/hermes-agent/pull/30872))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### The Big Refactor — `run_agent.py` 16k → 3.8k
|
||||
|
||||
- `run_agent.py` from 16,083 → 3,821 lines (-76%), extracted into 14 cohesive `agent/*` modules. `run_conversation` alone was 3,877 lines before the refactor. Every extraction keeps a thin forwarder on `AIAgent`, every test-patch path is preserved, every external caller stays compatible. ([#27248](https://github.com/NousResearch/hermes-agent/pull/27248))
|
||||
|
||||
### Agent loop & conversation
|
||||
|
||||
- Auxiliary task layered fallback (primary → chain → main agent → graceful fail) on capacity errors (402/429/connection). (salvages [#26811](https://github.com/NousResearch/hermes-agent/pull/26811) + [#26998](https://github.com/NousResearch/hermes-agent/pull/26998)) ([#27625](https://github.com/NousResearch/hermes-agent/pull/27625))
|
||||
- Buffer retry/fallback status; surface only on terminal failure (no more noisy "retrying..." spam in mid-run output). ([#33816](https://github.com/NousResearch/hermes-agent/pull/33816))
|
||||
- Host contract for external context engines — condenses 5 prior PRs into one extension surface. ([#33750](https://github.com/NousResearch/hermes-agent/pull/33750))
|
||||
- Fallback immediately on provider content-policy blocks. ([#33883](https://github.com/NousResearch/hermes-agent/pull/33883))
|
||||
- Re-pad `reasoning_content` on cross-provider fallback to require-side providers. (salvage [#33784](https://github.com/NousResearch/hermes-agent/pull/33784)) ([#33795](https://github.com/NousResearch/hermes-agent/pull/33795))
|
||||
- Per-turn tool-outcome verifier — patch tool gets indent preservation, CRLF preservation, per-file failure escalation. ([#32273](https://github.com/NousResearch/hermes-agent/pull/32273))
|
||||
- Single-knob native vision for custom-provider models. ([#29679](https://github.com/NousResearch/hermes-agent/pull/29679))
|
||||
- Background review fork isolated from external memory plugins. ([#27190](https://github.com/NousResearch/hermes-agent/pull/27190))
|
||||
- Background review inherits parent toolset config for `tools[]` cache parity. ([#29704](https://github.com/NousResearch/hermes-agent/pull/29704))
|
||||
- Recover from providers returning list-type tool content. ([#30259](https://github.com/NousResearch/hermes-agent/pull/30259))
|
||||
- Treat partial-stream stub responses as length truncation rather than clean stop. ([#30998](https://github.com/NousResearch/hermes-agent/pull/30998))
|
||||
- OpenAI execution guidance applied to xAI Grok / xai-oauth. ([#27797](https://github.com/NousResearch/hermes-agent/pull/27797))
|
||||
- ContextVars propagate to concurrent tool worker threads.
|
||||
- Preload `jiter` native parser. ([#33692](https://github.com/NousResearch/hermes-agent/pull/33692))
|
||||
- Expose context engine tools with saved toolsets. (salvage of [#31194](https://github.com/NousResearch/hermes-agent/pull/31194)) ([#33719](https://github.com/NousResearch/hermes-agent/pull/33719))
|
||||
|
||||
### Sessions & memory
|
||||
|
||||
- `session_search` rebuilt — single-shape (discovery + scroll + browse), no aux-LLM, ~20ms vs. ~90s. ([#27590](https://github.com/NousResearch/hermes-agent/pull/27590))
|
||||
- Salvage [#29182](https://github.com/NousResearch/hermes-agent/pull/29182) — opt-in JSON snapshot writer for sessions. ([#29278](https://github.com/NousResearch/hermes-agent/pull/29278))
|
||||
- Persist `platform_message_id` for recall across gateway restarts. ([#29449](https://github.com/NousResearch/hermes-agent/pull/29449))
|
||||
- Inline memory-context mentions stay visible in conversation. ([#28132](https://github.com/NousResearch/hermes-agent/pull/28132))
|
||||
- Recalled memory labeled informational, not authoritative. ([#28583](https://github.com/NousResearch/hermes-agent/pull/28583))
|
||||
- Memory + context-engine tool injection gated on `enabled_toolsets`. ([#30177](https://github.com/NousResearch/hermes-agent/pull/30177))
|
||||
- Guard against external drift in `MEMORY.md` / `USER.md`. ([#30877](https://github.com/NousResearch/hermes-agent/pull/30877))
|
||||
- Honcho runtime peer mapping — correctness follow-ups + setup wizard + docs. ([#30077](https://github.com/NousResearch/hermes-agent/pull/30077))
|
||||
- Periodic memory logging for leak detection. (salvage of [#17667](https://github.com/NousResearch/hermes-agent/pull/17667)) ([#27102](https://github.com/NousResearch/hermes-agent/pull/27102))
|
||||
|
||||
### Codex / Responses-API maturation
|
||||
|
||||
- TTFB watchdog for stalled Codex Responses streams. ([#32042](https://github.com/NousResearch/hermes-agent/pull/32042))
|
||||
- Actionable hint when stale-call detector fires on known silent-reject pattern. ([#32016](https://github.com/NousResearch/hermes-agent/pull/32016), [#33133](https://github.com/NousResearch/hermes-agent/pull/33133))
|
||||
- Drop SDK `responses.stream()` helper; consume events directly. ([#33042](https://github.com/NousResearch/hermes-agent/pull/33042))
|
||||
- Gracefully recover from `invalid_encrypted_content`. (salvage of [#10144](https://github.com/NousResearch/hermes-agent/pull/10144)) ([#33035](https://github.com/NousResearch/hermes-agent/pull/33035))
|
||||
- Recover Codex Responses streams with null output. ([#32963](https://github.com/NousResearch/hermes-agent/pull/32963), [#33390](https://github.com/NousResearch/hermes-agent/pull/33390))
|
||||
- Drop foreign-issuer reasoning and transient `rs_tmp` reasoning replay state. ([#33156](https://github.com/NousResearch/hermes-agent/pull/33156), [#33146](https://github.com/NousResearch/hermes-agent/pull/33146))
|
||||
- Codex 429 quota classified as rate-limit, not missing credentials. ([#33168](https://github.com/NousResearch/hermes-agent/pull/33168))
|
||||
- Codex chat path falls back to credential_pool when singleton is empty. ([#33189](https://github.com/NousResearch/hermes-agent/pull/33189))
|
||||
- Codex re-auth syncs credential_pool. ([#33164](https://github.com/NousResearch/hermes-agent/pull/33164))
|
||||
- Omit `tools` key when no tools registered. ([#33409](https://github.com/NousResearch/hermes-agent/pull/33409))
|
||||
- Parse Codex image-generation SSE directly. ([#32933](https://github.com/NousResearch/hermes-agent/pull/32933))
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ Kanban — Multi-Agent Maturation Wave
|
||||
|
||||
### Orchestration & dispatch
|
||||
|
||||
- Orchestrator-driven auto-decomposition on triage. ([#27572](https://github.com/NousResearch/hermes-agent/pull/27572))
|
||||
- Kanban swarm topology helper — `hermes kanban swarm` creates a Swarm v1 graph (root + parallel workers + gated verifier + gated synthesizer + shared blackboard). (salvages [#26791](https://github.com/NousResearch/hermes-agent/pull/26791) by @Niraven) ([#28443](https://github.com/NousResearch/hermes-agent/pull/28443))
|
||||
- Dispatcher wires review agents from the review column. ([#28449](https://github.com/NousResearch/hermes-agent/pull/28449))
|
||||
- Stale-detection for running tasks in dispatcher. ([#28452](https://github.com/NousResearch/hermes-agent/pull/28452))
|
||||
- Respawn guard blocks repeat worker storms. ([#28455](https://github.com/NousResearch/hermes-agent/pull/28455))
|
||||
- Respawn guard defers `blocker_auth` instead of auto-blocking. ([#28683](https://github.com/NousResearch/hermes-agent/pull/28683))
|
||||
- Cross-profile cron jobs surface in dashboard. ([#28457](https://github.com/NousResearch/hermes-agent/pull/28457))
|
||||
- Worker visibility endpoints: `/workers/active`, `/runs/{id}`, `/inspect`. (salvages [#23761](https://github.com/NousResearch/hermes-agent/pull/23761) by @Interstellar-code) ([#28432](https://github.com/NousResearch/hermes-agent/pull/28432))
|
||||
|
||||
### Task configuration & scheduling
|
||||
|
||||
- Per-task model override. ([#28364](https://github.com/NousResearch/hermes-agent/pull/28364))
|
||||
- Board-level default workdir. ([#28394](https://github.com/NousResearch/hermes-agent/pull/28394))
|
||||
- Configurable worktree paths and branches. ([#28462](https://github.com/NousResearch/hermes-agent/pull/28462))
|
||||
- Scheduled task start times. ([#28384](https://github.com/NousResearch/hermes-agent/pull/28384))
|
||||
- Scheduled status for delayed follow-ups. ([#28467](https://github.com/NousResearch/hermes-agent/pull/28467))
|
||||
- Trimmed task comments. ([#28399](https://github.com/NousResearch/hermes-agent/pull/28399))
|
||||
- Initial-status for human-ops cards. ([#28414](https://github.com/NousResearch/hermes-agent/pull/28414))
|
||||
- `max_in_progress` config to cap concurrent running tasks. ([#28420](https://github.com/NousResearch/hermes-agent/pull/28420))
|
||||
- Filter tasks by workflow fields. ([#28454](https://github.com/NousResearch/hermes-agent/pull/28454))
|
||||
- `--sort` for `hermes kanban list`. ([#28427](https://github.com/NousResearch/hermes-agent/pull/28427))
|
||||
- Optional `board` parameter on all MCP tools. ([#28444](https://github.com/NousResearch/hermes-agent/pull/28444))
|
||||
- Stamp originating ACP session_id on tasks. ([#28447](https://github.com/NousResearch/hermes-agent/pull/28447))
|
||||
- `auto_promote_children` config toggle. ([#28344](https://github.com/NousResearch/hermes-agent/pull/28344))
|
||||
- `archive --rm` to hard-delete archived tasks. ([#28355](https://github.com/NousResearch/hermes-agent/pull/28355))
|
||||
- Promote dependents when parent is archived. ([#28372](https://github.com/NousResearch/hermes-agent/pull/28372))
|
||||
- Promote blocked tasks when parent dependencies complete. ([#28377](https://github.com/NousResearch/hermes-agent/pull/28377))
|
||||
- Demote ready children when parent is reopened. ([#28382](https://github.com/NousResearch/hermes-agent/pull/28382))
|
||||
- `promote` verb for manual `todo→ready` recovery + bulk `--ids`. (salvage [#29464](https://github.com/NousResearch/hermes-agent/pull/29464)) ([#31334](https://github.com/NousResearch/hermes-agent/pull/31334))
|
||||
|
||||
### Dashboard
|
||||
|
||||
- Drag-to-delete trash zone + bulk delete. ([#28468](https://github.com/NousResearch/hermes-agent/pull/28468))
|
||||
- Surface per-task `model_override` in show + tool output. ([#28442](https://github.com/NousResearch/hermes-agent/pull/28442))
|
||||
- Cross-profile notification delivery via `kanban.notification_sources`. ([#28395](https://github.com/NousResearch/hermes-agent/pull/28395))
|
||||
- Scratch-workspace deletion warning for users. ([#30949](https://github.com/NousResearch/hermes-agent/pull/30949))
|
||||
- Mobile dashboard UX polish. ([#28127](https://github.com/NousResearch/hermes-agent/pull/28127))
|
||||
|
||||
### Reliability
|
||||
|
||||
- Worker log retention configurable. ([#27867](https://github.com/NousResearch/hermes-agent/pull/27867))
|
||||
- Configurable claim TTL. ([#28392](https://github.com/NousResearch/hermes-agent/pull/28392))
|
||||
- Fingerprint crash errors to prevent fleet-wide retry exhaustion. ([#28380](https://github.com/NousResearch/hermes-agent/pull/28380))
|
||||
- Reset failure counters on `unblock_task`. ([#28379](https://github.com/NousResearch/hermes-agent/pull/28379))
|
||||
- Detect cycles in `decompose_triage_task` sibling-link pre-validation. ([#28088](https://github.com/NousResearch/hermes-agent/pull/28088))
|
||||
- Surface unusable triage auxiliary model (auto-decompose aware). ([#27871](https://github.com/NousResearch/hermes-agent/pull/27871))
|
||||
- Align failure diagnostics with retry limit. ([#27868](https://github.com/NousResearch/hermes-agent/pull/27868))
|
||||
- Align worker terminal timeout with task runtime. ([#27864](https://github.com/NousResearch/hermes-agent/pull/27864))
|
||||
- Auto-install bundled skills (kanban-worker) on init. ([#28368](https://github.com/NousResearch/hermes-agent/pull/28368))
|
||||
- Make legacy task migration idempotent. ([#28397](https://github.com/NousResearch/hermes-agent/pull/28397))
|
||||
- Serialize DB initialization. ([#28383](https://github.com/NousResearch/hermes-agent/pull/28383))
|
||||
- Persist worker session metadata on completion. ([#28387](https://github.com/NousResearch/hermes-agent/pull/28387))
|
||||
- Pass `accept-hooks` to worker chat subprocess. ([#28393](https://github.com/NousResearch/hermes-agent/pull/28393))
|
||||
- Preserve worker tools with restricted toolsets. ([#28396](https://github.com/NousResearch/hermes-agent/pull/28396))
|
||||
- Avoid unsafe Windows worker Hermes shim resolution. ([#28398](https://github.com/NousResearch/hermes-agent/pull/28398))
|
||||
- Sync slash subcommands with live parser. ([#28376](https://github.com/NousResearch/hermes-agent/pull/28376))
|
||||
- Show scheduled kanban tasks in dashboard. ([#28400](https://github.com/NousResearch/hermes-agent/pull/28400))
|
||||
- Assign single-task kanban decompositions. ([#28401](https://github.com/NousResearch/hermes-agent/pull/28401))
|
||||
- Configurable `max_tokens` for kanban specify. ([#28374](https://github.com/NousResearch/hermes-agent/pull/28374))
|
||||
- Per-job profile support for cron. ([#28124](https://github.com/NousResearch/hermes-agent/pull/28124))
|
||||
- Codex app-server: include every Kanban-pinned path in `writable_roots`. ([#28435](https://github.com/NousResearch/hermes-agent/pull/28435))
|
||||
- Cache kanban worker guidance at session init for prompt-cache reuse. ([#28425](https://github.com/NousResearch/hermes-agent/pull/28425))
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- `openai._base_client` import deferred — 240ms / 17MB off every CLI cold start. ([#28864](https://github.com/NousResearch/hermes-agent/pull/28864))
|
||||
- Agent-loop hot-path optimizations — 47% fewer per-conversation function calls (399k → 213k for 31-turn chat). ([#28866](https://github.com/NousResearch/hermes-agent/pull/28866))
|
||||
- Compression-feasibility check deferred — 170-290ms off every agent construction. ([#28957](https://github.com/NousResearch/hermes-agent/pull/28957))
|
||||
- Adaptive subprocess poll — ~195ms off every tool call, 1+ second per turn. ([#29006](https://github.com/NousResearch/hermes-agent/pull/29006))
|
||||
- Termux TUI cold start speedup. ([#29419](https://github.com/NousResearch/hermes-agent/pull/29419))
|
||||
- Termux non-TUI cold start speedup. (salvage [#29438](https://github.com/NousResearch/hermes-agent/pull/29438)) ([#30121](https://github.com/NousResearch/hermes-agent/pull/30121))
|
||||
- Termux fast-path version + deferred bare-prompt agent startup. ([#30609](https://github.com/NousResearch/hermes-agent/pull/30609))
|
||||
- Cut hermes `--version` wall time 63% — flips head-to-head vs Codex CLI. ([#31968](https://github.com/NousResearch/hermes-agent/pull/31968))
|
||||
- Date-only timestamp + loud gateway-DB roundtrip logging — improves prompt-cache hit rate. ([#27675](https://github.com/NousResearch/hermes-agent/pull/27675))
|
||||
- Cache kanban worker guidance at session init for prompt-cache reuse. ([#28425](https://github.com/NousResearch/hermes-agent/pull/28425))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### Tool surface
|
||||
|
||||
- `patch`: indent preservation, CRLF preservation, per-file failure escalation. ([#32273](https://github.com/NousResearch/hermes-agent/pull/32273))
|
||||
- `terminal`: warn at call time when `background=true` runs silently. ([#31289](https://github.com/NousResearch/hermes-agent/pull/31289))
|
||||
- `terminal`: nudge homebrewed CI pollers at the tool surface. ([#33142](https://github.com/NousResearch/hermes-agent/pull/33142))
|
||||
- `x_search`: surface degraded results + validate dates. ([#29484](https://github.com/NousResearch/hermes-agent/pull/29484))
|
||||
- `x_search`: auto-enable toolset when xAI credentials are configured. ([#27376](https://github.com/NousResearch/hermes-agent/pull/27376))
|
||||
- `computer_use`: route SOM/vision captures via auxiliary.vision. ([#30126](https://github.com/NousResearch/hermes-agent/pull/30126))
|
||||
- `transcription`: reject symlinked audio inputs. ([#10082](https://github.com/NousResearch/hermes-agent/pull/10082))
|
||||
- TTS: prevent double `[pause]` in xAI auto speech tags. ([#32237](https://github.com/NousResearch/hermes-agent/pull/32237))
|
||||
- TTS: preserve native audio outside Telegram voice delivery. ([#28512](https://github.com/NousResearch/hermes-agent/pull/28512))
|
||||
- TTS: opt-in xAI `auto_speech_tags` speech-tag pauses for natural voice replies. ([#29376](https://github.com/NousResearch/hermes-agent/pull/29376))
|
||||
- Voice: chunk oversized CLI recordings. ([#30044](https://github.com/NousResearch/hermes-agent/pull/30044))
|
||||
- Voice: honor `PULSE_SERVER` / `PIPEWIRE_REMOTE` inside Docker. ([#22534](https://github.com/NousResearch/hermes-agent/pull/22534))
|
||||
|
||||
### Browser
|
||||
|
||||
- All cloud browser providers (Browserbase, Anchor, Camofox, Hyperbrowser, etc.) migrated to image_gen-style plugins. (salvages [#25580](https://github.com/NousResearch/hermes-agent/pull/25580)) ([#27403](https://github.com/NousResearch/hermes-agent/pull/27403))
|
||||
- Auto-launch Chromium-family browser for CDP. ([#29106](https://github.com/NousResearch/hermes-agent/pull/29106))
|
||||
- Docker: discover agent-browser Chromium binary at boot. ([#33184](https://github.com/NousResearch/hermes-agent/pull/33184))
|
||||
|
||||
### Image generation
|
||||
|
||||
- **Krea** provider plugin (Krea 2 Medium + Large). ([#33236](https://github.com/NousResearch/hermes-agent/pull/33236))
|
||||
- FAL backend ported to `plugins/image_gen/fal`. (salvage [#27966](https://github.com/NousResearch/hermes-agent/pull/27966)) ([#30380](https://github.com/NousResearch/hermes-agent/pull/30380))
|
||||
- Cache xAI ephemeral URL responses to disk. ([#31759](https://github.com/NousResearch/hermes-agent/pull/31759))
|
||||
|
||||
### Web search
|
||||
|
||||
- **xAI Web Search** as a provider plugin. ([#29042](https://github.com/NousResearch/hermes-agent/pull/29042))
|
||||
|
||||
### MCP
|
||||
|
||||
- **Nous-approved MCP catalog** with interactive picker. ([#30870](https://github.com/NousResearch/hermes-agent/pull/30870))
|
||||
- **TLS client certificate (mTLS) support** for HTTP and SSE MCP servers. ([#33721](https://github.com/NousResearch/hermes-agent/pull/33721))
|
||||
- Stdin paste-back fallback for headless OAuth flow. ([#32053](https://github.com/NousResearch/hermes-agent/pull/32053))
|
||||
- `skip` at paste prompt bypasses auth without disabling server. ([#32069](https://github.com/NousResearch/hermes-agent/pull/32069))
|
||||
- Registry-aware `mcp_` prefix on both ends of round-trip. ([#31700](https://github.com/NousResearch/hermes-agent/pull/31700))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills Ecosystem
|
||||
|
||||
### Skills system
|
||||
|
||||
- **Skill bundles** — `/<name>` loads multiple skills. ([#28373](https://github.com/NousResearch/hermes-agent/pull/28373))
|
||||
- Skills Hub: health checks, freshness badge, and a watchdog cron. ([#32345](https://github.com/NousResearch/hermes-agent/pull/32345))
|
||||
- Opt-in AST deep diagnostics on skill writes. (salvage of [#30918](https://github.com/NousResearch/hermes-agent/pull/30918)) ([#31198](https://github.com/NousResearch/hermes-agent/pull/31198))
|
||||
- Bundled/pinned skill protection in background-review prompts. ([#28338](https://github.com/NousResearch/hermes-agent/pull/28338))
|
||||
- Show user-modified skill names in bundled skill sync summary. ([#28671](https://github.com/NousResearch/hermes-agent/pull/28671))
|
||||
- Load symlinked skill slash commands. ([#27759](https://github.com/NousResearch/hermes-agent/pull/27759))
|
||||
- Deduplicate Skills Hub search results by identifier, not name. ([#29490](https://github.com/NousResearch/hermes-agent/pull/29490))
|
||||
|
||||
### New skills
|
||||
|
||||
- `openhands` — delegate-to-OpenHands orchestration skill (closes [#477](https://github.com/NousResearch/hermes-agent/issues/477)) ([#32261](https://github.com/NousResearch/hermes-agent/pull/32261))
|
||||
- `code-wiki` — persistent indexed dev wiki (closes [#486](https://github.com/NousResearch/hermes-agent/issues/486)) ([#32240](https://github.com/NousResearch/hermes-agent/pull/32240))
|
||||
- `web-pentest` — OWASP recipes (closes [#400](https://github.com/NousResearch/hermes-agent/issues/400)) ([#32265](https://github.com/NousResearch/hermes-agent/pull/32265))
|
||||
- `baoyu-article-illustrator` ([#28287](https://github.com/NousResearch/hermes-agent/pull/28287))
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Providers
|
||||
|
||||
### xAI deep integration
|
||||
|
||||
- **xAI Web Search** as a `plugins/web/xai/` provider plugin. ([#29042](https://github.com/NousResearch/hermes-agent/pull/29042))
|
||||
- **`hermes proxy` xAI upstream** — OpenAI-compatible local proxy backed by xai-oauth. ([#28356](https://github.com/NousResearch/hermes-agent/pull/28356))
|
||||
- **May 15 model retirement detection + `hermes migrate xai`** for grok-4 / grok-3 / grok-code-fast-1 / grok-imagine-image-pro. ([#29277](https://github.com/NousResearch/hermes-agent/pull/29277))
|
||||
- **Opt-in `auto_speech_tags`** for natural xAI TTS voice replies. ([#29376](https://github.com/NousResearch/hermes-agent/pull/29376))
|
||||
- **xai-oauth base_url pinned to x.ai origin** — closes silent credential-leak vector. ([#28952](https://github.com/NousResearch/hermes-agent/pull/28952))
|
||||
- **OpenAI-style execution guidance** applied to Grok / xai-oauth models. ([#27797](https://github.com/NousResearch/hermes-agent/pull/27797))
|
||||
- xAI: detect retired May 15 models in doctor/chat startup. ([#29277](https://github.com/NousResearch/hermes-agent/pull/29277))
|
||||
- xAI: resolve Grok Build context for OAuth. ([#30579](https://github.com/NousResearch/hermes-agent/pull/30579))
|
||||
- xAI OAuth: tier-gated 403 with API-key fallback. ([#28351](https://github.com/NousResearch/hermes-agent/pull/28351))
|
||||
- xAI OAuth: PKCE `code_challenge` echo. ([#27560](https://github.com/NousResearch/hermes-agent/pull/27560))
|
||||
- xAI OAuth: quarantine dead tokens on terminal refresh failure. ([#28116](https://github.com/NousResearch/hermes-agent/pull/28116))
|
||||
- xAI OAuth: honor `WKE=unauthenticated` disambiguator at both classifier sites. ([#30872](https://github.com/NousResearch/hermes-agent/pull/30872))
|
||||
- xAI OAuth: accept bare-code manual paste (state=None). (closes [#26923](https://github.com/NousResearch/hermes-agent/issues/26923)) ([#33880](https://github.com/NousResearch/hermes-agent/pull/33880))
|
||||
- xAI OAuth: fall back to manual paste on loopback timeout. ([#33231](https://github.com/NousResearch/hermes-agent/pull/33231))
|
||||
- xAI proxy: handle 429 rate-limit responses in proxy retry path. ([#33743](https://github.com/NousResearch/hermes-agent/pull/33743))
|
||||
|
||||
### Other providers
|
||||
|
||||
- **OpenAI API as a first-class provider** (distinct from Codex runtime). ([#31898](https://github.com/NousResearch/hermes-agent/pull/31898))
|
||||
- **Microsoft Entra ID** auth for Azure Foundry (with 1M Anthropic-Messages beta preserved on Bearer). (salvages [#27509](https://github.com/NousResearch/hermes-agent/pull/27509), [#27022](https://github.com/NousResearch/hermes-agent/pull/27022)) ([#28101](https://github.com/NousResearch/hermes-agent/pull/28101), [#28084](https://github.com/NousResearch/hermes-agent/pull/28084))
|
||||
- **OpenRouter** sticky routing — `session_id` passed via `extra_body` so a long-running session keeps landing on the same upstream provider. (@Cybourgeoisie) ([#33939](https://github.com/NousResearch/hermes-agent/pull/33939))
|
||||
- Nous: JWT token for inference; stop replaying invalid Nous refresh tokens. (@rewbs) ([#27663](https://github.com/NousResearch/hermes-agent/pull/27663))
|
||||
- Nous Portal: one-shot setup, status CLI, and Nous-included markers. ([#30860](https://github.com/NousResearch/hermes-agent/pull/30860))
|
||||
- Anthropic adapter: extract 7 helpers from `convert_messages_to_anthropic`. (salvage [#27784](https://github.com/NousResearch/hermes-agent/pull/27784)) ([#30386](https://github.com/NousResearch/hermes-agent/pull/30386))
|
||||
- Catalog: add `qwen3.7-max` to Alibaba + Alibaba-Coding-Plan model lists. ([#33129](https://github.com/NousResearch/hermes-agent/pull/33129))
|
||||
- opencode-go: route `qwen3.7-max` via `anthropic_messages`. (@beardthelion) ([#32780](https://github.com/NousResearch/hermes-agent/pull/32780))
|
||||
- opencode-go: expose Kimi K2 + DeepSeek reasoning controls. ([#30845](https://github.com/NousResearch/hermes-agent/pull/30845))
|
||||
- Remove Vercel AI Gateway and Vercel Sandbox.
|
||||
- MiniMax OAuth: refresh short-lived access tokens per request. ([#30619](https://github.com/NousResearch/hermes-agent/pull/30619))
|
||||
- Codex OAuth: quarantine terminal refresh errors. ([#28118](https://github.com/NousResearch/hermes-agent/pull/28118))
|
||||
- Codex: drop dead model slugs that HTTP 400 on ChatGPT Pro. ([#33424](https://github.com/NousResearch/hermes-agent/pull/33424))
|
||||
- Codex: sync `manual:device_code` pool entries on re-auth. ([#33744](https://github.com/NousResearch/hermes-agent/pull/33744))
|
||||
- MiniMax OAuth: quarantine terminal refresh errors. ([#28119](https://github.com/NousResearch/hermes-agent/pull/28119))
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Secrets
|
||||
|
||||
- **Bitwarden Secrets Manager** integration with lazy `bws` install. ([#30035](https://github.com/NousResearch/hermes-agent/pull/30035))
|
||||
- Bitwarden: EU Cloud + self-hosted server URL support. ([#31378](https://github.com/NousResearch/hermes-agent/pull/31378))
|
||||
- Label detected credentials with their source (Bitwarden). ([#30364](https://github.com/NousResearch/hermes-agent/pull/30364))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### Gateway core
|
||||
|
||||
- **Deliverable mode** — agents ship artifacts as native uploads from any platform (Slack/Discord/Telegram/Teams/Email). ([#27813](https://github.com/NousResearch/hermes-agent/pull/27813))
|
||||
- `hermes send` — pipe any script's output to any messaging platform. (salvage of [#19631](https://github.com/NousResearch/hermes-agent/pull/19631)) ([#27188](https://github.com/NousResearch/hermes-agent/pull/27188))
|
||||
- Debounce queued text follow-ups during active sessions. (salvage of [#31235](https://github.com/NousResearch/hermes-agent/pull/31235)) ([#31341](https://github.com/NousResearch/hermes-agent/pull/31341))
|
||||
- Plugin-transformed final_response delivered through streaming gate. ([#31433](https://github.com/NousResearch/hermes-agent/pull/31433))
|
||||
- Refresh cached agent tools on `/reload-mcp`. ([#32815](https://github.com/NousResearch/hermes-agent/pull/32815))
|
||||
- Harden kanban + provider cleanup races on long-running workloads. ([#29479](https://github.com/NousResearch/hermes-agent/pull/29479))
|
||||
|
||||
### New / reorganized adapters
|
||||
|
||||
- **ntfy** — 23rd platform, push notifications, plugin shape, zero core edits. (salvages [#30625](https://github.com/NousResearch/hermes-agent/pull/30625) → [#4043](https://github.com/NousResearch/hermes-agent/pull/4043)) ([#30867](https://github.com/NousResearch/hermes-agent/pull/30867))
|
||||
- **Discord** adapter migrated to bundled plugin. (salvage of [#24356](https://github.com/NousResearch/hermes-agent/pull/24356)) ([#30591](https://github.com/NousResearch/hermes-agent/pull/30591))
|
||||
- **Mattermost** adapter migrated to bundled plugin. (salvage of [#30916](https://github.com/NousResearch/hermes-agent/pull/30916)) ([#31748](https://github.com/NousResearch/hermes-agent/pull/31748))
|
||||
|
||||
### Telegram
|
||||
|
||||
- Edit status messages in place instead of appending. (based on [#30141](https://github.com/NousResearch/hermes-agent/pull/30141) by @qike-ms) ([#30864](https://github.com/NousResearch/hermes-agent/pull/30864))
|
||||
- Skip-STT audio path + 2GB cap via local Bot API server. ([#28541](https://github.com/NousResearch/hermes-agent/pull/28541))
|
||||
- Route image documents (.png/.jpg/.webp/.gif) through vision pipeline. ([#28519](https://github.com/NousResearch/hermes-agent/pull/28519))
|
||||
- Route audio file attachments away from STT pipeline. ([#28478](https://github.com/NousResearch/hermes-agent/pull/28478))
|
||||
- `disable_topic_auto_rename` gateway flag. ([#28523](https://github.com/NousResearch/hermes-agent/pull/28523))
|
||||
- `ignore_root_dm` config to drop messages without thread_id. ([#28536](https://github.com/NousResearch/hermes-agent/pull/28536))
|
||||
- Chat-scoped auth without sender user_id. ([#28525](https://github.com/NousResearch/hermes-agent/pull/28525))
|
||||
- Fail-closed auth fallback when `TELEGRAM_ALLOWED_USERS` is empty. ([#28494](https://github.com/NousResearch/hermes-agent/pull/28494))
|
||||
- Roll over tool progress bubbles + scope audio_file_paths. ([#28482](https://github.com/NousResearch/hermes-agent/pull/28482))
|
||||
- Avoid duplicate text after auto-TTS voice replies. ([#28509](https://github.com/NousResearch/hermes-agent/pull/28509))
|
||||
- Mark final voice reply notify-worthy so Telegram delivers it audibly. ([#28504](https://github.com/NousResearch/hermes-agent/pull/28504))
|
||||
|
||||
### Discord
|
||||
|
||||
- Recover Windows voice opus decoding. ([#33182](https://github.com/NousResearch/hermes-agent/pull/33182))
|
||||
- `allow_any_attachment` config to accept arbitrary file types. ([#27245](https://github.com/NousResearch/hermes-agent/pull/27245))
|
||||
- Transcribe native voice notes. ([#28993](https://github.com/NousResearch/hermes-agent/pull/28993))
|
||||
- Define UI view classes after lazy install. ([#28817](https://github.com/NousResearch/hermes-agent/pull/28817))
|
||||
|
||||
### Signal / Matrix / Feishu / Slack / WeCom
|
||||
|
||||
- Signal: `require_mention` filter for group chats. ([#28574](https://github.com/NousResearch/hermes-agent/pull/28574))
|
||||
- Matrix: warn on clock-skew silent message drops. ([#27330](https://github.com/NousResearch/hermes-agent/pull/27330))
|
||||
- Matrix E2EE installs full dep set; plugins respect `is_connected`. ([#31688](https://github.com/NousResearch/hermes-agent/pull/31688))
|
||||
- Feishu: require webhook auth secret + honor config extras. ([#30746](https://github.com/NousResearch/hermes-agent/pull/30746))
|
||||
- Feishu: enforce auth and chat binding for approval buttons. ([#30744](https://github.com/NousResearch/hermes-agent/pull/30744))
|
||||
- Slack: socket recovery + Windows restart dedupe. ([#28873](https://github.com/NousResearch/hermes-agent/pull/28873))
|
||||
- WeCom: safe-parse untrusted XML. ([#32442](https://github.com/NousResearch/hermes-agent/pull/32442))
|
||||
|
||||
### DingTalk / Webhooks / Microsoft Graph
|
||||
|
||||
- DingTalk: transcribe native voice notes. ([#28993](https://github.com/NousResearch/hermes-agent/pull/28993))
|
||||
- Webhook: enforce `INSECURE_NO_AUTH` safety rail on dynamic route reloads. ([#30863](https://github.com/NousResearch/hermes-agent/pull/30863))
|
||||
- Webhook: restrict default toolset capabilities. ([#30745](https://github.com/NousResearch/hermes-agent/pull/30745))
|
||||
- Microsoft Graph: harden webhook auth requirements. ([#30169](https://github.com/NousResearch/hermes-agent/pull/30169))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & TUI
|
||||
|
||||
### CLI
|
||||
|
||||
- `/update` slash command in CLI and TUI. ([#23854](https://github.com/NousResearch/hermes-agent/pull/23854))
|
||||
- Update auto-rollback when post-pull syntax check fails. ([#28669](https://github.com/NousResearch/hermes-agent/pull/28669))
|
||||
- `--branch` flag for `hermes update`. (@jquesnelle) ([#29591](https://github.com/NousResearch/hermes-agent/pull/29591))
|
||||
- `/exit --delete` flag to remove session on quit. (salvage of [#17665](https://github.com/NousResearch/hermes-agent/pull/17665)) ([#27101](https://github.com/NousResearch/hermes-agent/pull/27101))
|
||||
- `▶ N` indicator in status bar for running `/background` tasks. ([#27175](https://github.com/NousResearch/hermes-agent/pull/27175))
|
||||
- Live background terminal-process count in status bar. ([#32061](https://github.com/NousResearch/hermes-agent/pull/32061))
|
||||
- Append session recap to `/status` output. (salvage of [#18587](https://github.com/NousResearch/hermes-agent/pull/18587)) ([#27176](https://github.com/NousResearch/hermes-agent/pull/27176))
|
||||
- Configurable paste-collapse thresholds (TUI + CLI). (salvage [#29723](https://github.com/NousResearch/hermes-agent/pull/29723)) ([#32087](https://github.com/NousResearch/hermes-agent/pull/32087))
|
||||
- `/resume` accepts position numbers. ([#31709](https://github.com/NousResearch/hermes-agent/pull/31709))
|
||||
- Bring tool-call display back — verbose mode, specific failure reasons, todo progress. ([#31293](https://github.com/NousResearch/hermes-agent/pull/31293))
|
||||
- Validate runtime token refresh in Qwen auth status. ([#31196](https://github.com/NousResearch/hermes-agent/pull/31196))
|
||||
|
||||
### TUI
|
||||
|
||||
- **TUI session orchestrator** — multiple live sessions in one TUI window. (salvages [#27642](https://github.com/NousResearch/hermes-agent/pull/27642)) ([#32980](https://github.com/NousResearch/hermes-agent/pull/32980))
|
||||
- `mouse_tracking` DEC mode presets. (salvage of [#26681](https://github.com/NousResearch/hermes-agent/pull/26681) by @OutThisLife) ([#30084](https://github.com/NousResearch/hermes-agent/pull/30084))
|
||||
- Termux scrollback preservation + touch-friendly defaults. ([#28910](https://github.com/NousResearch/hermes-agent/pull/28910))
|
||||
- Full assistant text in scrollback (no history truncation). ([#28829](https://github.com/NousResearch/hermes-agent/pull/28829))
|
||||
- Preserve scrollback when branching sessions. ([#30162](https://github.com/NousResearch/hermes-agent/pull/30162))
|
||||
- Preserve Python dunder identifiers in markdown. ([#28582](https://github.com/NousResearch/hermes-agent/pull/28582))
|
||||
- Active profile shown in TUI prompt. ([#28581](https://github.com/NousResearch/hermes-agent/pull/28581))
|
||||
- Improve Charizard completion menu contrast. ([#28346](https://github.com/NousResearch/hermes-agent/pull/28346))
|
||||
- Stop slash dropdown chopping last char of `/goal`. ([#31311](https://github.com/NousResearch/hermes-agent/pull/31311))
|
||||
- Clipboard copy on linux/wayland. ([#29342](https://github.com/NousResearch/hermes-agent/pull/29342))
|
||||
- Anchor `splitReasoning` unclosed-tag regex; stop eating last paragraph. ([#29426](https://github.com/NousResearch/hermes-agent/pull/29426))
|
||||
- Surface verbose tool details. ([#30225](https://github.com/NousResearch/hermes-agent/pull/30225))
|
||||
- Load Linux skills on Termux + salvage @adybag14-cyber's Termux gates. ([#30166](https://github.com/NousResearch/hermes-agent/pull/30166))
|
||||
- Handle images with codex app-server. ([#31220](https://github.com/NousResearch/hermes-agent/pull/31220))
|
||||
- Refresh virtual transcript on viewport resize. ([#31077](https://github.com/NousResearch/hermes-agent/pull/31077))
|
||||
- Ignore late thinking deltas after completion. ([#31055](https://github.com/NousResearch/hermes-agent/pull/31055))
|
||||
- Commit composer input bursts immediately. ([#31053](https://github.com/NousResearch/hermes-agent/pull/31053))
|
||||
- Log parent gateway lifecycle exits. ([#31051](https://github.com/NousResearch/hermes-agent/pull/31051))
|
||||
- Clear TTS env var on voice off + TTS indicator in status bar. ([#30987](https://github.com/NousResearch/hermes-agent/pull/30987))
|
||||
- Pass `--expose-gc` as node argv instead of NODE_OPTIONS. ([#29998](https://github.com/NousResearch/hermes-agent/pull/29998))
|
||||
- Align composer cursorLayout with wrap-ansi to kill multiline cursor drift. ([#27489](https://github.com/NousResearch/hermes-agent/pull/27489))
|
||||
- Harden Terminal.app rendering and color paths. ([#27251](https://github.com/NousResearch/hermes-agent/pull/27251))
|
||||
- Keep `/goal` verdict out of compact status row. ([#27971](https://github.com/NousResearch/hermes-agent/pull/27971))
|
||||
- Clamp curses color 8 for 8-color terminals (Docker). ([#30260](https://github.com/NousResearch/hermes-agent/pull/30260))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
### Promptware & memory hardening
|
||||
|
||||
- **Promptware defense** — shared threat patterns + memory load-time scan + tool-result delimiters. ([#32269](https://github.com/NousResearch/hermes-agent/pull/32269))
|
||||
- Expand memory content scanning patterns to parity with skills guard. ([#9151](https://github.com/NousResearch/hermes-agent/pull/9151))
|
||||
- Harden Skills Guard multi-word prompt patterns. (@YLChen-007) ([#26852](https://github.com/NousResearch/hermes-agent/pull/26852))
|
||||
- Split cron scanner so skill prose stops false-positiving exfil patterns. ([#32339](https://github.com/NousResearch/hermes-agent/pull/32339))
|
||||
|
||||
### File safety
|
||||
|
||||
- Protect Hermes control-plane files from prompt injection (`auth.json`, `config.yaml`, `webhook_subscriptions.json`, `mcp-tokens/`). (salvages @PratikRai0101's [#14157](https://github.com/NousResearch/hermes-agent/pull/14157)) ([#30397](https://github.com/NousResearch/hermes-agent/pull/30397))
|
||||
- Write-deny `<root>/.env` when running under a profile. ([#29687](https://github.com/NousResearch/hermes-agent/pull/29687))
|
||||
- Defense-in-depth read-deny on credential stores. (salvages [#17659](https://github.com/NousResearch/hermes-agent/pull/17659) + [#8055](https://github.com/NousResearch/hermes-agent/pull/8055)) ([#30721](https://github.com/NousResearch/hermes-agent/pull/30721))
|
||||
- TTS `output_path` traversal + update ZIP symlink reject. (salvage [#6693](https://github.com/NousResearch/hermes-agent/pull/6693) + [#15881](https://github.com/NousResearch/hermes-agent/pull/15881)) ([#32056](https://github.com/NousResearch/hermes-agent/pull/32056))
|
||||
- Reject symlinked audio inputs. ([#10082](https://github.com/NousResearch/hermes-agent/pull/10082))
|
||||
|
||||
### Credential safety
|
||||
|
||||
- Avoid persisting borrowed credential secrets — runtime env-sourced keys no longer leak into `auth.json`. ([#31416](https://github.com/NousResearch/hermes-agent/pull/31416))
|
||||
- Validate Nous Portal `inference_base_url` against host allowlist. (salvages [#27612](https://github.com/NousResearch/hermes-agent/pull/27612)) ([#30611](https://github.com/NousResearch/hermes-agent/pull/30611))
|
||||
- Harden API server key placeholder handling. ([#30738](https://github.com/NousResearch/hermes-agent/pull/30738))
|
||||
- Harden Google Chat OAuth credential persistence. (@Zyrixtrex) ([#24788](https://github.com/NousResearch/hermes-agent/pull/24788))
|
||||
- xAI OAuth: pin inference `base_url` to x.ai origin. ([#28952](https://github.com/NousResearch/hermes-agent/pull/28952))
|
||||
- Quarantine dead OAuth tokens on terminal refresh failure (xAI, Codex, MiniMax). ([#28116](https://github.com/NousResearch/hermes-agent/pull/28116), [#28118](https://github.com/NousResearch/hermes-agent/pull/28118), [#28119](https://github.com/NousResearch/hermes-agent/pull/28119))
|
||||
|
||||
### Supply-chain
|
||||
|
||||
- **On-demand supply-chain audit via OSV.dev** — `hermes audit`. ([#31460](https://github.com/NousResearch/hermes-agent/pull/31460))
|
||||
- `hermes update` syntax-validates critical files post-pull, auto-rollback on failure. ([#28669](https://github.com/NousResearch/hermes-agent/pull/28669))
|
||||
- Quarantine `hermes.exe` vs concurrent Windows instance. ([#26677](https://github.com/NousResearch/hermes-agent/pull/26677))
|
||||
|
||||
### Other hardening
|
||||
|
||||
- Restrict default webhook toolset capabilities. ([#30745](https://github.com/NousResearch/hermes-agent/pull/30745))
|
||||
- Harden Microsoft Graph webhook auth requirements. ([#30169](https://github.com/NousResearch/hermes-agent/pull/30169))
|
||||
- Require source CIDR allowlisting for public msgraph webhook binds. ([#33722](https://github.com/NousResearch/hermes-agent/pull/33722))
|
||||
- Require `API_SERVER_KEY` before dispatching API server work. ([#33232](https://github.com/NousResearch/hermes-agent/pull/33232))
|
||||
- env_passthrough: apply GHSA-rhgp-j443-p4rf filter to config.yaml path. (@roadhero) ([#27794](https://github.com/NousResearch/hermes-agent/pull/27794))
|
||||
- Dashboard + WeCom: restrict markdown link schemes; safe-parse untrusted XML. ([#32442](https://github.com/NousResearch/hermes-agent/pull/32442))
|
||||
- Salvage project-plugin RCE bypass fix from PR [#29311](https://github.com/NousResearch/hermes-agent/pull/29311) (GHSA-5qr3-c538-wm9j). ([#30837](https://github.com/NousResearch/hermes-agent/pull/30837))
|
||||
- Cross-profile soft guard on file-write tools + system-prompt hint. ([#31290](https://github.com/NousResearch/hermes-agent/pull/31290))
|
||||
- Reject unsafe tar members in Android psutil compatibility installer. ([#33742](https://github.com/NousResearch/hermes-agent/pull/33742))
|
||||
- Reject non-regular tar members during tirith auto-install. ([#33786](https://github.com/NousResearch/hermes-agent/pull/33786))
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Native Windows (Beta Continued)
|
||||
|
||||
- Complete Windows bootstrap — `dep_ensure` + `install.ps1` + detection. (@alt-glitch) ([#27845](https://github.com/NousResearch/hermes-agent/pull/27845))
|
||||
- `install.ps1`: strip BOM, `-Commit`/`-Tag` pin params, harden git ops. (@jquesnelle) ([#28169](https://github.com/NousResearch/hermes-agent/pull/28169))
|
||||
- Consolidate ACP browser bootstrap into `install.{sh,ps1}`. (@alt-glitch) ([#27851](https://github.com/NousResearch/hermes-agent/pull/27851))
|
||||
- `hermes update` quarantines live `hermes.exe`. ([#26677](https://github.com/NousResearch/hermes-agent/pull/26677))
|
||||
- Discord voice opus decoding on Windows. ([#33182](https://github.com/NousResearch/hermes-agent/pull/33182))
|
||||
- Windows Docker Desktop compatible compose file. (@Sunil123135) ([#31031](https://github.com/NousResearch/hermes-agent/pull/31031))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Web Dashboard
|
||||
|
||||
- Hardened Slack socket recovery + Windows restart dedupe. ([#28873](https://github.com/NousResearch/hermes-agent/pull/28873))
|
||||
- Web dashboard: migrate checkboxes to `@nous-research/ui` + design-system polish. (@austinpickett) ([#28814](https://github.com/NousResearch/hermes-agent/pull/28814))
|
||||
- Web dashboard: collapsible sidebar. (@austinpickett) ([#33421](https://github.com/NousResearch/hermes-agent/pull/33421))
|
||||
- Dashboard typography & contrast pass. (salvage of [#28832](https://github.com/NousResearch/hermes-agent/pull/28832)) ([#30714](https://github.com/NousResearch/hermes-agent/pull/30714))
|
||||
- Skills page: lazy-fetch catalog instead of bundling 34MB into JS. ([#33809](https://github.com/NousResearch/hermes-agent/pull/33809))
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
- **s6-overlay container supervision** — abstract `ServiceManager` protocol (systemd/launchd/Windows/s6 backends), per-profile gateway supervision in-container, container-restart reconciliation, hadolint/shellcheck CI. (salvage of [#30136](https://github.com/NousResearch/hermes-agent/pull/30136), @benbarclay) ([#31760](https://github.com/NousResearch/hermes-agent/pull/31760))
|
||||
- Auto-redirect `gateway run` to supervised mode inside the s6 image. (@benbarclay) ([#33583](https://github.com/NousResearch/hermes-agent/pull/33583))
|
||||
- Tee supervised gateway stdout to docker logs. (@benbarclay) ([#33621](https://github.com/NousResearch/hermes-agent/pull/33621))
|
||||
- Drop `docker exec` to hermes uid before invoking the CLI. (@benbarclay) ([#33628](https://github.com/NousResearch/hermes-agent/pull/33628))
|
||||
- Align HOME for dashboard and s6 gateway services. (@Dusk1e) ([#33481](https://github.com/NousResearch/hermes-agent/pull/33481))
|
||||
- Bake build-time git SHA into image so `hermes dump` reports it. (@benbarclay) ([#33655](https://github.com/NousResearch/hermes-agent/pull/33655))
|
||||
- `hermes update` prints `docker pull` guidance instead of bogus git error. (@benbarclay) ([#33659](https://github.com/NousResearch/hermes-agent/pull/33659))
|
||||
- Upgrade Node to 22 LTS via multi-stage from `node:22-bookworm-slim`. (@benbarclay) ([#33060](https://github.com/NousResearch/hermes-agent/pull/33060))
|
||||
- Drop `build-essential` from apt install. (@benbarclay) ([#33028](https://github.com/NousResearch/hermes-agent/pull/33028))
|
||||
- Propagate env through s6 to cont-init and main CMD. ([#32412](https://github.com/NousResearch/hermes-agent/pull/32412))
|
||||
- Targeted chown to preserve host file ownership in `HERMES_HOME`. ([#33033](https://github.com/NousResearch/hermes-agent/pull/33033))
|
||||
- `mkdir HERMES_HOME` as root in stage2 before chown / privilege drop. ([#33078](https://github.com/NousResearch/hermes-agent/pull/33078))
|
||||
- chown `ui-tui` and `node_modules` on UID remap so TUI esbuild works. ([#33045](https://github.com/NousResearch/hermes-agent/pull/33045))
|
||||
- Include `anthropic`, `bedrock`, `azure-identity` extras in image. ([#30504](https://github.com/NousResearch/hermes-agent/pull/30504))
|
||||
- Stop pushing per-commit SHA tags to Docker Hub. ([#29387](https://github.com/NousResearch/hermes-agent/pull/29387))
|
||||
- Simplify Docker tagging — push both `:main` and `:latest` on main push. ([#33225](https://github.com/NousResearch/hermes-agent/pull/33225))
|
||||
- Test slicing across GH actions jobs. (@ethernet8023) ([#30575](https://github.com/NousResearch/hermes-agent/pull/30575))
|
||||
- Discover agent-browser Chromium binary at boot. ([#33184](https://github.com/NousResearch/hermes-agent/pull/33184))
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API Server
|
||||
|
||||
- **Session control API** — `/api/sessions/*` (list/create/read/patch/delete/fork) + SSE-streaming chat. (salvages [#29302](https://github.com/NousResearch/hermes-agent/pull/29302) by @Codename-11 + multimodal followup by @Schwartz10) ([#33134](https://github.com/NousResearch/hermes-agent/pull/33134))
|
||||
- `GET /v1/skills` and `/v1/toolsets`. ([#33016](https://github.com/NousResearch/hermes-agent/pull/33016))
|
||||
- Coerce stringified booleans in stream/store/approval payloads. (salvage [#26639](https://github.com/NousResearch/hermes-agent/pull/26639)) ([#27293](https://github.com/NousResearch/hermes-agent/pull/27293))
|
||||
- Honor `key_env` in auth-failure fallback resolution. ([#30840](https://github.com/NousResearch/hermes-agent/pull/30840))
|
||||
|
||||
---
|
||||
|
||||
## 🎟️ ACP (VS Code / Zed / JetBrains)
|
||||
|
||||
- Session edit auto-approval modes. (salvage of [#27034](https://github.com/NousResearch/hermes-agent/pull/27034)) ([#27862](https://github.com/NousResearch/hermes-agent/pull/27862))
|
||||
- Enrich Zed permission cards — command in title + `reject_always`. ([#28148](https://github.com/NousResearch/hermes-agent/pull/28148))
|
||||
- Replay session history before responding to `session/load`. ([#26957](https://github.com/NousResearch/hermes-agent/pull/26957), [#26943](https://github.com/NousResearch/hermes-agent/pull/26943))
|
||||
- Plugin-transformed final_response delivered through streaming gate. ([#31433](https://github.com/NousResearch/hermes-agent/pull/31433))
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Plugin Surface
|
||||
|
||||
- `register_tts_provider()` plugin hook. (salvage of [#30420](https://github.com/NousResearch/hermes-agent/pull/30420)) ([#31745](https://github.com/NousResearch/hermes-agent/pull/31745))
|
||||
- `register_transcription_provider()` hook + `stt.providers` command-provider registry. (salvage of [#30493](https://github.com/NousResearch/hermes-agent/pull/30493)) ([#31907](https://github.com/NousResearch/hermes-agent/pull/31907))
|
||||
- `register_auxiliary_task()` in PluginContext API. (salvage [#29817](https://github.com/NousResearch/hermes-agent/pull/29817)) ([#31177](https://github.com/NousResearch/hermes-agent/pull/31177))
|
||||
- Bundled `security-guidance` plugin. ([#33131](https://github.com/NousResearch/hermes-agent/pull/33131))
|
||||
- Discord and Mattermost migrated to bundled plugins. ([#30591](https://github.com/NousResearch/hermes-agent/pull/30591), [#31748](https://github.com/NousResearch/hermes-agent/pull/31748))
|
||||
- ntfy as platform plugin. ([#30867](https://github.com/NousResearch/hermes-agent/pull/30867))
|
||||
- Surface category-namespaced plugins in `hermes plugins list`. ([#27187](https://github.com/NousResearch/hermes-agent/pull/27187))
|
||||
- Plugin discovery failures raised to WARNING level. ([#28318](https://github.com/NousResearch/hermes-agent/pull/28318))
|
||||
- `hermes_plugins` included in gateway.log component filter. ([#28313](https://github.com/NousResearch/hermes-agent/pull/28313))
|
||||
- Seed plugin extras before `is_connected` gate. ([#31703](https://github.com/NousResearch/hermes-agent/pull/31703))
|
||||
- Dashboard: allowlist plugin assets + denylist subprocess-influencing env vars. ([#32277](https://github.com/NousResearch/hermes-agent/pull/32277))
|
||||
|
||||
---
|
||||
|
||||
## 📦 Distribution & Install
|
||||
|
||||
- Install-method stamping + Docker detection. (@alt-glitch) ([#27843](https://github.com/NousResearch/hermes-agent/pull/27843))
|
||||
- Nix `#messaging` and `#full` package variants. (@alt-glitch) ([#33108](https://github.com/NousResearch/hermes-agent/pull/33108))
|
||||
- Pre-load messaging gateway deps via `--extra messaging`. (salvage [#26394](https://github.com/NousResearch/hermes-agent/pull/26394)) ([#27558](https://github.com/NousResearch/hermes-agent/pull/27558))
|
||||
- Avoid piping installer directly into `iex` (Windows). ([#28347](https://github.com/NousResearch/hermes-agent/pull/28347))
|
||||
- Ship bundled skills in wheel. ([#28421](https://github.com/NousResearch/hermes-agent/pull/28421))
|
||||
- Ship dashboard plugin assets in wheel. ([#28406](https://github.com/NousResearch/hermes-agent/pull/28406))
|
||||
- Make Camofox lazy-installed instead of eager. ([#27055](https://github.com/NousResearch/hermes-agent/pull/27055))
|
||||
- Wire STT lazy-install into transcription_tools.py. ([#30256](https://github.com/NousResearch/hermes-agent/pull/30256))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes (highlights only)
|
||||
|
||||
- Match bare custom provider by active base URL in `hermes model`. ([#28908](https://github.com/NousResearch/hermes-agent/pull/28908))
|
||||
- Route `auxiliary.vision.provider=openai` to api.openai.com, skip text-only main. ([#31452](https://github.com/NousResearch/hermes-agent/pull/31452))
|
||||
- Lint: skip per-file shell linter when LSP will handle the file. ([#29054](https://github.com/NousResearch/hermes-agent/pull/29054))
|
||||
- Treat empty credential pool entries as unauthenticated in `/model` picker. ([#28312](https://github.com/NousResearch/hermes-agent/pull/28312))
|
||||
- Reverted within window: Firecrawl integration tag, send_message @username auto-mentions, Telegram quick-command-only menus, Telegram pin-on-turn.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- Disarm lazy-install probe so `_HAS_FASTER_WHISPER` patches work. ([#30334](https://github.com/NousResearch/hermes-agent/pull/30334))
|
||||
- Cover default board dashboard pin. ([#28361](https://github.com/NousResearch/hermes-agent/pull/28361))
|
||||
- Cover `_task_dict` `task_age` fallback. ([#28365](https://github.com/NousResearch/hermes-agent/pull/28365))
|
||||
- Allowlist `tmp_path` for `kanban_notify` artifact delivery tests. ([#30851](https://github.com/NousResearch/hermes-agent/pull/30851), [#30852](https://github.com/NousResearch/hermes-agent/pull/30852))
|
||||
- Cover null output stream terminal events in Codex. ([#33137](https://github.com/NousResearch/hermes-agent/pull/33137))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **30-day docs overhaul** — full correctness audit, every PR in the window covered, Nous Portal weave, sidebar reorg. ([#33782](https://github.com/NousResearch/hermes-agent/pull/33782))
|
||||
- Dedicated Nous Portal integration page and setup guide. ([#31296](https://github.com/NousResearch/hermes-agent/pull/31296))
|
||||
- Providers: move Nous Portal first, Google Gemini OAuth last. ([#31287](https://github.com/NousResearch/hermes-agent/pull/31287))
|
||||
- `session_search` rewrite for single-shape tool. ([#27840](https://github.com/NousResearch/hermes-agent/pull/27840))
|
||||
- Kanban: document failure_limit, max_retries, inline create shortcuts, goals & kanban settings. ([#28357](https://github.com/NousResearch/hermes-agent/pull/28357), [#28358](https://github.com/NousResearch/hermes-agent/pull/28358), [#28359](https://github.com/NousResearch/hermes-agent/pull/28359), [#28360](https://github.com/NousResearch/hermes-agent/pull/28360), [#28362](https://github.com/NousResearch/hermes-agent/pull/28362))
|
||||
- Kanban Codex lane skill. ([#28430](https://github.com/NousResearch/hermes-agent/pull/28430))
|
||||
- xAI OAuth: note X Premium+ also unlocks Grok OAuth. ([#29055](https://github.com/NousResearch/hermes-agent/pull/29055))
|
||||
- Docs site: Docker audio bridge notes, "Installing more tools in the container", xurl auth HOME in Docker.
|
||||
- Email: clarify gateway vs Himalaya setup. (@helix4u) ([#33634](https://github.com/NousResearch/hermes-agent/pull/33634))
|
||||
- Auth docs: replace stale `hermes login` references with `hermes auth add`. ([#32859](https://github.com/NousResearch/hermes-agent/pull/32859))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- @teknium1 (lead)
|
||||
|
||||
### Notable salvages & cherry-picks
|
||||
|
||||
- **@benbarclay** — s6-overlay container supervision (29 commits salvaged), Node 22 LTS upgrade, build-essential cleanup, `gateway run` auto-redirect in s6, tee supervised stdout to docker logs, `hermes update` Docker guidance, build-time SHA stamping
|
||||
- **@OutThisLife** — `mouse_tracking` DEC mode presets
|
||||
- **@jquesnelle** — Windows installer hardening, `--branch` flag for `hermes update`, install.ps1 BOM strip / commit-pin
|
||||
- **@alt-glitch** — Windows `dep_ensure` bootstrap, Nix package variants (`.#messaging`, `.#full`), install-method stamping, ACP browser bootstrap consolidation
|
||||
- **@austinpickett** — `/update` slash command, dashboard checkboxes → `@nous-research/ui`, mobile dashboard polish, collapsible sidebar
|
||||
- **@ethernet8023** — CI test slicing across GH Actions jobs, TUI clipboard copy fix
|
||||
- **@kshitijk4poor** — doctor section banner + fail-and-issue helpers extraction, post-tag salvage cluster (curator-fallout, kanban SQLite hardening, install world-readable uv dirs, xAI bare-code paste)
|
||||
- **@rewbs** — Nous JWT inference switch + refresh-token replay fix
|
||||
- **@Codename-11** + **@Schwartz10** — session control API (REST + SSE + multimodal followup)
|
||||
- **@Niraven** — kanban swarm topology helper
|
||||
- **@Interstellar-code** — kanban worker visibility endpoints
|
||||
- **@adybag14-cyber** — termux cold-start optimizations (multiple PRs)
|
||||
- **@qike-ms** — Telegram in-place status edits design
|
||||
- **@sprmn24** — ntfy adapter
|
||||
- **@Jaaneek** — xAI Web Search provider plugin
|
||||
- **@yannsunn** — xAI upstream adapter for `hermes proxy`
|
||||
- **@Cybourgeoisie** — OpenRouter sticky routing via session_id
|
||||
- **@memosr** — Nous Portal base_url allowlist validation
|
||||
- **@Sunil123135** — Windows Docker Desktop compose file
|
||||
- **@Dusk1e** — Docker HOME alignment for dashboard + s6 gateway services
|
||||
- **@beardthelion** — opencode-go anthropic_messages routing
|
||||
- **@YLChen-007** — Skills Guard multi-word prompt patterns
|
||||
- **@roadhero** — env_passthrough GHSA-rhgp-j443-p4rf filter
|
||||
- **@Zyrixtrex** — Google Chat OAuth credential persistence hardening
|
||||
- **@briandevans**, **@tomqiaozc** — defense-in-depth read-deny on credential stores
|
||||
- **@PratikRai0101** — control-plane file write protection
|
||||
- **@helix4u**, **@Bartok9**, **@zccyman** — auxiliary fallback ladder components
|
||||
- **@ms-alan**, **@ticketclosed-wontfix**, **@donovan-yohan** — TUI session orchestrator + follow-ups
|
||||
- **@daimon-nous[bot]** — cron per-job profile support
|
||||
- **@bisko** — re-pad `reasoning_content` on cross-provider fallback
|
||||
|
||||
### All Contributors
|
||||
|
||||
@02356abc, @0xchainer, @0xDevNinja, @0xjackyang, @0xsir0000, @0z1-ghb, @8bit64k, @aaronlab, @AceWattGit,
|
||||
@ACR27, @adam91holt, @AdamPlatin123, @Ade5954, @AdityaRajeshGadgil, @adybag14-cyber, @AhmetArif0, @ai-hana-ai,
|
||||
@alaamohanad169-ship-it, @alber70g, @albert748, @alt-glitch, @aqilaziz, @argabor, @asdlem, @austinpickett,
|
||||
@avifenesh, @awizemann, @B0Tch1, @Bartok9, @BaxBit, @Beandon13, @beardthelion, @benbarclay, @bensargotest-sys,
|
||||
@binhnt92, @bird, @bisko, @BlackishGreen33, @booker1207, @bradhallett, @briandevans, @Brixyy, @brndnsvr,
|
||||
@BROCCOLO1D, @btorresgil, @burjorjee, @carltonawong, @Carry00, @chaconne67, @chdlc, @chromalinx, @ChyuWei,
|
||||
@CipherFrame, @cmullins70, @CNSeniorious000, @codeblackhole1024, @Codename-11, @colin-chang, @counterposition,
|
||||
@cresslank, @CryptoByz, @cyb0rgk1tty, @Cybourgeoisie, @daizhonggeng, @darvsum, @davidcampbelldc, @deas,
|
||||
@dgians, @dillweed, @DoGMaTiiC, @donovan-yohan, @draplater, @Drexuxux, @dskwe, @dsr-restyn, @Dusk1e,
|
||||
@dusterbloom, @duyua9, @egilewski, @el-analista, @eliteworkstation94-ai, @eloklam, @EloquentBrush0x, @emonty,
|
||||
@emozilla, @erhnysr, @erikengervall, @Erosika, @ether-btc, @ethernet8023, @EvilHumphrey, @fabiosiqueira,
|
||||
@falasi, @falconexe, @fardoche6, @felix-windsor, @Fewmanism, @ffr31mr, @flamiinngo, @flanny7, @flooryyyy,
|
||||
@fonhal, @francip, @fujinice, @gianfrancopiana, @glennc, @Glucksberg, @godlin-gh, @Grogger, @guillaumemeyer,
|
||||
@Gutslabs, @H-Ali13381, @hanzckernel, @haran2001, @hawknewton, @hayka-pacha, @hehehe0803, @helix4u, @HenkDz,
|
||||
@Hermes, @hermesagent26, @Hinotoi-agent, @hongchen1993, @honor2030, @houenyang-momo, @ht1072, @hueilau,
|
||||
@iamfoz, @ilonagaja509-glitch, @InB4DevOps, @indigokarasu, @Interstellar-code, @iqdoctor, @iRonin, @Jaaneek,
|
||||
@JabberELF, @jacevys, @jackey8616, @jackjin1997, @jdelmerico, @jfuenmayor, @Jiahui-Gu, @JimLiu, @joe102084,
|
||||
@JohnC1009, @jonpol01, @Jpalmer95, @Julientalbot, @justemu, @justincc, @jvinals, @karthikeyann, @kasunvinod,
|
||||
@kchuang1015, @kenyonxu, @khungate, @kiranvk-2011, @kjames2001, @konsisumer, @kpadilha, @kriscolab,
|
||||
@krislidimo, @kronexoi, @kshitijk4poor, @kunci115, @Kylejeong2, @kylekahraman, @LaPhilosophie, @leeseoki0,
|
||||
@lemassykoi, @Lempkey, @LeonJS, @LeonSGP43, @lidge-jun, @LifeJiggy, @liuhao1024, @LizerAIDev, @loicnico96,
|
||||
@loongfay, @m0n3r0, @malaiwah, @matthewlai, @mavrickdeveloper, @maxmilian, @McClean-Edison, @memosr,
|
||||
@Mind-Dragon, @momowind, @MoonJuhan, @MoonRay305, @moortekweb-art, @MorAlekss, @ms-alan, @Nami4D,
|
||||
@nehaaprasaad, @nekwo, @nftpoetrist, @NickLarcombe, @nidhi-singh02, @Niraven, @nnnet, @noctilust, @novax635,
|
||||
@nthrow, @nv-kasikritc, @nycomar, @OCWC22, @oemtalks, @OmX, @ooovenenoso, @orcool, @oseftg, @outsourc-e,
|
||||
@OutThisLife, @Paperclip, @PaTTeeL, @pepelax, @phoenixshen, @Pluviobyte, @pnascimento9596, @pochi-gio, @pr7426,
|
||||
@PratikRai0101, @Prithvi1994, @psionic73, @ptichalouf, @Que0x, @QuenVix, @quocanh261997, @qWaitCrypto, @Qwinty,
|
||||
@r266-tech, @rak135, @rdasilva1016-ui, @rewbs, @roadhero, @rodrigoeqnit, @RonHillDev, @roycepersonalassistant,
|
||||
@rudi193-cmd, @RyanRana, @sadiksaifi, @samahn0601, @samggggflynn, @SamuelZ12, @sanghyuk-seo-nexcube,
|
||||
@Saurav0989, @savanne-kham, @Schrotti77, @Schwartz10, @SerenityTn, @sgtworkman, @sharziki, @shaun0927,
|
||||
@shellybotmoyer, @shunsuke-hikiyama, @SimbaKingjoe, @SimoKiihamaki, @sir-ad, @Slimydog21, @slowtokki0409,
|
||||
@Soju06, @someaka, @soynchux, @sprmn24, @Stark-X, @steezkelly, @stepanov1975, @stephenschoettler,
|
||||
@stevehq26-bot, @steveonjava, @Strontvod, @subtract0, @Sunil123135, @superearn-fisher, @Sylw3ster, @tchanee,
|
||||
@that-ambuj, @thedavidmurray, @TheOnlyMika, @therahul-yo, @thewillhuang, @ticketclosed-wontfix, @Timur00Kh,
|
||||
@tomqiaozc, @Tosko4, @Tranquil-Flow, @tw2818, @uzunkuyruk, @vaddisrinivas, @vanthinh6886, @vgocoder,
|
||||
@victorGPT, @vynxevainglory-ai, @waefrebeorn, @walli, @wangpuv, @wanwan2qq, @wesleysimplicio, @worlldz,
|
||||
@wpengpeng168, @WuKongAI-CMU, @wuli666, @Wysie, @wysie, @xxxigm, @yannsunn, @YanzhongSu, @YarrowQiao, @ygd58,
|
||||
@YLChen-007, @yoniebans, @yu-xin-c, @YuanHanzhong, @zapabob, @zccyman, @ziliangpeng, @zwolniony, @Zyrixtrex
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.5.16...v2026.5.28](https://github.com/NousResearch/hermes-agent/compare/v2026.5.16...v2026.5.28)
|
||||
@@ -1,110 +0,0 @@
|
||||
# Hermes Agent v0.15.1 (v2026.5.29)
|
||||
|
||||
**Release Date:** May 29, 2026
|
||||
**Since v0.15.0:** 28 commits · 21 merged PRs · hotfix release · 9 contributors
|
||||
|
||||
> **The Patch Release.** A same-day hotfix for v0.15.0. Headline fix: the dashboard infinite-reload loop that hit anyone running v0.15.0 in loopback mode (Docker, hosted Hermes, fresh installs). A handful of other v0.15.0 follow-ups go along for the ride — kanban worker SIGTERM, `/model` picker unification, `/yolo` session bypass, the full 19,932-entry skills.sh catalog, `.md` media delivery restoration, gateway probe-stepdown safety, web-URL redaction passthrough, kanban worker vision on referenced images, hindsight observation-default. Docker users get an explicit `--insecure` opt-in env var (no more bind-host inference), MCP server bare-command PATH resolution, and arm64 PR-build cache fixes.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Dashboard 401 reload loop fixed** — In loopback mode the dashboard's identity probe (`/api/auth/me`) returns 401 by design, but v0.15.0's stale-token reload guard treated every 401 as a rotated session token and full-page-reloaded to pick up a fresh one. Every successful sibling call cleared the one-shot reload guard, so the page reload-looped forever (Firefox: "Navigated to /sessions" storm; Chrome: React re-render storm). Fix adds an `allowUnauthorized` opt-out to `fetchJSON` that skips only the loopback stale-token reload — 401 still throws so `AuthWidget` swallows it, gated-mode `login_url` redirects are unaffected. Closes [#34206](https://github.com/NousResearch/hermes-agent/issues/34206), [#34202](https://github.com/NousResearch/hermes-agent/issues/34202). ([#30698](https://github.com/NousResearch/hermes-agent/pull/30698) — @austinpickett)
|
||||
|
||||
- **Docker dashboard `--insecure` is now an explicit env opt-in, never derived from bind host** — Previously the Docker entrypoint inferred `--insecure` when the dashboard bound to a non-loopback host. That conflated "I want LAN access" with "I want to disable the same-origin guard." The fix splits them: bind host is bind host, and disabling the dashboard's loopback auth requires an explicit `HERMES_DASHBOARD_INSECURE=1`. Existing setups that genuinely wanted insecure binding must now set the env var. ([#34188](https://github.com/NousResearch/hermes-agent/pull/34188), [#34204](https://github.com/NousResearch/hermes-agent/pull/34204) — @benbarclay)
|
||||
|
||||
- **MCP bare command resolution under Docker** — MCP servers configured with bare commands (`npx`, `npm`, `node`) now resolve against `/usr/local/bin` so they actually launch inside the Docker image where those binaries live. v0.15.0 left these failing silently in containers when the agent's effective PATH didn't include the Node toolchain location. ([#34186](https://github.com/NousResearch/hermes-agent/pull/34186) — @benbarclay)
|
||||
|
||||
- **Skills page sidebar / source pills restored** — A stale `useMemo` dependency in the new dashboard skills page collapsed the source pills and category sidebar to "All" only. Fixed; both surfaces now reflect the live catalog state. ([#34194](https://github.com/NousResearch/hermes-agent/pull/34194))
|
||||
|
||||
- **Kanban worker can be killed again** — `SIGTERM` on a kanban worker was being absorbed by an intermediate process and the worker stayed running. Closes [#28181](https://github.com/NousResearch/hermes-agent/issues/28181). ([#34045](https://github.com/NousResearch/hermes-agent/pull/34045))
|
||||
|
||||
- **Full skills.sh catalog (858 → 19,932 entries)** — The skills hub page was pulling a partial paginated catalog. The fetch now walks the sitemap, so all 19,932 skills.sh entries surface in the picker instead of just the first 858. ([#34025](https://github.com/NousResearch/hermes-agent/pull/34025))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
### Dashboard / Web
|
||||
|
||||
- **`/api/auth/me` 401 no longer triggers reload loop** in loopback mode — ([#30698](https://github.com/NousResearch/hermes-agent/pull/30698) — @austinpickett)
|
||||
- **Skills page source pills + category sidebar restored** — stale `useMemo` dep ([#34194](https://github.com/NousResearch/hermes-agent/pull/34194))
|
||||
|
||||
### Docker
|
||||
|
||||
- **`--insecure` is now explicit opt-in via env var**, not derived from bind host ([#34188](https://github.com/NousResearch/hermes-agent/pull/34188) — @benbarclay)
|
||||
- **Dashboard test suite repaired** to match the insecure-opt-in fix ([#34204](https://github.com/NousResearch/hermes-agent/pull/34204) — @benbarclay)
|
||||
- **arm64 PR builds skip the GHA cache** to avoid cache-thrash on cross-arch builders ([#33704](https://github.com/NousResearch/hermes-agent/pull/33704) — @BROCCOLO1D)
|
||||
|
||||
### MCP
|
||||
|
||||
- **Bare `npx`/`npm`/`node` resolve against `/usr/local/bin`** for Docker compatibility ([#34186](https://github.com/NousResearch/hermes-agent/pull/34186) — @benbarclay)
|
||||
|
||||
### Kanban
|
||||
|
||||
- **Worker SIGTERM actually terminates the process** ([#34045](https://github.com/NousResearch/hermes-agent/pull/34045))
|
||||
- **Workers receive images referenced in task bodies** for vision-capable models ([#34210](https://github.com/NousResearch/hermes-agent/pull/34210))
|
||||
|
||||
### Gateway
|
||||
|
||||
- **`.md` files deliver again** — media-delivery validation defaults to denylist-only instead of an overly-narrow allowlist ([#34022](https://github.com/NousResearch/hermes-agent/pull/34022))
|
||||
- **Probe stepdown safety** — on a context-overflow without an explicit provider context limit, the agent no longer steps down to a smaller model based on an unknown ceiling (salvage of [#33673](https://github.com/NousResearch/hermes-agent/pull/33673)) ([#33826](https://github.com/NousResearch/hermes-agent/pull/33826))
|
||||
|
||||
### CLI
|
||||
|
||||
- **`/yolo` mid-session enables the per-session bypass** instead of just toggling the env var (which the running agent had already snapshotted) ([#33931](https://github.com/NousResearch/hermes-agent/pull/33931) — @kshitijk4poor)
|
||||
- **`/model` and `hermes model` show the same list**, plus disk cache for picker startup ([#33867](https://github.com/NousResearch/hermes-agent/pull/33867))
|
||||
|
||||
### Skills
|
||||
|
||||
- **Full skills.sh catalog via sitemap** — 858 → 19,932 entries ([#34025](https://github.com/NousResearch/hermes-agent/pull/34025))
|
||||
|
||||
### Redaction
|
||||
|
||||
- **Web URLs pass through unchanged** — the redactor was eating query parameters that looked credential-shaped ([#34029](https://github.com/NousResearch/hermes-agent/pull/34029))
|
||||
|
||||
---
|
||||
|
||||
## ✨ Small Features
|
||||
|
||||
- **Hindsight default narrowed to observation-only** for `recall_types` — tool path is also narrowed ([#34079](https://github.com/NousResearch/hermes-agent/pull/34079) — @nicoloboschi, follow-up [#34091](https://github.com/NousResearch/hermes-agent/pull/4df62d239e38bf8c212a595721c9c01e176f6c3a) — @kshitijk4poor)
|
||||
- **Memory providers receive completed-turn message context** — salvage of [#28065](https://github.com/NousResearch/hermes-agent/pull/28065) ([#34097](https://github.com/NousResearch/hermes-agent/pull/34097) — @kshitijk4poor, credit to @devwdave)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **`--no-supervise` / `HERMES_GATEWAY_NO_SUPERVISE` documented** in the reference docs (follow-up to [#33583](https://github.com/NousResearch/hermes-agent/pull/33583)) ([#33751](https://github.com/NousResearch/hermes-agent/pull/33751) — @r266-tech)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Infrastructure
|
||||
|
||||
- **Vercel deploy workflow accepts `workflow_dispatch`** so docs deploys can be manually triggered ([#34081](https://github.com/NousResearch/hermes-agent/pull/34081))
|
||||
- **`@nous-research/ui` bumped to 0.18.2** (Nix `npmDepsHash` also updated to match) ([#34193](https://github.com/NousResearch/hermes-agent/pull/34193) follow-ups — @austinpickett)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- @teknium1
|
||||
|
||||
### Community
|
||||
- @austinpickett — dashboard 401 reload-loop fix (the headline), `@nous-research/ui` bump, Nix `npmDepsHash` updates
|
||||
- @benbarclay — Docker `--insecure` opt-in, MCP bare-command resolution, dashboard test repair
|
||||
- @kshitijk4poor — `/yolo` session bypass, completed-turn memory context salvage, hindsight follow-up docs
|
||||
- @nicoloboschi — hindsight `recall_types` observation default
|
||||
- @BROCCOLO1D — arm64 PR build cache fix
|
||||
- @r266-tech — `--no-supervise` reference docs
|
||||
- @yangguangjin — probe stepdown safety (salvage of @yanghd's #33673)
|
||||
- @devwdave — completed-turn memory context (credited via salvage)
|
||||
- @andrewhosf — co-author
|
||||
|
||||
### Issue Reporters (the 401 loop)
|
||||
- @routesmith ([#34206](https://github.com/NousResearch/hermes-agent/issues/34206))
|
||||
- @beeaton ([#34202](https://github.com/NousResearch/hermes-agent/issues/34202))
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.5.28...v2026.5.29](https://github.com/NousResearch/hermes-agent/compare/v2026.5.28...v2026.5.29)
|
||||
@@ -907,6 +907,72 @@ def _build_polished_completion_content(
|
||||
return [_text(text)]
|
||||
|
||||
|
||||
def _build_patch_mode_content(patch_text: str) -> List[Any]:
|
||||
"""Parse V4A patch mode input into ACP diff blocks when possible."""
|
||||
if not patch_text:
|
||||
return [acp.tool_content(acp.text_block(""))]
|
||||
|
||||
try:
|
||||
from tools.patch_parser import OperationType, parse_v4a_patch
|
||||
|
||||
operations, error = parse_v4a_patch(patch_text)
|
||||
if error or not operations:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
content: List[Any] = []
|
||||
for op in operations:
|
||||
if op.operation == OperationType.UPDATE:
|
||||
old_chunks: list[str] = []
|
||||
new_chunks: list[str] = []
|
||||
for hunk in op.hunks:
|
||||
old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}]
|
||||
new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}]
|
||||
if old_lines or new_lines:
|
||||
old_chunks.append("\n".join(old_lines))
|
||||
new_chunks.append("\n".join(new_lines))
|
||||
|
||||
old_text = "\n...\n".join(chunk for chunk in old_chunks if chunk)
|
||||
new_text = "\n...\n".join(chunk for chunk in new_chunks if chunk)
|
||||
if old_text or new_text:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=old_text or None,
|
||||
new_text=new_text or "",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.ADD:
|
||||
added_lines = [line.content for hunk in op.hunks for line in hunk.lines if line.prefix == "+"]
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
new_text="\n".join(added_lines),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.DELETE:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=f"Delete file: {op.file_path}",
|
||||
new_text="",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.MOVE:
|
||||
content.append(
|
||||
acp.tool_content(acp.text_block(f"Move file: {op.file_path} -> {op.new_path}"))
|
||||
)
|
||||
|
||||
return content or [acp.tool_content(acp.text_block(patch_text))]
|
||||
except Exception:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
|
||||
def _strip_diff_prefix(path: str) -> str:
|
||||
raw = str(path or "").strip()
|
||||
if raw.startswith(("a/", "b/")):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.15.1",
|
||||
"version": "0.14.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.15.1",
|
||||
"package": "hermes-agent[acp]==0.14.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,3 @@ These modules contain pure utility functions and self-contained classes
|
||||
that were previously embedded in the 3,600-line run_agent.py. Extracting
|
||||
them makes run_agent.py focused on the AIAgent orchestrator class.
|
||||
"""
|
||||
|
||||
from . import jiter_preload as _jiter_preload # noqa: F401
|
||||
|
||||
+4
-17
@@ -27,6 +27,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
@@ -36,6 +37,7 @@ from agent.memory_manager import StreamingContextScrubber
|
||||
from agent.model_metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
fetch_model_metadata,
|
||||
get_model_context_length,
|
||||
is_local_endpoint,
|
||||
query_ollama_num_ctx,
|
||||
)
|
||||
@@ -50,6 +52,7 @@ from agent.tool_guardrails import (
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from hermes_constants import get_hermes_home
|
||||
from model_tools import check_toolset_requirements, get_tool_definitions
|
||||
from utils import base_url_host_matches
|
||||
|
||||
# Use the same logger name as run_agent so tests patching ``run_agent.logger``
|
||||
@@ -180,7 +183,6 @@ def init_agent(
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
user_id_alt: str = None,
|
||||
user_name: str = None,
|
||||
chat_id: str = None,
|
||||
chat_name: str = None,
|
||||
@@ -263,7 +265,6 @@ def init_agent(
|
||||
agent.ephemeral_system_prompt = ephemeral_system_prompt
|
||||
agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
|
||||
agent._user_id = user_id # Platform user identifier (gateway sessions)
|
||||
agent._user_id_alt = user_id_alt # Optional stable alternate platform identifier
|
||||
agent._user_name = user_name
|
||||
agent._chat_id = chat_id
|
||||
agent._chat_name = chat_name
|
||||
@@ -1118,8 +1119,6 @@ def init_agent(
|
||||
# Thread gateway user identity for per-user memory scoping
|
||||
if agent._user_id:
|
||||
_init_kwargs["user_id"] = agent._user_id
|
||||
if agent._user_id_alt:
|
||||
_init_kwargs["user_id_alt"] = agent._user_id_alt
|
||||
if agent._user_name:
|
||||
_init_kwargs["user_name"] = agent._user_name
|
||||
if agent._chat_id:
|
||||
@@ -1198,18 +1197,6 @@ def init_agent(
|
||||
_agent_section = {}
|
||||
agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")
|
||||
|
||||
# Universal task-completion guidance toggle. Default True. Surfaced
|
||||
# as a separate flag from tool_use_enforcement because the guidance
|
||||
# applies to ALL models, not just the model families enforcement
|
||||
# targets.
|
||||
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
||||
|
||||
# Local Python toolchain probe toggle. Default True. When False,
|
||||
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
||||
# line). Useful for users on exotic setups where the probe heuristics
|
||||
# are noisy.
|
||||
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
|
||||
|
||||
# App-level API retry count (wraps each model API call). Default 3,
|
||||
# overridable via agent.api_max_retries in config.yaml. See #11616.
|
||||
try:
|
||||
@@ -1471,6 +1458,7 @@ def init_agent(
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
_ctx = getattr(agent.context_compressor, "context_length", 0)
|
||||
if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH:
|
||||
raise ValueError(
|
||||
@@ -1530,7 +1518,6 @@ def init_agent(
|
||||
platform=agent.platform or "cli",
|
||||
model=agent.model,
|
||||
context_length=getattr(agent.context_compressor, "context_length", 0),
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
_ra().logger.debug("Context engine on_session_start: %s", _ce_err)
|
||||
|
||||
+81
-172
@@ -25,17 +25,24 @@ from __future__ import annotations
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from agent.message_sanitization import (
|
||||
_repair_tool_call_arguments,
|
||||
_sanitize_surrogates,
|
||||
)
|
||||
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
|
||||
from agent.trajectory import convert_scratchpad_to_think
|
||||
from agent.credential_pool import STATUS_EXHAUSTED
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -553,24 +560,6 @@ def recover_with_credential_pool(
|
||||
if pool is None:
|
||||
return False, has_retried_429
|
||||
|
||||
# Defensive guard: if a fallback provider is active and its provider name
|
||||
# doesn't match the pool's provider, the pool belongs to the PRIMARY
|
||||
# provider. Mutating it based on fallback errors would corrupt the
|
||||
# primary's credential state (see #33088) and, via _swap_credential,
|
||||
# overwrite the agent's base_url back to the primary's endpoint — every
|
||||
# subsequent request then goes to the wrong host and 404s (see #33163).
|
||||
# The pool should only act when the agent is still on the same provider
|
||||
# that seeded the pool.
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
if status_code == 402:
|
||||
@@ -1372,129 +1361,81 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
old_model = agent.model
|
||||
old_provider = agent.provider
|
||||
|
||||
# ── Snapshot all fields the swap+rebuild can mutate ──
|
||||
# If the rebuild raises (bad API key, network error, build_anthropic_client
|
||||
# failure, etc.) we restore these atomically so the agent isn't left with a
|
||||
# new model/provider name paired with the OLD client — that mismatch causes
|
||||
# HTTP 400s like "claude-sonnet-4-6 is not supported on openai-codex" on the
|
||||
# next turn. Callers in cli.py / gateway/run.py / tui_gateway/server.py
|
||||
# catch the re-raised exception and show the user a warning; without this
|
||||
# rollback the warning is misleading because the swap partially succeeded.
|
||||
# Use a sentinel so we can distinguish "attribute was unset" from
|
||||
# "attribute was None" and skip the restore for genuinely-missing
|
||||
# attributes (tests construct bare agents via __new__ without all fields).
|
||||
_MISSING = object()
|
||||
_snapshot = {
|
||||
name: getattr(agent, name, _MISSING)
|
||||
for name in (
|
||||
"model",
|
||||
"provider",
|
||||
"base_url",
|
||||
"api_mode",
|
||||
"api_key",
|
||||
"client",
|
||||
"_anthropic_client",
|
||||
"_anthropic_api_key",
|
||||
"_anthropic_base_url",
|
||||
"_is_anthropic_oauth",
|
||||
"_config_context_length",
|
||||
# Clear the per-config context_length override so the new model's
|
||||
# actual context window is resolved via get_model_context_length()
|
||||
# instead of inheriting the stale value from the previous model.
|
||||
agent._config_context_length = None
|
||||
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
agent._transport_cache.clear()
|
||||
if api_key:
|
||||
agent.api_key = api_key
|
||||
|
||||
# ── Build new client ──
|
||||
if api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
_is_oauth_token,
|
||||
)
|
||||
}
|
||||
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
|
||||
# live dict doesn't poison the rollback target.
|
||||
_snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {})
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
|
||||
# API key — falling back would send Anthropic credentials to third-party endpoints.
|
||||
_is_native_anthropic = new_provider == "anthropic"
|
||||
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
|
||||
|
||||
try:
|
||||
# Clear the per-config context_length override so the new model's
|
||||
# actual context window is resolved via get_model_context_length()
|
||||
# instead of inheriting the stale value from the previous model.
|
||||
agent._config_context_length = None
|
||||
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
agent._transport_cache.clear()
|
||||
if api_key:
|
||||
agent.api_key = api_key
|
||||
|
||||
# ── Build new client ──
|
||||
if api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
_is_oauth_token,
|
||||
)
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
|
||||
# API key — falling back would send Anthropic credentials to third-party endpoints.
|
||||
_is_native_anthropic = new_provider == "anthropic"
|
||||
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
|
||||
|
||||
# MiniMax OAuth: swap static string for a per-request callable token
|
||||
# provider so the rebuilt client survives 15-min token expiry. See
|
||||
# the matching block in agent_init.py for the full rationale.
|
||||
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url,
|
||||
timeout=get_provider_request_timeout(agent.provider, agent.model),
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
effective_key = api_key or agent.api_key
|
||||
effective_base = base_url or agent.base_url
|
||||
agent._client_kwargs = {
|
||||
"api_key": effective_key,
|
||||
"base_url": effective_base,
|
||||
}
|
||||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
shared=True,
|
||||
)
|
||||
except Exception:
|
||||
# Rollback every mutated field to the pre-swap snapshot so the agent
|
||||
# is left consistent (old model + old provider + old client) and the
|
||||
# caller's exception handler can surface a meaningful warning. The
|
||||
# exception is re-raised; cli.py / gateway/run.py / tui_gateway catch
|
||||
# it and print "Agent swap failed; change applied to next session".
|
||||
for _name, _value in _snapshot.items():
|
||||
if _value is _MISSING:
|
||||
# Attribute did not exist before the swap — don't fabricate it.
|
||||
continue
|
||||
# MiniMax OAuth: swap static string for a per-request callable token
|
||||
# provider so the rebuilt client survives 15-min token expiry. See
|
||||
# the matching block in agent_init.py for the full rationale.
|
||||
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
setattr(agent, _name, _value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
raise
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url,
|
||||
timeout=get_provider_request_timeout(agent.provider, agent.model),
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
effective_key = api_key or agent.api_key
|
||||
effective_base = base_url or agent.base_url
|
||||
agent._client_kwargs = {
|
||||
"api_key": effective_key,
|
||||
"base_url": effective_base,
|
||||
}
|
||||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
shared=True,
|
||||
)
|
||||
|
||||
# ── Re-evaluate prompt caching ──
|
||||
agent._use_prompt_caching, agent._use_native_cache_layout = (
|
||||
@@ -1692,8 +1633,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -1989,36 +1928,6 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
|
||||
api_msg.pop("reasoning_content", None)
|
||||
|
||||
|
||||
def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
|
||||
"""Re-pad assistant turns with reasoning_content for the active provider.
|
||||
|
||||
``api_messages`` is built once, before the retry loop, while the *primary*
|
||||
provider is active. If a mid-conversation fallback then switches to a
|
||||
require-side provider (DeepSeek / Kimi / MiMo thinking mode), assistant
|
||||
turns that were built when the prior provider did NOT need the echo-back go
|
||||
out without ``reasoning_content`` and the new provider rejects them with
|
||||
HTTP 400 ("The reasoning_content in the thinking mode must be passed back").
|
||||
|
||||
Calling this immediately before building the request kwargs re-applies the
|
||||
pad against the *current* provider. It is idempotent and a no-op unless
|
||||
``_needs_thinking_reasoning_pad()`` is True for the active provider, so it
|
||||
is safe to call every iteration and covers every fallback path.
|
||||
|
||||
Returns the number of assistant turns that gained reasoning_content.
|
||||
"""
|
||||
if not agent._needs_thinking_reasoning_pad():
|
||||
return 0
|
||||
padded = 0
|
||||
for api_msg in api_messages:
|
||||
if api_msg.get("role") != "assistant":
|
||||
continue
|
||||
if api_msg.get("reasoning_content"):
|
||||
continue
|
||||
copy_reasoning_content_for_api(agent, api_msg, api_msg)
|
||||
if api_msg.get("reasoning_content"):
|
||||
padded += 1
|
||||
return padded
|
||||
|
||||
|
||||
def _iter_pool_sockets(client: Any):
|
||||
"""Yield raw sockets reachable from an OpenAI/httpx client pool.
|
||||
|
||||
+21
-51
@@ -77,16 +77,16 @@ ADAPTIVE_EFFORT_MAP = {
|
||||
# xhigh as a distinct level between high and max; older adaptive-thinking
|
||||
# models (4.6) reject it with a 400. Keep this substring list in sync with
|
||||
# the Anthropic migration guide as new model families ship.
|
||||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
||||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7")
|
||||
|
||||
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
|
||||
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
|
||||
# and drops temperature/top_p/top_k).
|
||||
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
|
||||
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7")
|
||||
|
||||
# Models where temperature/top_p/top_k return 400 if set to non-default values.
|
||||
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
|
||||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
||||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7")
|
||||
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||
|
||||
# ── Max output token limits per Anthropic model ───────────────────────
|
||||
@@ -94,8 +94,6 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
||||
# starves thinking-enabled models (thinking tokens count toward the limit).
|
||||
_ANTHROPIC_OUTPUT_LIMITS = {
|
||||
# Claude 4.8
|
||||
"claude-opus-4-8": 128_000,
|
||||
# Claude 4.7
|
||||
"claude-opus-4-7": 128_000,
|
||||
# Claude 4.6
|
||||
@@ -894,6 +892,20 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def read_claude_managed_key() -> Optional[str]:
|
||||
"""Read Claude's native managed key from ~/.claude.json for diagnostics only."""
|
||||
claude_json = Path.home() / ".claude.json"
|
||||
if claude_json.exists():
|
||||
try:
|
||||
data = json.loads(claude_json.read_text(encoding="utf-8"))
|
||||
primary_key = data.get("primaryApiKey", "")
|
||||
if isinstance(primary_key, str) and primary_key.strip():
|
||||
return primary_key.strip()
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read ~/.claude.json: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
||||
"""Check if Claude Code credentials have a non-expired access token."""
|
||||
import time
|
||||
@@ -1242,16 +1254,10 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
print()
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui
|
||||
webbrowser.open(auth_url)
|
||||
print(" (Browser opened automatically)")
|
||||
except Exception:
|
||||
_can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior
|
||||
|
||||
if _can_open_gui():
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
print(" (Browser opened automatically)")
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
|
||||
print()
|
||||
print("After authorizing, you'll see a code. Paste it below.")
|
||||
@@ -1783,25 +1789,11 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
tool_result_ids.add(block.get("tool_use_id"))
|
||||
for m in result:
|
||||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||||
kept = [
|
||||
m["content"] = [
|
||||
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)"}]
|
||||
|
||||
@@ -1846,10 +1838,6 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any
|
||||
fixed[-1]["content"] = prev_content + curr_content
|
||||
else:
|
||||
# Consecutive assistant messages — merge text content.
|
||||
# Propagate the orphan-strip signature-invalidation flag onto the
|
||||
# surviving (prev) dict so _manage_thinking_signatures still sees it.
|
||||
if m.get("_thinking_signature_invalidated"):
|
||||
fixed[-1]["_thinking_signature_invalidated"] = True
|
||||
# Drop thinking blocks from the *second* message: their
|
||||
# signature was computed against a different turn boundary
|
||||
# and becomes invalid once merged.
|
||||
@@ -1938,26 +1926,11 @@ def _manage_thinking_signatures(
|
||||
else:
|
||||
# Latest assistant on direct Anthropic: keep signed, downgrade unsigned
|
||||
# to text so the reasoning isn't lost.
|
||||
#
|
||||
# Exception: if orphan-stripping (or another structural mutation) removed
|
||||
# a tool_use block from THIS turn, every thinking signature on it was
|
||||
# computed against the original turn content and is now dead. Anthropic
|
||||
# rejects the turn either way — replaying the signed block 400s with
|
||||
# "thinking blocks in the latest assistant message cannot be modified",
|
||||
# and a bare signed block with no following tool_use is also invalid.
|
||||
# Demote ALL thinking blocks on this turn to text so the turn replays
|
||||
# cleanly and the model can re-plan from the surviving tool results.
|
||||
signature_dead = bool(m.get("_thinking_signature_invalidated"))
|
||||
new_content = []
|
||||
for b in m["content"]:
|
||||
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
||||
new_content.append(b)
|
||||
continue
|
||||
if signature_dead:
|
||||
thinking_text = b.get("thinking", "")
|
||||
if thinking_text:
|
||||
new_content.append({"type": "text", "text": thinking_text})
|
||||
continue
|
||||
if b.get("type") == "redacted_thinking":
|
||||
# Redacted blocks use 'data' for the signature payload —
|
||||
# drop the block when 'data' is missing (can't be validated).
|
||||
@@ -1977,9 +1950,6 @@ def _manage_thinking_signatures(
|
||||
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
|
||||
b.pop("cache_control", None)
|
||||
|
||||
# Drop the internal bookkeeping flag — it must never reach the API payload.
|
||||
m.pop("_thinking_signature_invalidated", None)
|
||||
|
||||
|
||||
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
"""Keep only the most recent ``_MAX_KEEP_IMAGES`` computer-use screenshots.
|
||||
|
||||
+49
-345
@@ -700,20 +700,12 @@ class _CodexCompletionsAdapter:
|
||||
# xAI's Responses endpoint rejects ``pattern`` and ``format`` JSON Schema
|
||||
# keywords (HTTP 400). Strip them here to match the parity guarantee that
|
||||
# chat_completion_helpers.py provides for the main-agent xAI path.
|
||||
#
|
||||
# Deep-copy before sanitizing — ``list(tools)`` is only a shallow
|
||||
# copy of the outer list, but the sanitizers mutate the inner
|
||||
# parameter dicts in place. Without a deep copy the caller's
|
||||
# tool registry permanently loses its slash-containing enum
|
||||
# constraints after the first auxiliary xAI call. See #27907.
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools = _copy.deepcopy(list(tools))
|
||||
tools, _ = strip_pattern_and_format(tools)
|
||||
tools, _ = strip_pattern_and_format(list(tools))
|
||||
tools, _ = strip_slash_enum(tools)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -836,7 +828,7 @@ class _CodexCompletionsAdapter:
|
||||
val = obj.get(key, default)
|
||||
return val if val is not None else default
|
||||
|
||||
for item in (getattr(final, "output", None) or []):
|
||||
for item in getattr(final, "output", []):
|
||||
item_type = _item_get(item, "type")
|
||||
if item_type == "message":
|
||||
for part in (_item_get(item, "content") or []):
|
||||
@@ -1243,23 +1235,8 @@ def _read_nous_auth() -> Optional[dict]:
|
||||
|
||||
|
||||
def _nous_api_key(provider: dict) -> str:
|
||||
"""Extract a usable Nous inference JWT from stored auth state."""
|
||||
from hermes_cli.auth import _nous_invoke_jwt_is_usable
|
||||
|
||||
for token_key, expiry_key in (
|
||||
("agent_key", "agent_key_expires_at"),
|
||||
("access_token", "expires_at"),
|
||||
):
|
||||
token = provider.get(token_key)
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
continue
|
||||
if _nous_invoke_jwt_is_usable(
|
||||
token,
|
||||
scope=provider.get("scope"),
|
||||
expires_at=provider.get(expiry_key),
|
||||
):
|
||||
return token
|
||||
return ""
|
||||
"""Extract the Nous runtime credential from the compatibility field."""
|
||||
return provider.get("agent_key") or provider.get("access_token", "")
|
||||
|
||||
|
||||
def _nous_base_url() -> str:
|
||||
@@ -1271,16 +1248,25 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[
|
||||
"""Return fresh Nous runtime credentials when available.
|
||||
|
||||
This mirrors the main agent's 401 recovery path and keeps auxiliary
|
||||
clients aligned with the singleton auth store + JWT refresh flow instead of
|
||||
clients aligned with the singleton auth store + JWT/mint flow instead of
|
||||
relying only on whatever raw tokens happen to be sitting in auth.json
|
||||
or the credential pool.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
from hermes_cli.auth import (
|
||||
NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
force_refresh=force_refresh,
|
||||
inference_auth_mode=(
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
if force_refresh
|
||||
else NOUS_INFERENCE_AUTH_MODE_AUTO
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary Nous runtime credential resolution failed: %s", exc)
|
||||
@@ -1564,9 +1550,13 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
_mark_provider_unhealthy("nous", ttl=60)
|
||||
return None, None
|
||||
if runtime is None and nous:
|
||||
# Runtime credential mint failed but stored Nous auth is still present.
|
||||
# Falls back to the raw stored token below; surface a debug line so
|
||||
# operators investigating expired/invalid sessions have a breadcrumb,
|
||||
# without blocking the fallback path the rest of this function relies on.
|
||||
logger.debug(
|
||||
"Auxiliary Nous: runtime JWT refresh failed; checking stored "
|
||||
"auth.json token."
|
||||
"Auxiliary Nous: runtime credential mint failed; falling back to "
|
||||
"stored auth.json token."
|
||||
)
|
||||
global auxiliary_is_nous
|
||||
auxiliary_is_nous = True
|
||||
@@ -1604,13 +1594,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
api_key, base_url = runtime
|
||||
else:
|
||||
api_key = _nous_api_key(nous or {})
|
||||
if not api_key:
|
||||
logger.warning(
|
||||
"Auxiliary Nous client unavailable: no usable inference JWT found "
|
||||
"(run: hermes auth add nous)."
|
||||
)
|
||||
_mark_provider_unhealthy("nous", ttl=60)
|
||||
return None, None
|
||||
base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/")
|
||||
return (
|
||||
OpenAI(
|
||||
@@ -1621,47 +1604,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
)
|
||||
|
||||
|
||||
def _refresh_nous_recommended_model(
|
||||
*, vision: bool, stale_model: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Re-fetch the Nous Portal's recommended model after a stale-model 404.
|
||||
|
||||
Long-lived processes (gateway, watchers) cache the Portal's
|
||||
``recommended-models`` payload for 10 minutes and, in practice, can pin a
|
||||
model for the whole process lifetime. When that model is later dropped from
|
||||
the Nous → OpenRouter catalog, every auxiliary call 404s with
|
||||
"model does not exist". This forces a fresh Portal fetch and returns a
|
||||
model name to retry with:
|
||||
|
||||
* the Portal's current recommendation for the task, if it differs from
|
||||
the model that just failed; otherwise
|
||||
* ``_NOUS_MODEL`` (google/gemini-3-flash-preview), the known-good default,
|
||||
if it too differs from the failed model.
|
||||
|
||||
Returns ``None`` when no usable alternative is available (e.g. the Portal
|
||||
still recommends the exact model that just 404'd and the default also
|
||||
matches it) — callers should then let the original error propagate.
|
||||
"""
|
||||
stale = (stale_model or "").strip().lower()
|
||||
fresh: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.models import get_nous_recommended_aux_model
|
||||
|
||||
fresh = get_nous_recommended_aux_model(vision=vision, force_refresh=True)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Nous recommended-model refresh failed (%s); using default %s",
|
||||
exc, _NOUS_MODEL,
|
||||
)
|
||||
if fresh and fresh.strip().lower() != stale:
|
||||
return fresh
|
||||
# Portal recommendation unchanged or unavailable — fall back to the
|
||||
# hardcoded known-good default, but only if it's actually different.
|
||||
if _NOUS_MODEL.strip().lower() != stale:
|
||||
return _NOUS_MODEL
|
||||
return None
|
||||
|
||||
|
||||
def _read_main_model() -> str:
|
||||
"""Read the user's configured main model from config.yaml.
|
||||
|
||||
@@ -1721,48 +1663,26 @@ def _read_main_provider() -> str:
|
||||
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
|
||||
_RUNTIME_MAIN_PROVIDER: str = ""
|
||||
_RUNTIME_MAIN_MODEL: str = ""
|
||||
_RUNTIME_MAIN_BASE_URL: str = ""
|
||||
_RUNTIME_MAIN_API_KEY: str = ""
|
||||
_RUNTIME_MAIN_API_MODE: str = ""
|
||||
|
||||
|
||||
def set_runtime_main(
|
||||
provider: str,
|
||||
model: str,
|
||||
*,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
api_mode: str = "",
|
||||
) -> None:
|
||||
"""Record the live runtime provider/model/credentials for the current AIAgent.
|
||||
def set_runtime_main(provider: str, model: str) -> None:
|
||||
"""Record the live runtime provider/model for the current AIAgent.
|
||||
|
||||
Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
|
||||
equivalent setter) at the top of each turn so that
|
||||
``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
|
||||
overrides instead of the stale config.yaml default.
|
||||
|
||||
For ``custom:`` providers, ``base_url`` and ``api_key`` must also be
|
||||
recorded so that ``_resolve_auto`` can construct a valid client in
|
||||
Step 1 instead of falling through to the aggregator chain.
|
||||
"""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
|
||||
_RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
|
||||
_RUNTIME_MAIN_MODEL = (model or "").strip()
|
||||
_RUNTIME_MAIN_BASE_URL = (base_url or "").strip()
|
||||
_RUNTIME_MAIN_API_KEY = api_key.strip() if isinstance(api_key, str) else ""
|
||||
_RUNTIME_MAIN_API_MODE = (api_mode or "").strip()
|
||||
|
||||
|
||||
def clear_runtime_main() -> None:
|
||||
"""Clear the runtime override (e.g. on session end)."""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
|
||||
_RUNTIME_MAIN_PROVIDER = ""
|
||||
_RUNTIME_MAIN_MODEL = ""
|
||||
_RUNTIME_MAIN_BASE_URL = ""
|
||||
_RUNTIME_MAIN_API_KEY = ""
|
||||
_RUNTIME_MAIN_API_MODE = ""
|
||||
|
||||
|
||||
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
@@ -2324,15 +2244,11 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
# but sometimes wrap them in 429 or other codes.
|
||||
# Daily quota exhaustion from Bedrock, Vertex AI, and similar providers
|
||||
# uses different language but is semantically identical to credit exhaustion.
|
||||
if status in {402, 404, 429, None}:
|
||||
if status in {402, 429, None}:
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds",
|
||||
"can only afford", "billing",
|
||||
"payment required",
|
||||
"out of funds", "run out of funds",
|
||||
"balance_depleted", "no usable credits",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
# Daily / monthly / weekly quota exhaustion keywords
|
||||
"quota exceeded", "quota_exceeded",
|
||||
"too many tokens per day", "daily limit",
|
||||
@@ -2344,18 +2260,6 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _nous_portal_account_has_fresh_paid_access() -> bool:
|
||||
"""Return True only when the fresh Nous account API says paid access is allowed."""
|
||||
try:
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
return account_info.paid_service_access is True
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary Nous paid-entitlement refresh check failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _is_rate_limit_error(exc: Exception) -> bool:
|
||||
"""Detect rate-limit errors that warrant provider fallback.
|
||||
|
||||
@@ -2384,10 +2288,6 @@ def _is_rate_limit_error(exc: Exception) -> bool:
|
||||
if not any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing",
|
||||
"payment required", "can only afford",
|
||||
"out of funds", "run out of funds",
|
||||
"balance_depleted", "no usable credits",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
)):
|
||||
return True
|
||||
return False
|
||||
@@ -2437,16 +2337,7 @@ def _is_auth_error(exc: Exception) -> bool:
|
||||
if status == 401:
|
||||
return True
|
||||
err_lower = str(exc).lower()
|
||||
if "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower():
|
||||
return True
|
||||
# xAI returns HTTP 403 with "unauthenticated:bad-credentials" when an OAuth2
|
||||
# access token has expired or is invalid — semantically a 401 auth failure,
|
||||
# even though the status code is 403 (PermissionDenied).
|
||||
if status == 403 and "bad-credentials" in err_lower:
|
||||
return True
|
||||
if "unauthenticated" in err_lower and "bad-credentials" in err_lower:
|
||||
return True
|
||||
return False
|
||||
return "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower()
|
||||
|
||||
|
||||
def _is_unsupported_parameter_error(exc: Exception, param: str) -> bool:
|
||||
@@ -2492,46 +2383,6 @@ def _is_unsupported_temperature_error(exc: Exception) -> bool:
|
||||
return _is_unsupported_parameter_error(exc, "temperature")
|
||||
|
||||
|
||||
def _is_model_not_found_error(exc: Exception) -> bool:
|
||||
"""Detect "the requested model doesn't exist" errors (404 / invalid model).
|
||||
|
||||
This fires when a resolved model name is no longer served by the endpoint
|
||||
— most commonly when a long-lived process pinned a Portal-recommended model
|
||||
that has since been dropped from the Nous → OpenRouter catalog. The Nous
|
||||
proxy returns 404 with a body like::
|
||||
|
||||
Model 'gpt-5.4-mini' not found. The requested model does not exist
|
||||
in our configuration or OpenRouter catalog.
|
||||
|
||||
Distinct from :func:`_is_payment_error` (which also matches some 404s for
|
||||
free-tier/credit language) — this one keys on "does not exist / not found /
|
||||
not a valid model" phrasing, and explicitly excludes the billing keywords
|
||||
that the payment path already owns so the two predicates don't overlap.
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
err_lower = str(exc).lower()
|
||||
# Billing/quota 404s belong to _is_payment_error — don't claim them here.
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing", "out of funds",
|
||||
"balance_depleted", "no usable credits", "free tier", "free-tier",
|
||||
"not available on the free tier",
|
||||
)):
|
||||
return False
|
||||
if status not in {404, 400, None}:
|
||||
return False
|
||||
return any(kw in err_lower for kw in (
|
||||
"model does not exist",
|
||||
"does not exist in our configuration",
|
||||
"openrouter catalog",
|
||||
"is not a valid model",
|
||||
"no such model",
|
||||
"model not found",
|
||||
"the model `", # OpenAI-style: "The model `X` does not exist"
|
||||
"model_not_found",
|
||||
"unknown model",
|
||||
))
|
||||
|
||||
|
||||
def _evict_cached_clients(provider: str) -> None:
|
||||
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -2639,8 +2490,6 @@ def _recoverable_pool_provider(
|
||||
return "copilot"
|
||||
if base_url_host_matches(base, "api.kimi.com"):
|
||||
return "kimi-coding"
|
||||
if base_url_host_matches(base, "api.x.ai"):
|
||||
return "xai-oauth"
|
||||
# For api_key providers not in the hardcoded list (e.g. opencode-go), match
|
||||
# the client base URL against all registered api_key providers so that
|
||||
# credential-pool rotation works for any provider the user configured.
|
||||
@@ -2837,11 +2686,15 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "nous":
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
from hermes_cli.auth import (
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
force_refresh=True,
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
)
|
||||
if not str(creds.get("api_key", "") or "").strip():
|
||||
return False
|
||||
@@ -2858,24 +2711,6 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "xai-oauth":
|
||||
# Preference: pool-level refresh (uses refresh_token from pool entry),
|
||||
# then fall back to singleton auth-store resolver.
|
||||
pool = load_pool(normalized)
|
||||
if pool and pool.has_credentials():
|
||||
# Ensure a current entry is selected before trying to refresh.
|
||||
pool.select()
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is not None and str(getattr(refreshed, "runtime_api_key", "") or "").strip():
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
|
||||
|
||||
creds = resolve_xai_oauth_runtime_credentials(force_refresh=True)
|
||||
if not str(creds.get("api_key", "") or "").strip():
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc)
|
||||
return False
|
||||
@@ -3083,18 +2918,6 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
runtime_api_key = runtime.get("api_key", "")
|
||||
runtime_api_mode = str(runtime.get("api_mode") or "")
|
||||
|
||||
# Fall back to process-local globals when main_runtime dict was not
|
||||
# provided or was incomplete. ``set_runtime_main()`` now records
|
||||
# base_url/api_key/api_mode alongside provider/model, so custom:
|
||||
# providers get the full credential surface in Step 1 of the
|
||||
# auto-detect chain.
|
||||
if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:
|
||||
runtime_base_url = _RUNTIME_MAIN_BASE_URL
|
||||
if not runtime_api_key and _RUNTIME_MAIN_API_KEY:
|
||||
runtime_api_key = _RUNTIME_MAIN_API_KEY
|
||||
if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:
|
||||
runtime_api_mode = _RUNTIME_MAIN_API_MODE
|
||||
|
||||
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
|
||||
# provider (not 'custom'). This catches the common "env poisoning"
|
||||
# scenario where a user switches providers via `hermes model` but the
|
||||
@@ -4840,23 +4663,24 @@ def _build_call_kwargs(
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if max_tokens is not None:
|
||||
# We do NOT cap output by default. Most chat-completions providers treat
|
||||
# an omitted max_tokens as "use the model's max output", which is what we
|
||||
# want for auxiliary tasks (compression summaries, titles, vision, etc.) —
|
||||
# an explicit cap only risks truncating a summary or 400-ing on providers
|
||||
# that reject the parameter outright (e.g. GitHub Copilot / newer OpenAI
|
||||
# GPT-5 models require max_completion_tokens, not max_tokens; ZAI vision
|
||||
# models reject it entirely with error 1210). Omitting it sidesteps all of
|
||||
# those wire-format quirks at once.
|
||||
#
|
||||
# The one exception is the Anthropic Messages wire (MiniMax and any
|
||||
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
|
||||
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
|
||||
# there.
|
||||
_effective_base = base_url or (
|
||||
_current_custom_base_url() if provider == "custom" else ""
|
||||
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
|
||||
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
|
||||
# ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with
|
||||
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
|
||||
_model_lower = (model or "").lower()
|
||||
_skip_max_tokens = (
|
||||
provider == "zai"
|
||||
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base):
|
||||
if _skip_max_tokens:
|
||||
pass # ZAI vision models do not accept max_tokens
|
||||
elif provider == "custom":
|
||||
custom_base = base_url or _current_custom_base_url()
|
||||
if base_url_hostname(custom_base) == "api.openai.com":
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
else:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
else:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if tools:
|
||||
@@ -5108,72 +4932,11 @@ def call_llm(
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
|
||||
# A long-lived process can pin a Portal-recommended model that has
|
||||
# since been dropped from the Nous → OpenRouter catalog, so every
|
||||
# auxiliary call 404s with "model does not exist". Force a fresh
|
||||
# Portal fetch and retry once with the current recommendation (or the
|
||||
# known-good default). Only applies to Nous-routed calls.
|
||||
_heal_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
|
||||
)
|
||||
if _is_model_not_found_error(first_err) and _heal_is_nous:
|
||||
healed_model = _refresh_nous_recommended_model(
|
||||
vision=(task == "vision"), stale_model=kwargs.get("model"))
|
||||
if healed_model and healed_model != kwargs.get("model"):
|
||||
logger.warning(
|
||||
"Auxiliary %s: model %r no longer in Nous catalog; "
|
||||
"retrying with refreshed recommendation %r",
|
||||
task or "call", kwargs.get("model"), healed_model,
|
||||
)
|
||||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
# ── Nous auth refresh parity with main agent ──────────────────
|
||||
client_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
|
||||
)
|
||||
if (
|
||||
_is_payment_error(first_err)
|
||||
and client_is_nous
|
||||
and _nous_portal_account_has_fresh_paid_access()
|
||||
):
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
model=final_model,
|
||||
async_mode=False,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=(task == "vision"),
|
||||
)
|
||||
if refreshed_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: refreshed Nous runtime credentials after paid account check, retrying",
|
||||
task or "call",
|
||||
)
|
||||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
or _is_payment_error(retry_err)
|
||||
or _is_connection_error(retry_err)
|
||||
or _is_rate_limit_error(retry_err)
|
||||
):
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
if _is_auth_error(first_err) and client_is_nous:
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
@@ -5571,70 +5334,11 @@ async def async_call_llm(
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
|
||||
# See the sync call_llm() path for the rationale: a long-lived process
|
||||
# can pin a Portal-recommended model that has since been dropped from
|
||||
# the Nous → OpenRouter catalog, 404'ing every auxiliary call. Force a
|
||||
# fresh Portal fetch and retry once with the current recommendation.
|
||||
_heal_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
|
||||
)
|
||||
if _is_model_not_found_error(first_err) and _heal_is_nous:
|
||||
healed_model = _refresh_nous_recommended_model(
|
||||
vision=(task == "vision"), stale_model=kwargs.get("model"))
|
||||
if healed_model and healed_model != kwargs.get("model"):
|
||||
logger.warning(
|
||||
"Auxiliary %s (async): model %r no longer in Nous catalog; "
|
||||
"retrying with refreshed recommendation %r",
|
||||
task or "call", kwargs.get("model"), healed_model,
|
||||
)
|
||||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
# ── Nous auth refresh parity with main agent ──────────────────
|
||||
client_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
|
||||
)
|
||||
if (
|
||||
_is_payment_error(first_err)
|
||||
and client_is_nous
|
||||
and _nous_portal_account_has_fresh_paid_access()
|
||||
):
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
model=final_model,
|
||||
async_mode=True,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
is_vision=(task == "vision"),
|
||||
)
|
||||
if refreshed_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s (async): refreshed Nous runtime credentials after paid account check, retrying",
|
||||
task or "call",
|
||||
)
|
||||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
or _is_payment_error(retry_err)
|
||||
or _is_connection_error(retry_err)
|
||||
or _is_rate_limit_error(retry_err)
|
||||
):
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
if _is_auth_error(first_err) and client_is_nous:
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
|
||||
@@ -483,11 +483,6 @@ def _run_review_in_thread(
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
|
||||
# Snapshot review actions before teardown. close() is allowed to
|
||||
# clean per-session state, but the user-visible self-improvement
|
||||
# summary still needs the completed review agent's tool results.
|
||||
review_messages = list(getattr(review_agent, "_session_messages", []))
|
||||
|
||||
# Tear down memory providers while stdout is still
|
||||
# redirected so background thread teardown (Honcho flush,
|
||||
# Hindsight sync, etc.) stays silent. The finally block
|
||||
@@ -500,6 +495,7 @@ def _run_review_in_thread(
|
||||
review_agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
review_messages = list(getattr(review_agent, "_session_messages", []))
|
||||
review_agent = None
|
||||
|
||||
# Scan the review agent's messages for successful tool actions
|
||||
|
||||
@@ -1167,6 +1167,18 @@ def _extract_provider_from_arn(arn: str) -> str:
|
||||
"""
|
||||
match = re.search(r"foundation-model/([^.]+)", arn)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def get_bedrock_model_ids(region: str) -> List[str]:
|
||||
"""Return a flat list of available Bedrock model IDs for the given region.
|
||||
|
||||
Convenience wrapper around ``discover_bedrock_models()`` for use in
|
||||
the model selection UI.
|
||||
"""
|
||||
models = discover_bedrock_models(region)
|
||||
return [m["id"] for m in models]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classification — Bedrock-specific exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -186,6 +186,37 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]:
|
||||
return None
|
||||
|
||||
|
||||
def get_active_browser_provider() -> Optional[BrowserProvider]:
|
||||
"""Resolve the currently-active cloud browser provider.
|
||||
|
||||
Reads ``browser.cloud_provider`` from config.yaml; falls back per the
|
||||
module docstring. Returns None for local mode or when no provider is
|
||||
available.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {})
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read browser config: %s", exc)
|
||||
browser_cfg = {}
|
||||
|
||||
configured: Optional[str] = None
|
||||
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
|
||||
try:
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
|
||||
configured = normalize_browser_cloud_provider(
|
||||
browser_cfg.get("cloud_provider")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("normalize_browser_cloud_provider failed: %s", exc)
|
||||
configured = None
|
||||
|
||||
return _resolve(configured)
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the registry. **Test-only.**"""
|
||||
with _lock:
|
||||
|
||||
@@ -15,23 +15,49 @@ sites unchanged. Symbols that tests patch on ``run_agent`` (e.g.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
from agent.message_sanitization import (
|
||||
_sanitize_surrogates,
|
||||
_sanitize_messages_surrogates,
|
||||
_sanitize_structure_surrogates,
|
||||
_sanitize_messages_non_ascii,
|
||||
_sanitize_tools_non_ascii,
|
||||
_sanitize_structure_non_ascii,
|
||||
_strip_images_from_messages,
|
||||
_strip_non_ascii,
|
||||
_repair_tool_call_arguments,
|
||||
_escape_invalid_chars_in_json_strings,
|
||||
)
|
||||
from agent.tool_dispatch_helpers import (
|
||||
_is_multimodal_tool_result,
|
||||
_multimodal_text_summary,
|
||||
)
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.tool_guardrails import (
|
||||
ToolGuardrailDecision,
|
||||
append_toolguard_guidance,
|
||||
toolguard_synthetic_result,
|
||||
)
|
||||
from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
@@ -103,24 +129,6 @@ def estimate_request_context_tokens(api_payload: Any) -> int:
|
||||
return _chars(api_payload) // 4
|
||||
|
||||
|
||||
def _is_openai_codex_backend(agent) -> bool:
|
||||
base_url_lower = str(getattr(agent, "_base_url_lower", "") or "")
|
||||
base_url_hostname = str(getattr(agent, "_base_url_hostname", "") or "")
|
||||
return (
|
||||
getattr(agent, "provider", None) == "openai-codex"
|
||||
or (
|
||||
base_url_hostname == "chatgpt.com"
|
||||
and "/backend-api/codex" in base_url_lower
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def interruptible_api_call(agent, api_kwargs: dict):
|
||||
"""
|
||||
@@ -149,6 +157,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# #29507: dispatch on the calling thread.
|
||||
#
|
||||
@@ -241,96 +256,32 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
# apply richer recovery (credential rotation, provider fallback).
|
||||
_stale_timeout = agent._compute_non_stream_stale_timeout(api_kwargs)
|
||||
|
||||
# ── Codex Responses stream watchdogs ────────────────────────────────
|
||||
# ── Time-to-first-byte (TTFB) watchdog for the Codex Responses stream ──
|
||||
# The chatgpt.com/backend-api/codex endpoint has an intermittent failure
|
||||
# mode where it accepts the connection but never emits a single stream
|
||||
# event (observed directly: 0 events, no HTTP status, the socket just
|
||||
# hangs). A fresh reconnect succeeds in ~2s, but the wall-clock stale
|
||||
# timeout (often 180–900s) makes us wait minutes before retrying. While no
|
||||
# stream event has arrived yet we apply a much shorter TTFB cutoff so the
|
||||
# main retry loop can reconnect promptly. Large subscription-backed Codex
|
||||
# requests can legitimately spend tens of seconds in backend admission /
|
||||
# prompt prefill before the first SSE event, so the no-byte TTFB watchdog
|
||||
# is disabled for large chatgpt.com/backend-api/codex requests. A second
|
||||
# failure mode emits an opening SSE frame and then stalls forever in SSL
|
||||
# read; for that we watch the gap since the last Codex stream event. This
|
||||
# matches Codex CLI's stream_idle_timeout model: any valid SSE event is
|
||||
# activity. Operators can tune via HERMES_CODEX_TTFB_TIMEOUT_SECONDS and
|
||||
# HERMES_CODEX_EVENT_STALE_TIMEOUT_SECONDS (0 disables each).
|
||||
_codex_watchdog_enabled = agent.api_mode == "codex_responses"
|
||||
_openai_codex_backend = _is_openai_codex_backend(agent)
|
||||
_est_tokens_for_codex_watchdog = estimate_request_context_tokens(api_kwargs)
|
||||
if _codex_watchdog_enabled and _openai_codex_backend:
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_stale_timeout = max(_stale_timeout, 1200.0)
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
_stale_timeout = max(_stale_timeout, 900.0)
|
||||
elif _est_tokens_for_codex_watchdog > 25_000:
|
||||
_stale_timeout = max(_stale_timeout, 600.0)
|
||||
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_codex_idle_timeout_default = 180.0
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
_codex_idle_timeout_default = 120.0
|
||||
elif _est_tokens_for_codex_watchdog > 10_000:
|
||||
_codex_idle_timeout_default = 60.0
|
||||
else:
|
||||
_codex_idle_timeout_default = 12.0
|
||||
|
||||
# No-byte TTFB cutoff. The OpenAI SDK's own streaming read timeout is far
|
||||
# longer (openai 2.x DEFAULT_TIMEOUT.read = 600s), so a tight 12s default
|
||||
# killed subscription-backed Codex requests mid-prefill before the backend
|
||||
# had a chance to emit its first SSE event. Default to 120s — long enough to
|
||||
# clear normal backend admission / prompt prefill, short enough to still
|
||||
# reconnect promptly when the socket is genuinely wedged. Set
|
||||
# HERMES_CODEX_TTFB_TIMEOUT_SECONDS=0 to disable this watchdog entirely.
|
||||
_ttfb_enabled = _codex_watchdog_enabled
|
||||
_ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 120.0)
|
||||
# main retry loop can reconnect promptly. Once the first event arrives the
|
||||
# stream is healthy, so we fall back to the wall-clock stale timeout and
|
||||
# never interrupt a legitimate long generation. Gated to codex_responses:
|
||||
# only that path streams events incrementally (the chat_completions
|
||||
# non-stream, anthropic and bedrock branches here have no first-event
|
||||
# signal). The marker advances on *any* event (see codex_runtime), so
|
||||
# reasoning-only / tool-call-only turns are not mistaken for a stall.
|
||||
# Operators can tune via HERMES_CODEX_TTFB_TIMEOUT_SECONDS (0 disables).
|
||||
_ttfb_enabled = agent.api_mode == "codex_responses"
|
||||
try:
|
||||
_ttfb_timeout = float(os.getenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "45"))
|
||||
except (TypeError, ValueError):
|
||||
_ttfb_timeout = 45.0
|
||||
if _ttfb_timeout <= 0:
|
||||
_ttfb_enabled = False
|
||||
elif _openai_codex_backend:
|
||||
_ttfb_disable_above = _env_float("HERMES_CODEX_TTFB_DISABLE_ABOVE_TOKENS", 25_000.0)
|
||||
_ttfb_strict = os.environ.get("HERMES_CODEX_TTFB_STRICT", "").strip().lower() in {
|
||||
"1", "true", "yes", "on"
|
||||
}
|
||||
if (
|
||||
not _ttfb_strict
|
||||
and _ttfb_disable_above > 0
|
||||
and _est_tokens_for_codex_watchdog >= _ttfb_disable_above
|
||||
):
|
||||
_ttfb_enabled = False
|
||||
logger.info(
|
||||
"Disabling openai-codex no-byte TTFB watchdog for large request "
|
||||
"(context=~%s tokens >= %.0f). Waiting for backend response instead. "
|
||||
"Set HERMES_CODEX_TTFB_STRICT=1 to force early reconnects.",
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
_ttfb_disable_above,
|
||||
)
|
||||
else:
|
||||
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0)
|
||||
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
|
||||
logger.info(
|
||||
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
|
||||
"(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.",
|
||||
_ttfb_timeout,
|
||||
_ttfb_cap,
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
)
|
||||
_ttfb_timeout = _ttfb_cap
|
||||
|
||||
_codex_idle_enabled = _codex_watchdog_enabled
|
||||
_codex_idle_timeout = _env_float(
|
||||
"HERMES_CODEX_EVENT_STALE_TIMEOUT_SECONDS",
|
||||
_codex_idle_timeout_default,
|
||||
)
|
||||
if _codex_idle_timeout <= 0:
|
||||
_codex_idle_enabled = False
|
||||
|
||||
if _codex_watchdog_enabled:
|
||||
if _ttfb_enabled:
|
||||
# Reset before the worker starts so a marker left over from a previous
|
||||
# call on this agent can't be misread as first-byte for this one.
|
||||
agent._codex_stream_last_event_ts = None
|
||||
agent._codex_stream_last_progress_ts = None
|
||||
|
||||
_call_start = time.time()
|
||||
agent._touch_activity("waiting for non-streaming API response")
|
||||
@@ -377,13 +328,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
_elapsed, _ttfb_timeout, api_kwargs.get("model", "unknown"),
|
||||
)
|
||||
if _silent_hint:
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
f"⚠️ No first byte from provider in {int(_elapsed)}s "
|
||||
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting. {_silent_hint}"
|
||||
)
|
||||
else:
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
f"⚠️ No first byte from provider in {int(_elapsed)}s "
|
||||
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting."
|
||||
@@ -410,45 +361,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
)
|
||||
break
|
||||
|
||||
# Stream-idle detector: the Codex backend emitted at least one SSE
|
||||
# frame, then stopped emitting events. Valid keepalive / in_progress
|
||||
# frames refresh _codex_stream_last_event_ts and should not be killed.
|
||||
_last_codex_event_ts = getattr(agent, "_codex_stream_last_event_ts", None)
|
||||
if (
|
||||
_codex_idle_enabled
|
||||
and _last_codex_event_ts is not None
|
||||
and (time.time() - _last_codex_event_ts) > _codex_idle_timeout
|
||||
):
|
||||
_event_stale_elapsed = time.time() - _last_codex_event_ts
|
||||
logger.warning(
|
||||
"Codex stream produced no SSE events for %.0fs after first byte "
|
||||
"(threshold %.0fs, model=%s, context=~%s tokens). Killing "
|
||||
"connection so the retry loop can reconnect.",
|
||||
_event_stale_elapsed,
|
||||
_codex_idle_timeout,
|
||||
api_kwargs.get("model", "unknown"),
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
)
|
||||
agent._buffer_status(
|
||||
f"⚠️ Codex stream sent no events for {int(_event_stale_elapsed)}s "
|
||||
f"after first byte (model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting."
|
||||
)
|
||||
try:
|
||||
_close_request_client_once("codex_stream_idle_kill")
|
||||
except Exception:
|
||||
pass
|
||||
agent._touch_activity(
|
||||
f"codex stream killed after {int(_event_stale_elapsed)}s with no SSE events"
|
||||
)
|
||||
t.join(timeout=2.0)
|
||||
if result["error"] is None and result["response"] is None:
|
||||
result["error"] = TimeoutError(
|
||||
f"Codex stream produced no SSE events for {int(_event_stale_elapsed)}s "
|
||||
f"after first byte (threshold: {int(_codex_idle_timeout)}s)"
|
||||
)
|
||||
break
|
||||
|
||||
# Stale-call detector: kill the connection if no response
|
||||
# arrives within the configured timeout.
|
||||
if _elapsed > _stale_timeout:
|
||||
@@ -467,13 +379,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
api_kwargs.get("model", "unknown"), f"{_est_ctx:,}",
|
||||
)
|
||||
if _silent_hint:
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
f"⚠️ No response from provider for {int(_elapsed)}s "
|
||||
f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"{_silent_hint}"
|
||||
)
|
||||
else:
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
f"⚠️ No response from provider for {int(_elapsed)}s "
|
||||
f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Aborting call."
|
||||
@@ -588,23 +500,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
# It also rejects ``enum`` values containing ``/`` (HuggingFace IDs
|
||||
# like ``Qwen/Qwen3.5-0.8B`` shipped by MCP servers) — same 400 with
|
||||
# the same opaque message; strip those enums too.
|
||||
#
|
||||
# Deep-copy ``tools_for_api`` before sanitizing: the sanitizers
|
||||
# mutate in place (documented contract on ``strip_slash_enum`` /
|
||||
# ``strip_pattern_and_format``), and ``tools_for_api`` is a direct
|
||||
# reference to ``agent.tools``. Without the copy, the first xAI
|
||||
# request permanently strips constraints from the shared per-agent
|
||||
# tool registry — every subsequent non-xAI call from the same
|
||||
# agent (auxiliary task routed to Anthropic, OpenRouter fallback,
|
||||
# main-model swap) sees the already-stripped schema. See #27907.
|
||||
if is_xai_responses:
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools_for_api = _copy.deepcopy(tools_for_api)
|
||||
tools_for_api, _ = strip_pattern_and_format(tools_for_api)
|
||||
tools_for_api, _ = strip_slash_enum(tools_for_api)
|
||||
except Exception as exc:
|
||||
@@ -1141,25 +1042,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
agent._transport_cache.clear()
|
||||
agent._fallback_activated = True
|
||||
|
||||
# Clear the credential pool when the fallback provider doesn't match
|
||||
# the pool's provider. The pool was seeded for the primary provider;
|
||||
# leaving it attached means downstream recovery (rate_limit / billing /
|
||||
# auth) calls ``_swap_credential`` with a primary entry which overwrites
|
||||
# the agent's ``base_url`` back to the primary's endpoint — every
|
||||
# fallback request then 404s against the wrong host. See #33163.
|
||||
# When the fallback shares the pool's provider (e.g. both openrouter
|
||||
# entries with different routing) the pool is preserved.
|
||||
_existing_pool = getattr(agent, "_credential_pool", None)
|
||||
if _existing_pool is not None:
|
||||
_pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower()
|
||||
if _pool_provider and _pool_provider != fb_provider:
|
||||
logger.info(
|
||||
"Fallback to %s/%s: clearing primary credential pool "
|
||||
"(pool_provider=%s) to prevent cross-provider contamination",
|
||||
fb_provider, fb_model, _pool_provider,
|
||||
)
|
||||
agent._credential_pool = None
|
||||
|
||||
# Honor per-provider / per-model request_timeout_seconds for the
|
||||
# fallback target (same knob the primary client uses). None = use
|
||||
# SDK default.
|
||||
@@ -1247,7 +1129,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
f"🔄 Primary model failed — switching to fallback: "
|
||||
f"{fb_model} via {fb_provider}"
|
||||
)
|
||||
@@ -1283,18 +1165,6 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
agent._copy_reasoning_content_for_api(msg, api_msg)
|
||||
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
|
||||
api_msg.pop(internal_field, None)
|
||||
# Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go,
|
||||
# Mistral, Moonshot/Kimi) reject any message key outside the Chat
|
||||
# Completions schema. The main loop drops these via
|
||||
# ChatCompletionsTransport.convert_messages(), but the summary path
|
||||
# hand-builds messages and calls chat.completions.create() directly,
|
||||
# bypassing the transport — so mirror that sanitization here:
|
||||
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
|
||||
# and every Hermes-internal underscore-prefixed scaffolding key.
|
||||
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
|
||||
api_msg.pop(schema_foreign, None)
|
||||
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
|
||||
api_msg.pop(internal_key, None)
|
||||
if _needs_sanitize:
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
api_messages.append(api_msg)
|
||||
@@ -1633,6 +1503,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# See #29507 explanation in the non-streaming variant above. A
|
||||
# stranger thread (the interrupt-check / stale-stream detector loop)
|
||||
@@ -2241,7 +2118,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
mid_tool_call=False,
|
||||
diag=request_client_holder.get("diag"),
|
||||
)
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
"❌ Provider returned malformed streaming data after "
|
||||
f"{_max_stream_retries + 1} attempts. "
|
||||
"The provider may be experiencing issues — "
|
||||
@@ -2348,7 +2225,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_stale_elapsed, _stream_stale_timeout,
|
||||
api_kwargs.get("model", "unknown"), f"{_est_ctx:,}",
|
||||
)
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
f"⚠️ No response from provider for {int(_stale_elapsed)}s "
|
||||
f"(model: {api_kwargs.get('model', 'unknown')}, "
|
||||
f"context: ~{_est_ctx:,} tokens). "
|
||||
|
||||
@@ -913,26 +913,6 @@ def _preflight_codex_api_kwargs(
|
||||
elif "stream" in api_kwargs:
|
||||
raise ValueError("Codex Responses stream flag is only allowed in fallback streaming requests.")
|
||||
|
||||
# Safety-net sanitization for xAI Responses (#28490): defense-in-depth
|
||||
# for the same slash-enum strip that ``chat_completion_helpers`` and
|
||||
# ``auxiliary_client`` apply at request-build time. If a future code
|
||||
# path forgets to sanitize before calling us, this catches the bypass
|
||||
# so xAI doesn't 400 with ``Invalid arguments passed to the model``
|
||||
# (HuggingFace IDs like ``Qwen/Qwen3.5-0.8B`` from MCP tool schemas).
|
||||
#
|
||||
# Gated on the model name pattern because native Codex (OpenAI) DOES
|
||||
# accept slash-containing enum values — stripping them there would
|
||||
# silently degrade tool-schema constraints. xAI is the only
|
||||
# Responses-API surface that rejects the shape.
|
||||
model_name_for_provider_check = str(api_kwargs.get("model") or "").lower()
|
||||
is_xai_model = model_name_for_provider_check.startswith(("grok-", "x-ai/grok-"))
|
||||
if is_xai_model and normalized.get("tools"):
|
||||
try:
|
||||
from tools.schema_sanitizer import strip_slash_enum
|
||||
normalized["tools"], _ = strip_slash_enum(normalized["tools"])
|
||||
except Exception:
|
||||
pass # Best-effort — the caller-level sanitization should have handled it
|
||||
|
||||
unexpected = sorted(key for key in api_kwargs if key not in allowed_keys)
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
@@ -980,48 +960,6 @@ def _extract_responses_reasoning_text(item: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _format_responses_error(error_obj: Any, response_status: str) -> str:
|
||||
"""Build a human-readable error string from a Responses ``response.error`` payload.
|
||||
|
||||
The OpenAI Responses API carries failure details under ``response.error``
|
||||
on terminal ``response.failed`` events, in the shape
|
||||
``{"code": "rate_limit_exceeded", "message": "Slow down", "param": ...}``.
|
||||
Earlier code only surfaced ``message``, which left users staring at bare
|
||||
strings like ``"Slow down"`` while the failure mode (rate limit vs
|
||||
context-length vs internal_error vs model-overloaded) was hidden in
|
||||
``code``. We now prefix ``code`` when both are present so consumers can
|
||||
distinguish failure modes without parsing the bare message.
|
||||
|
||||
Falls back to ``code`` alone when ``message`` is empty, and to a stable
|
||||
default referencing the response status when no error payload is
|
||||
available at all. Adapted from anomalyco/opencode#28757.
|
||||
"""
|
||||
# Pull code and message from either dict or attribute-style payloads.
|
||||
code: Any = None
|
||||
message: Any = None
|
||||
if isinstance(error_obj, dict):
|
||||
code = error_obj.get("code")
|
||||
message = error_obj.get("message")
|
||||
elif error_obj is not None:
|
||||
code = getattr(error_obj, "code", None)
|
||||
message = getattr(error_obj, "message", None)
|
||||
|
||||
code_str = str(code).strip() if isinstance(code, str) else (str(code).strip() if code else "")
|
||||
message_str = str(message).strip() if isinstance(message, str) else (str(message).strip() if message else "")
|
||||
|
||||
if code_str and message_str:
|
||||
return f"{code_str}: {message_str}"
|
||||
if message_str:
|
||||
return message_str
|
||||
if code_str:
|
||||
return code_str
|
||||
if error_obj:
|
||||
# Last-resort: stringify whatever the provider sent so it's at least
|
||||
# visible in logs/UI rather than silently swallowed.
|
||||
return str(error_obj)
|
||||
return f"Responses API returned status '{response_status}'"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full response normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1065,7 +1003,10 @@ def _normalize_codex_response(
|
||||
|
||||
if response_status in {"failed", "cancelled"}:
|
||||
error_obj = getattr(response, "error", None)
|
||||
error_msg = _format_responses_error(error_obj, response_status)
|
||||
if isinstance(error_obj, dict):
|
||||
error_msg = error_obj.get("message") or str(error_obj)
|
||||
else:
|
||||
error_msg = str(error_obj) if error_obj else f"Responses API returned status '{response_status}'"
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
content_parts: List[str] = []
|
||||
|
||||
@@ -16,6 +16,7 @@ compatibility.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
+28
-357
@@ -40,46 +40,16 @@ SUMMARY_PREFIX = (
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Respond ONLY to the latest user message that appears AFTER this "
|
||||
"summary — that message is the single source of truth for what to do "
|
||||
"right now. "
|
||||
"If the latest user message is consistent with the '## Active Task' "
|
||||
"section, you may use the summary as background. If the latest user "
|
||||
"message contradicts, supersedes, changes topic from, or in any way "
|
||||
"diverges from '## Active Task' / '## In Progress' / '## Pending User "
|
||||
"Asks' / '## Remaining Work', the latest message WINS — discard those "
|
||||
"stale items entirely and do not 'wrap up the old task first'. "
|
||||
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
|
||||
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
|
||||
"topic) must immediately end any in-flight work described in the "
|
||||
"summary; do not re-surface it in later turns. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"The current session state (files, config, etc.) may reflect work "
|
||||
"described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
# stale directive it carried (e.g. "resume exactly from Active Task") survives
|
||||
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
|
||||
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
|
||||
_HISTORICAL_SUMMARY_PREFIXES = (
|
||||
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"Respond ONLY to the latest user message "
|
||||
"that appears AFTER this summary. The current session state (files, "
|
||||
"config, etc.) may reflect work described here — avoid repeating it:",
|
||||
"config, etc.) may reflect work described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Minimum tokens for the summary output
|
||||
_MIN_SUMMARY_TOKENS = 2000
|
||||
@@ -105,44 +75,6 @@ _IMAGE_TOKEN_ESTIMATE = 1600
|
||||
_IMAGE_CHAR_EQUIVALENT = _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
|
||||
# Hard ceiling for the deterministic summary-failure handoff. The fallback is
|
||||
# only meant to preserve continuity anchors from the dropped window, not to
|
||||
# become another unbounded transcript copy after the LLM summarizer failed.
|
||||
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
|
||||
_FALLBACK_TURN_MAX_CHARS = 700
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
|
||||
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
|
||||
value = value.strip()
|
||||
if value and value not in items and len(items) < limit:
|
||||
items.append(value)
|
||||
|
||||
|
||||
def _extract_tool_call_name_and_args(tool_call: Any) -> tuple[str, str]:
|
||||
"""Return a best-effort ``(name, arguments)`` pair for dict/object tool calls."""
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function") or {}
|
||||
return str(fn.get("name") or "unknown"), str(fn.get("arguments") or "")
|
||||
|
||||
fn = getattr(tool_call, "function", None)
|
||||
if fn is None:
|
||||
return "unknown", ""
|
||||
return str(getattr(fn, "name", None) or "unknown"), str(getattr(fn, "arguments", None) or "")
|
||||
|
||||
|
||||
def _extract_tool_call_id(tool_call: Any) -> str:
|
||||
if isinstance(tool_call, dict):
|
||||
return str(tool_call.get("id") or "")
|
||||
return str(getattr(tool_call, "id", "") or "")
|
||||
|
||||
|
||||
def _collect_path_mentions(text: str, relevant_files: list[str], *, limit: int = 12) -> None:
|
||||
for match in _PATH_MENTION_RE.findall(text):
|
||||
_dedupe_append(relevant_files, match.rstrip(".,:;"), limit=limit)
|
||||
|
||||
|
||||
def _content_length_for_budget(raw_content: Any) -> int:
|
||||
"""Return the effective char-length of a message's content for token budgeting.
|
||||
@@ -548,10 +480,6 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def update_model(
|
||||
self,
|
||||
@@ -609,8 +537,8 @@ class ContextCompressor(ContextEngine):
|
||||
self.quiet_mode = quiet_mode
|
||||
# When True, summary-generation failure aborts compression entirely
|
||||
# (returns messages unchanged, sets _last_compress_aborted=True).
|
||||
# When False (default = historical behavior), insert a
|
||||
# deterministic "summary unavailable" handoff and drop the middle window.
|
||||
# When False (default = historical behavior), insert a static
|
||||
# "summary unavailable" placeholder and drop the middle window.
|
||||
self.abort_on_summary_failure = abort_on_summary_failure
|
||||
|
||||
self.context_length = get_model_context_length(
|
||||
@@ -649,10 +577,6 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
self.last_prompt_tokens = 0
|
||||
self.last_completion_tokens = 0
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
self.summary_model = summary_model_override or ""
|
||||
|
||||
@@ -686,44 +610,6 @@ class ContextCompressor(ContextEngine):
|
||||
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
|
||||
if self.last_prompt_tokens > 0:
|
||||
self.last_real_prompt_tokens = self.last_prompt_tokens
|
||||
if self.last_prompt_tokens < self.threshold_tokens:
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when a high rough preflight estimate is known-noisy.
|
||||
|
||||
``estimate_request_tokens_rough(..., tools=...)`` intentionally
|
||||
overestimates schema-heavy requests so Hermes compresses before a
|
||||
provider rejects the payload. After a successful compressed API call,
|
||||
though, provider ``prompt_tokens`` are a better signal than repeating
|
||||
compaction from the same rough schema overhead. Defer only while the
|
||||
rough estimate has grown modestly since a request the provider proved
|
||||
fit under the threshold.
|
||||
"""
|
||||
if rough_tokens < self.threshold_tokens:
|
||||
return False
|
||||
if self.last_real_prompt_tokens <= 0:
|
||||
return False
|
||||
if self.last_real_prompt_tokens >= self.threshold_tokens:
|
||||
return False
|
||||
|
||||
baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens
|
||||
if baseline <= 0:
|
||||
return False
|
||||
|
||||
growth = max(0, rough_tokens - baseline)
|
||||
tolerated_growth = max(4096, int(self.threshold_tokens * 0.05))
|
||||
if growth > tolerated_growth:
|
||||
return False
|
||||
|
||||
self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens)
|
||||
return True
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
"""Check if context exceeds the compression threshold.
|
||||
@@ -998,195 +884,6 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _build_static_fallback_summary(
|
||||
self,
|
||||
turns_to_summarize: List[Dict[str, Any]],
|
||||
reason: str | None = None,
|
||||
) -> str:
|
||||
"""Build a deterministic handoff when the LLM summarizer is unavailable.
|
||||
|
||||
This is intentionally much less rich than an LLM-written summary, but it
|
||||
is still better than a bare "N messages were removed" marker. It keeps
|
||||
the most useful continuity anchors that can be extracted locally:
|
||||
recent user asks, assistant/tool actions, files/commands mentioned in
|
||||
tool calls, and any error text. The result uses the normal summary
|
||||
structure so downstream prompts can recover gracefully after a provider
|
||||
outage or summary-model failure.
|
||||
"""
|
||||
user_asks: list[str] = []
|
||||
assistant_actions: list[str] = []
|
||||
tool_actions: list[str] = []
|
||||
relevant_files: list[str] = []
|
||||
blockers: list[str] = []
|
||||
last_dropped_turns: list[str] = []
|
||||
|
||||
def _compact_fallback_turn(value: Any) -> str:
|
||||
text = redact_sensitive_text(_content_text_for_contains(value))
|
||||
text = re.sub(r"\bgh[pousr]_[A-Za-z0-9_]{8,}\b", "[REDACTED]", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) > _FALLBACK_TURN_MAX_CHARS:
|
||||
text = text[: _FALLBACK_TURN_MAX_CHARS - 15].rstrip() + " ...[truncated]"
|
||||
return re.sub(r"\bgh[pousr]_[A-Za-z0-9_.-]+", "[REDACTED]", text)
|
||||
|
||||
def _remember_dropped_turn(label: str, text: str, *, limit: int = 8) -> None:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
last_dropped_turns.append(f"{label}: {text}")
|
||||
if len(last_dropped_turns) > limit:
|
||||
del last_dropped_turns[0]
|
||||
|
||||
def _collect_paths_from_jsonish(obj: Any) -> None:
|
||||
if isinstance(obj, dict):
|
||||
for key, val in obj.items():
|
||||
if key in {"path", "workdir", "file_path", "output_path"} and isinstance(val, str):
|
||||
_dedupe_append(relevant_files, val, limit=12)
|
||||
_collect_paths_from_jsonish(val)
|
||||
elif isinstance(obj, list):
|
||||
for val in obj:
|
||||
_collect_paths_from_jsonish(val)
|
||||
elif isinstance(obj, str):
|
||||
_collect_path_mentions(obj, relevant_files)
|
||||
|
||||
call_id_to_tool: dict[str, tuple[str, str]] = {}
|
||||
for msg in turns_to_summarize:
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, raw_args = _extract_tool_call_name_and_args(tc)
|
||||
args = redact_sensitive_text(raw_args)
|
||||
call_id = _extract_tool_call_id(tc)
|
||||
if call_id:
|
||||
call_id_to_tool[call_id] = (name, args)
|
||||
if args:
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
except Exception:
|
||||
parsed = args
|
||||
_collect_paths_from_jsonish(parsed)
|
||||
|
||||
for msg in turns_to_summarize:
|
||||
role = msg.get("role", "unknown")
|
||||
text = _compact_fallback_turn(msg.get("content"))
|
||||
_collect_path_mentions(text, relevant_files)
|
||||
|
||||
turn_text = text
|
||||
turn_tool_names: list[str] = []
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, _args = _extract_tool_call_name_and_args(tc)
|
||||
turn_tool_names.append(name)
|
||||
if turn_tool_names:
|
||||
prefix = "tool calls: " + ", ".join(turn_tool_names[:6])
|
||||
turn_text = f"{prefix}; {turn_text}" if turn_text else prefix
|
||||
_remember_dropped_turn(str(role).upper(), turn_text)
|
||||
|
||||
if len(text) > 600:
|
||||
text = text[:420].rstrip() + " ... " + text[-160:].lstrip()
|
||||
|
||||
if role == "user" and text:
|
||||
user_asks.append(text)
|
||||
elif role == "assistant":
|
||||
tool_names: list[str] = []
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, _args = _extract_tool_call_name_and_args(tc)
|
||||
tool_names.append(name)
|
||||
if tool_names:
|
||||
assistant_actions.append(
|
||||
"Called tool(s): " + ", ".join(tool_names[:6])
|
||||
)
|
||||
elif text:
|
||||
assistant_actions.append(text)
|
||||
elif role == "tool":
|
||||
call_id = str(msg.get("tool_call_id") or "")
|
||||
tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", ""))
|
||||
tool_actions.append(
|
||||
_summarize_tool_result(tool_name, tool_args, text or "")
|
||||
)
|
||||
if re.search(
|
||||
r"\b(error|failed|exception|traceback|timeout|timed out|fatal)\b",
|
||||
text,
|
||||
re.I,
|
||||
):
|
||||
blockers.append(text[:500])
|
||||
|
||||
def _bullets(items: list[str], limit: int = 8) -> str:
|
||||
unique: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
unique.append(item)
|
||||
if len(unique) >= limit:
|
||||
break
|
||||
return "\n".join(f"- {item}" for item in unique) if unique else "None."
|
||||
|
||||
completed: list[str] = []
|
||||
for idx, item in enumerate((assistant_actions + tool_actions)[:12], start=1):
|
||||
completed.append(f"{idx}. {item}")
|
||||
|
||||
active_task = (
|
||||
f"User asked: {user_asks[-1]!r}"
|
||||
if user_asks
|
||||
else "Unknown from deterministic fallback."
|
||||
)
|
||||
previous_summary_note = ""
|
||||
if self._previous_summary:
|
||||
previous_summary_note = (
|
||||
"\n\nPrevious compaction summary was present and should still be treated as "
|
||||
"background continuity context, but the latest LLM summary update failed."
|
||||
)
|
||||
|
||||
reason_text = f" Summary failure reason: {reason}." if reason else ""
|
||||
body = f"""## Active Task
|
||||
{active_task}
|
||||
|
||||
## Goal
|
||||
Recovered from a deterministic fallback because the LLM context summarizer was unavailable. Continue from the protected recent messages after this summary and use current file/system state for exact details.{previous_summary_note}
|
||||
|
||||
## Constraints & Preferences
|
||||
- This fallback was generated locally without an LLM summary call.
|
||||
- Secrets and credentials were redacted before preservation.
|
||||
- The summary may be incomplete; prefer verifying current files, git state, processes, and test results instead of assuming omitted details.
|
||||
|
||||
## Completed Actions
|
||||
{chr(10).join(completed) if completed else "None recoverable from compacted turns."}
|
||||
|
||||
## Active State
|
||||
Unknown from deterministic fallback. Inspect current repository/session state if needed.
|
||||
|
||||
## In Progress
|
||||
{active_task}
|
||||
|
||||
## Blocked
|
||||
{_bullets(blockers, limit=5)}
|
||||
|
||||
## Key Decisions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Resolved Questions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Pending User Asks
|
||||
{active_task}
|
||||
|
||||
## Relevant Files
|
||||
{_bullets(relevant_files, limit=12)}
|
||||
|
||||
## Remaining Work
|
||||
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
|
||||
|
||||
## Last Dropped Turns
|
||||
{_bullets(last_dropped_turns, limit=8)}
|
||||
|
||||
## Critical Context
|
||||
Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}"""
|
||||
summary = self._with_summary_prefix(redact_sensitive_text(body.strip()))
|
||||
if len(summary) > _FALLBACK_SUMMARY_MAX_CHARS:
|
||||
summary = summary[: _FALLBACK_SUMMARY_MAX_CHARS - 42].rstrip() + "\n...[fallback summary truncated]"
|
||||
return summary
|
||||
|
||||
def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None:
|
||||
"""Switch from a separate ``summary_model`` back to the main model.
|
||||
|
||||
@@ -1214,11 +911,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately
|
||||
|
||||
def _generate_summary(
|
||||
self,
|
||||
turns_to_summarize: List[Dict[str, Any]],
|
||||
focus_topic: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:
|
||||
"""Generate a structured summary of conversation turns.
|
||||
|
||||
Uses a structured template (Goal, Progress, Decisions, Resolved/Pending
|
||||
@@ -1266,27 +959,11 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""## Active Task
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
input verbatim — the exact words they used. This includes:
|
||||
- Explicit task assignments ("refactor the auth module")
|
||||
- Questions awaiting an answer ("waarom staat X op Y?", "wat zijn de volgende stappen?")
|
||||
- Decisions awaiting input ("optie A of B?")
|
||||
- Ongoing discussions where the assistant owes the next substantive reply
|
||||
A conversation where the user just asked a question IS an active task — the
|
||||
task is "answer that question with full context". Do NOT write "None" merely
|
||||
because the user did not issue an imperative command; reserve "None" for the
|
||||
rare case where the last exchange was fully resolved and the user said
|
||||
something like "thanks, that's all".
|
||||
If multiple items are outstanding, list only the ones NOT yet completed.
|
||||
Continuation should pick up exactly here. Examples:
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or
|
||||
task assignment verbatim — the exact words they used. If multiple tasks
|
||||
were requested and only some are done, list only the ones NOT yet completed.
|
||||
Continuation should pick up exactly here. Example:
|
||||
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
|
||||
"User asked: 'Waarom stond provider ineens op openrouter?' — needs investigation + answer"
|
||||
"User chose option A; awaiting implementation of step 2"
|
||||
If the user's most recent message was a reverse signal (stop, undo, roll
|
||||
back, never mind, just verify, change of topic) that supersedes earlier
|
||||
work, write the reverse signal verbatim and DO NOT carry forward the
|
||||
cancelled task. Example: "User asked: 'Stop the i18n refactor and just
|
||||
verify the current diff' — earlier i18n in-flight work is cancelled."
|
||||
If no outstanding task exists, write "None."]
|
||||
|
||||
## Goal
|
||||
@@ -1352,7 +1029,7 @@ PREVIOUS SUMMARY:
|
||||
NEW TURNS TO INCORPORATE:
|
||||
{content_to_summarize}
|
||||
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled input — this includes any question, decision request, or discussion turn that the assistant has not yet answered. Only write "None" if the last exchange was fully resolved.
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled request — this is the most important field for task continuity.
|
||||
|
||||
{_template_sections}"""
|
||||
else:
|
||||
@@ -1516,16 +1193,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
|
||||
@staticmethod
|
||||
def _strip_summary_prefix(summary: str) -> str:
|
||||
"""Return summary body without the current, legacy, or any historical
|
||||
handoff prefix.
|
||||
|
||||
Historical prefixes must be stripped too: a handoff persisted under an
|
||||
older prefix can be inherited into a resumed lineage (#35344), and if we
|
||||
only re-prepend the current prefix without removing the old one, the
|
||||
stale directive it carried stays embedded in the body.
|
||||
"""
|
||||
"""Return summary body without the current or legacy handoff prefix."""
|
||||
text = (summary or "").strip()
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
|
||||
if text.startswith(prefix):
|
||||
return text[len(prefix):].lstrip()
|
||||
return text
|
||||
@@ -1539,9 +1209,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
@staticmethod
|
||||
def _is_context_summary_content(content: Any) -> bool:
|
||||
text = _content_text_for_contains(content).lstrip()
|
||||
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
@@ -1940,9 +1608,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
# True → ABORT compression entirely. Return messages unchanged
|
||||
# and set _last_compress_aborted=True so callers can warn
|
||||
# the user and stop the auto-compress retry loop.
|
||||
# False → Fall through to the default fallback path below: insert
|
||||
# a deterministic "summary unavailable" handoff and drop
|
||||
# the middle window. Records _last_summary_fallback_used /
|
||||
# False → Fall through to the legacy fallback path below: insert
|
||||
# a static "summary unavailable" placeholder and drop the
|
||||
# middle window. Records _last_summary_fallback_used /
|
||||
# _last_summary_dropped_count for gateway hygiene to
|
||||
# surface a warning.
|
||||
# Default is False (historical behavior).
|
||||
@@ -1975,18 +1643,21 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
compressed.append(msg)
|
||||
|
||||
# If LLM summary failed, insert a deterministic fallback so the model
|
||||
# gets at least locally recoverable continuity anchors instead of a
|
||||
# content-free "N messages were removed" marker.
|
||||
# Legacy fallback path: LLM summary failed and abort_on_summary_failure
|
||||
# is False (the default). Insert a static placeholder so the model
|
||||
# knows context was lost rather than silently dropping everything.
|
||||
if not summary:
|
||||
if not self.quiet_mode:
|
||||
logger.warning("Summary generation failed — inserting deterministic fallback context summary")
|
||||
logger.warning("Summary generation failed — inserting static fallback context marker")
|
||||
n_dropped = compress_end - compress_start
|
||||
self._last_summary_dropped_count = n_dropped
|
||||
self._last_summary_fallback_used = True
|
||||
summary = self._build_static_fallback_summary(
|
||||
turns_to_summarize,
|
||||
reason=self._last_summary_error,
|
||||
summary = (
|
||||
f"{SUMMARY_PREFIX}\n"
|
||||
f"Summary generation was unavailable. {n_dropped} message(s) were "
|
||||
f"removed to free context space but could not be summarized. The removed "
|
||||
f"messages contained earlier work in this session. Continue based on the "
|
||||
f"recent messages below and the current state of any files or resources."
|
||||
)
|
||||
|
||||
_merge_summary_into_tail = False
|
||||
|
||||
+1
-15
@@ -71,12 +71,7 @@ class ContextEngine(ABC):
|
||||
def update_from_response(self, usage: Dict[str, Any]) -> None:
|
||||
"""Update tracked token usage from an API response.
|
||||
|
||||
Called after every LLM call with a normalized usage dict. The legacy
|
||||
keys ``prompt_tokens``, ``completion_tokens``, and ``total_tokens``
|
||||
are always present. Newer hosts also include canonical buckets:
|
||||
``input_tokens``, ``output_tokens``, ``cache_read_tokens``,
|
||||
``cache_write_tokens``, and ``reasoning_tokens``. Engines should
|
||||
treat those fields as optional for compatibility with older hosts.
|
||||
Called after every LLM call with the usage dict from the response.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -115,15 +110,6 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when preflight should trust recent real usage instead.
|
||||
|
||||
Built-in compression uses this to avoid re-compacting from known-noisy
|
||||
rough estimates after a compressed request has already fit. Third-party
|
||||
engines can ignore it safely.
|
||||
"""
|
||||
return False
|
||||
|
||||
# -- Optional: manual /compress preflight ------------------------------
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
|
||||
@@ -34,33 +34,13 @@ import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
The pid+tid prefix lets ops tell crashed/abandoned holders apart from
|
||||
live ones (expiry-based recovery uses the timestamp, but ``holder``
|
||||
is what shows up in diagnostics + log lines). The agent instance id
|
||||
and a per-acquire uuid disambiguate two co-resident agents on the
|
||||
same thread (background_review forks run on a worker thread, but
|
||||
on machines where compression itself dispatches to a thread pool
|
||||
we want each acquire to be unique).
|
||||
"""
|
||||
import threading
|
||||
return (
|
||||
f"pid={os.getpid()}"
|
||||
f":tid={threading.get_ident()}"
|
||||
f":agent={id(agent):x}"
|
||||
f":nonce={uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
|
||||
|
||||
def check_compression_model_feasibility(agent: Any) -> None:
|
||||
"""Warn at session start if the auxiliary compression model's context
|
||||
window is smaller than the main model's compression threshold.
|
||||
@@ -308,14 +288,11 @@ def compress_context(
|
||||
# The check itself sets ``agent._compression_warning`` so the
|
||||
# status-callback replay machinery still emits the warning to the user
|
||||
# the first time it would matter.
|
||||
if not getattr(agent, "_compression_feasibility_checked", False):
|
||||
# Mark as checked only after the probe completes. If the check
|
||||
# raises (e.g. a fatal aux-context ValueError that aborts the
|
||||
# session), leaving the flag unset is harmless; a non-fatal
|
||||
# transient failure is swallowed inside the function so the flag
|
||||
# is set normally on the next successful pass.
|
||||
check_compression_model_feasibility(agent)
|
||||
agent._compression_feasibility_checked = True
|
||||
if not getattr(agent, "_compression_feasibility_checked", True):
|
||||
try:
|
||||
check_compression_model_feasibility(agent)
|
||||
finally:
|
||||
agent._compression_feasibility_checked = True
|
||||
|
||||
_pre_msg_count = len(messages)
|
||||
logger.info(
|
||||
@@ -328,103 +305,6 @@ def compress_context(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
# AIAgent instances that share the same session_id (most commonly the
|
||||
# parent-turn agent and its background-review fork — see
|
||||
# ``agent/background_review.py``: ``review_agent.session_id =
|
||||
# agent.session_id``) can each call compress() on overlapping
|
||||
# snapshots of the same conversation. Both succeed, both rotate
|
||||
# ``agent.session_id`` to a fresh id, both create child sessions in
|
||||
# state.db parented to the same old id. The gateway's SessionEntry
|
||||
# only catches one rotation, so the other child becomes an orphan
|
||||
# that silently accumulates writes — Damien's repro shape.
|
||||
#
|
||||
# Acquire keyed on the OLD session_id (the rotation target's parent),
|
||||
# because that's the id that competing paths see and read from
|
||||
# SessionEntry at the start of their own compression attempt.
|
||||
#
|
||||
# If we can't acquire the lock, another path is mid-compression on
|
||||
# this session. Aborting is correct: the messages are unchanged, the
|
||||
# other path's rotation will produce the canonical new session_id,
|
||||
# and our caller's auto-compress loop sees ``len(returned) == len(input)``
|
||||
# and stops retrying for this cycle. The session is NOT corrupted —
|
||||
# we just sit out this round and let the winner finish.
|
||||
_lock_db = getattr(agent, "_session_db", None)
|
||||
_lock_sid = agent.session_id or ""
|
||||
_lock_holder: Optional[str] = None
|
||||
# Probe whether the lock subsystem is actually available on this
|
||||
# SessionDB instance. A process running mismatched module versions
|
||||
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
|
||||
# long-lived ``hermes_state.SessionDB`` class still bound to the
|
||||
# pre-#34351 version in memory) has the call site but not the method.
|
||||
# In that case ``try_acquire_compression_lock`` raises AttributeError —
|
||||
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
|
||||
# runs and the exception propagates to the outer agent loop, which
|
||||
# prints the error and retries. Because compression never succeeds,
|
||||
# the token count never drops and the loop re-triggers compaction
|
||||
# forever (the "API call #47/#48/#49 ... has no attribute
|
||||
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
|
||||
# subsystem is missing or broken in any unexpected way, skip locking
|
||||
# and proceed with compression. Skipping the lock risks a rare
|
||||
# concurrent-compression session fork; an infinite no-progress loop
|
||||
# that never compresses at all is strictly worse.
|
||||
if _lock_db is not None and _lock_sid:
|
||||
_lock_holder = _compression_lock_holder(agent)
|
||||
try:
|
||||
_lock_acquired = _lock_db.try_acquire_compression_lock(
|
||||
_lock_sid, _lock_holder
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# Broken/absent lock subsystem (version skew, etc.). Log once
|
||||
# per session and proceed WITHOUT the lock rather than letting
|
||||
# the exception spin the outer loop.
|
||||
_lock_holder = None # we don't own anything to release
|
||||
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_error_sid = _lock_sid
|
||||
logger.warning(
|
||||
"compression lock subsystem unavailable for session=%s "
|
||||
"(%s: %s) — proceeding without lock. This usually means a "
|
||||
"stale in-memory module after an update; restart the "
|
||||
"process (or `hermes update`) to resync.",
|
||||
_lock_sid, type(_lock_err).__name__, _lock_err,
|
||||
)
|
||||
_lock_acquired = True # treat as acquired-but-unlocked; proceed
|
||||
if not _lock_acquired:
|
||||
try:
|
||||
existing = _lock_db.get_compression_lock_holder(_lock_sid)
|
||||
except Exception:
|
||||
existing = None
|
||||
logger.warning(
|
||||
"compression skipped: another path is compressing session=%s "
|
||||
"(holder=%s) — returning messages unchanged to avoid session fork",
|
||||
_lock_sid, existing,
|
||||
)
|
||||
_lock_holder = None # don't release a lock we don't own
|
||||
# Surface to the user once — quiet for downstream auto-compress loops
|
||||
if getattr(agent, "_last_compression_lock_warning_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_warning_sid = _lock_sid
|
||||
try:
|
||||
agent._emit_warning(
|
||||
"⚠ Skipping concurrent compression — another path "
|
||||
"is already compressing this session. Will retry "
|
||||
"after it finishes."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
return messages, _existing_sp
|
||||
|
||||
def _release_lock() -> None:
|
||||
"""Release the lock keyed on the OLD session_id (before rotation)."""
|
||||
if _lock_db is not None and _lock_sid and _lock_holder:
|
||||
try:
|
||||
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
|
||||
except Exception as _rel_err:
|
||||
logger.debug("compression lock release failed: %s", _rel_err)
|
||||
|
||||
# Notify external memory provider before compression discards context
|
||||
if agent._memory_manager:
|
||||
try:
|
||||
@@ -438,11 +318,6 @@ def compress_context(
|
||||
# Plugin context engine with strict signature that doesn't accept
|
||||
# focus_topic / force — fall back to calling without them.
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
|
||||
except BaseException:
|
||||
# ANY exception during compress() must release the lock so the
|
||||
# session isn't permanently blocked from future compression.
|
||||
_release_lock()
|
||||
raise
|
||||
|
||||
# If compression aborted (aux LLM failed to produce a usable summary)
|
||||
# the compressor returns the input messages unchanged. Surface the
|
||||
@@ -461,7 +336,6 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_release_lock() # compression aborted — no rotation will happen
|
||||
return messages, _existing_sp
|
||||
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
@@ -547,7 +421,6 @@ def compress_context(
|
||||
agent.session_id or "",
|
||||
boundary_reason="compression",
|
||||
old_session_id=_old_sid,
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
logger.debug("context engine on_session_start (compression): %s", _ce_err)
|
||||
@@ -578,18 +451,19 @@ def compress_context(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
# Update token estimate after compaction so pressure calculations
|
||||
# use the post-compression count, not the stale pre-compression one.
|
||||
# Use estimate_request_tokens_rough() so tool schemas are included —
|
||||
# with 50+ tools enabled, schemas alone can add 20-30K tokens, and
|
||||
# omitting them delays the next compression cycle far past the
|
||||
# configured threshold (issue #14695).
|
||||
_compressed_est = estimate_request_tokens_rough(
|
||||
compressed,
|
||||
system_prompt=new_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
agent.context_compressor.last_compression_rough_tokens = _compressed_est
|
||||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_prompt_tokens = _compressed_est
|
||||
agent.context_compressor.last_completion_tokens = 0
|
||||
agent.context_compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
@@ -601,16 +475,10 @@ def compress_context(
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
|
||||
"context compression done: session=%s messages=%d->%d tokens=~%s",
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
# Release the lock on the OLD session_id only AFTER rotation completed
|
||||
# and all post-rotation bookkeeping (memory manager, context engine,
|
||||
# file dedup) ran. A concurrent path that wakes up the moment we
|
||||
# release will see the NEW session_id in state.db / SessionEntry and
|
||||
# acquire on that — no race against our just-finished work.
|
||||
_release_lock()
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
@@ -647,12 +515,6 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
# be re-sent and rejected again, wasting the single retry budget. We only
|
||||
# report success (caller retries) when every over-threshold image was
|
||||
# actually brought under the target.
|
||||
unshrinkable_oversized = 0
|
||||
|
||||
def _shrink_data_url(url: str) -> Optional[str]:
|
||||
"""Return a smaller data URL, or None if shrink can't help."""
|
||||
@@ -719,34 +581,17 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
if resized:
|
||||
image_value["url"] = resized
|
||||
changed_count += 1
|
||||
elif isinstance(url, str) and url.startswith("data:") \
|
||||
and len(url) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
elif isinstance(image_value, str):
|
||||
resized = _shrink_data_url(image_value)
|
||||
if resized:
|
||||
part["image_url"] = resized
|
||||
changed_count += 1
|
||||
elif image_value.startswith("data:") \
|
||||
and len(image_value) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
|
||||
if changed_count:
|
||||
logger.info(
|
||||
"image-shrink recovery: re-encoded %d image part(s) to fit under %.0f MB",
|
||||
changed_count, target_bytes / (1024 * 1024),
|
||||
)
|
||||
if unshrinkable_oversized:
|
||||
# At least one oversized image could not be shrunk under the target.
|
||||
# Retrying would re-send it and fail identically, so signal "no
|
||||
# progress" even if other parts shrank — the caller will surface the
|
||||
# original error rather than burning its single retry on a no-op.
|
||||
logger.warning(
|
||||
"image-shrink recovery: %d oversized image part(s) could not be "
|
||||
"shrunk under %.0f MB — not retrying (would re-send rejected payload)",
|
||||
unshrinkable_oversized, target_bytes / (1024 * 1024),
|
||||
)
|
||||
return False
|
||||
return changed_count > 0
|
||||
|
||||
|
||||
|
||||
+161
-562
File diff suppressed because it is too large
Load Diff
+23
-143
@@ -14,7 +14,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.config import load_env
|
||||
from hermes_cli.config import get_env_value, load_env
|
||||
from agent.credential_persistence import (
|
||||
is_borrowed_credential_source,
|
||||
sanitize_borrowed_credential_payload,
|
||||
@@ -22,6 +22,7 @@ from agent.credential_persistence import (
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
PROVIDER_REGISTRY,
|
||||
_auth_store_lock,
|
||||
_codex_access_token_is_expiring,
|
||||
@@ -54,38 +55,6 @@ def _load_config_safe() -> Optional[dict]:
|
||||
|
||||
STATUS_OK = "ok"
|
||||
STATUS_EXHAUSTED = "exhausted"
|
||||
# Terminal failure — the credential will never recover on its own. Used for
|
||||
# upstream-permanent OAuth states like ``token_invalidated`` / ``token_revoked``
|
||||
# where retrying after a TTL cooldown is guaranteed to fail. ``DEAD`` entries
|
||||
# are excluded from rotation unconditionally and only clear when an explicit
|
||||
# write-side sync (e.g. ``_save_codex_tokens`` after a fresh device-code
|
||||
# login) rewrites the tokens.
|
||||
STATUS_DEAD = "dead"
|
||||
|
||||
# OAuth error reasons that indicate the credential is permanently invalid
|
||||
# server-side and cannot be recovered by retry/refresh. Sourced from
|
||||
# OpenAI Codex Responses API, Anthropic, xAI, and Google OAuth spec.
|
||||
_TERMINAL_AUTH_REASONS = frozenset({
|
||||
"token_invalidated", # OpenAI Codex: "Your authentication token has been invalidated."
|
||||
"token_revoked", # OAuth 2.0 RFC 7009: token explicitly revoked
|
||||
"invalid_token", # RFC 6750: bearer token is malformed/expired/revoked
|
||||
"invalid_grant", # RFC 6749: refresh_token rejected during refresh
|
||||
"unauthorized_client", # RFC 6749: client no longer authorized
|
||||
"refresh_token_reused", # Single-use refresh token consumed by another process
|
||||
})
|
||||
|
||||
# How long a DEAD manual credential is preserved before being pruned.
|
||||
# Manual entries (``manual:*``) are independent credentials with no singleton
|
||||
# to re-seed from, so pruning them after a quiet window cleans up dead state
|
||||
# without losing recoverability — the user always has the option to re-add
|
||||
# via ``hermes auth add``.
|
||||
#
|
||||
# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)
|
||||
# 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
|
||||
# write-side sync (``_save_codex_tokens`` etc.) clears the status.
|
||||
DEAD_MANUAL_PRUNE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
||||
|
||||
AUTH_TYPE_OAUTH = "oauth"
|
||||
AUTH_TYPE_API_KEY = "api_key"
|
||||
@@ -202,22 +171,8 @@ class PooledCredential:
|
||||
def runtime_api_key(self) -> str:
|
||||
if self.provider == "nous":
|
||||
# Nous stores the runtime inference credential in agent_key for
|
||||
# compatibility. It must be a NAS invoke JWT.
|
||||
for token, expires_at in (
|
||||
(self.agent_key, self.agent_key_expires_at),
|
||||
(self.access_token, self.expires_at),
|
||||
):
|
||||
if (
|
||||
isinstance(token, str)
|
||||
and token.strip()
|
||||
and auth_mod._nous_invoke_jwt_is_usable(
|
||||
token,
|
||||
scope=getattr(self, "scope", None),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
):
|
||||
return token.strip()
|
||||
return ""
|
||||
# compatibility. It may be a NAS invoke JWT or legacy opaque key.
|
||||
return str(self.agent_key or self.access_token or "")
|
||||
return str(self.access_token or "")
|
||||
|
||||
@property
|
||||
@@ -483,29 +438,6 @@ class CredentialPool:
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
)
|
||||
|
||||
def _is_terminal_auth_failure(
|
||||
self,
|
||||
status_code: Optional[int],
|
||||
normalized_error: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Detect upstream-permanent OAuth failures that won't recover on TTL.
|
||||
|
||||
Only fires for 401 responses whose error code/reason matches a known
|
||||
terminal OAuth state (token_invalidated, token_revoked, invalid_grant,
|
||||
etc.). Distinguishes permanent failures from transient ones like
|
||||
token_expired (refreshable) or generic 401 without a specific reason
|
||||
(could be a server-side glitch worth retrying).
|
||||
|
||||
Returns False for non-401 status codes — 429 rate limits and 402
|
||||
billing failures are transient by nature and should keep TTL semantics.
|
||||
"""
|
||||
if status_code != 401:
|
||||
return False
|
||||
reason = normalized_error.get("reason")
|
||||
if not isinstance(reason, str):
|
||||
return False
|
||||
return reason.strip().lower() in _TERMINAL_AUTH_REASONS
|
||||
|
||||
def _mark_exhausted(
|
||||
self,
|
||||
entry: PooledCredential,
|
||||
@@ -513,20 +445,9 @@ class CredentialPool:
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
) -> PooledCredential:
|
||||
normalized_error = _normalize_error_context(error_context)
|
||||
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
|
||||
# transition to STATUS_DEAD instead of STATUS_EXHAUSTED. Without this,
|
||||
# a revoked credential gets a 1-hour TTL cooldown and then re-enters
|
||||
# rotation, failing immediately every hour until the user manually
|
||||
# removes it (issue #32849). DEAD entries are excluded from rotation
|
||||
# unconditionally and only clear via an explicit re-auth write-side
|
||||
# sync (``_save_codex_tokens`` after a fresh device-code login).
|
||||
if self._is_terminal_auth_failure(status_code, normalized_error):
|
||||
terminal_status = STATUS_DEAD
|
||||
else:
|
||||
terminal_status = STATUS_EXHAUSTED
|
||||
updated = replace(
|
||||
entry,
|
||||
last_status=terminal_status,
|
||||
last_status=STATUS_EXHAUSTED,
|
||||
last_status_at=time.time(),
|
||||
last_error_code=status_code,
|
||||
last_error_reason=normalized_error.get("reason"),
|
||||
@@ -931,7 +852,12 @@ class CredentialPool:
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
auth_mod.resolve_nous_runtime_credentials(
|
||||
force_refresh=force,
|
||||
min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
inference_auth_mode=(
|
||||
auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
if force
|
||||
else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO
|
||||
),
|
||||
)
|
||||
updated = self._sync_nous_entry_from_auth_store(entry)
|
||||
else:
|
||||
@@ -1213,7 +1139,7 @@ class CredentialPool:
|
||||
auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
)
|
||||
if self.provider == "nous":
|
||||
# Nous refresh can require network access and should happen when
|
||||
# Nous refresh/mint can require network access and should happen when
|
||||
# runtime credentials are actually resolved, not merely when the pool
|
||||
# is enumerated for listing, migration, or selection.
|
||||
return False
|
||||
@@ -1232,14 +1158,13 @@ class CredentialPool:
|
||||
"""
|
||||
now = time.time()
|
||||
cleared_any = False
|
||||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
for entry in self._entries:
|
||||
# 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).
|
||||
if (self.provider == "anthropic" and entry.source == "claude_code"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_anthropic_entry_from_credentials_file(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1250,7 +1175,7 @@ class CredentialPool:
|
||||
# exhausted status stale.
|
||||
if (self.provider == "nous"
|
||||
and entry.source == "device_code"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_nous_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1262,7 +1187,7 @@ class CredentialPool:
|
||||
# future for ChatGPT weekly windows).
|
||||
if (self.provider == "openai-codex"
|
||||
and entry.source == "device_code"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_codex_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1273,41 +1198,11 @@ class CredentialPool:
|
||||
# xAI Grok OAuth login) has since rotated in auth.json.
|
||||
if (self.provider == "xai-oauth"
|
||||
and entry.source == "loopback_pkce"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
cleared_any = True
|
||||
if entry.last_status == STATUS_DEAD:
|
||||
# Manual DEAD credentials get pruned after a 24h quiet window
|
||||
# so the pool doesn't accumulate dead entries forever. The
|
||||
# user can always re-add via ``hermes auth add``. Singleton-
|
||||
# seeded DEAD entries are kept so the audit trail (label,
|
||||
# last_error_reason, timestamps) stays visible — pruning them
|
||||
# would just be undone by ``_seed_from_singletons`` on the
|
||||
# next load anyway.
|
||||
if _is_manual_source(entry.source):
|
||||
dead_at = entry.last_status_at or 0
|
||||
if dead_at and now - dead_at > DEAD_MANUAL_PRUNE_TTL_SECONDS:
|
||||
_label = entry.label or entry.id[:8]
|
||||
logger.warning(
|
||||
"credential pool: pruning DEAD manual entry %s "
|
||||
"(reason=%s, age=%.1fh) — re-add via `hermes auth add %s`",
|
||||
_label,
|
||||
entry.last_error_reason or "unknown",
|
||||
(now - dead_at) / 3600.0,
|
||||
self.provider,
|
||||
)
|
||||
# Mark for removal after the loop completes; we can't
|
||||
# mutate self._entries while iterating.
|
||||
entries_to_prune.append(entry.id)
|
||||
cleared_any = True
|
||||
# Permanently failed credentials never re-enter rotation via
|
||||
# TTL. They only clear when a write-side re-auth sync rewrites
|
||||
# the tokens (e.g. ``_save_codex_tokens`` after a fresh
|
||||
# device-code login). The auth.json-sync paths below handle
|
||||
# the re-auth case for OAuth singletons.
|
||||
continue
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
@@ -1331,9 +1226,6 @@ class CredentialPool:
|
||||
continue
|
||||
entry = refreshed
|
||||
available.append(entry)
|
||||
if entries_to_prune:
|
||||
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()
|
||||
return available
|
||||
@@ -1401,22 +1293,11 @@ class CredentialPool:
|
||||
if entry is None:
|
||||
return None
|
||||
_label = entry.label or entry.id[:8]
|
||||
self._mark_exhausted(entry, status_code, error_context)
|
||||
# Re-read the updated entry to log the correct terminal state.
|
||||
updated_entry = next(
|
||||
(e for e in self._entries if e.id == entry.id), entry,
|
||||
logger.info(
|
||||
"credential pool: marking %s exhausted (status=%s), rotating",
|
||||
_label, status_code,
|
||||
)
|
||||
if updated_entry.last_status == STATUS_DEAD:
|
||||
logger.warning(
|
||||
"credential pool: marking %s DEAD (status=%s, reason=%s) — "
|
||||
"permanently failed, will NOT re-enter rotation until re-auth",
|
||||
_label, status_code, updated_entry.last_error_reason or "unknown",
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"credential pool: marking %s exhausted (status=%s), rotating",
|
||||
_label, status_code,
|
||||
)
|
||||
self._mark_exhausted(entry, status_code, error_context)
|
||||
self._current_id = None
|
||||
next_entry = self._select_unlocked()
|
||||
if next_entry:
|
||||
@@ -1756,9 +1637,9 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"inference_base_url": state.get("inference_base_url"),
|
||||
"agent_key": state.get("agent_key"),
|
||||
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
||||
# Carry the refresh timestamps into the pool so
|
||||
# Carry the mint/refresh timestamps into the pool so
|
||||
# freshness-sensitive consumers (self-heal hooks, pool
|
||||
# pruning by age) can distinguish just-refreshed credentials
|
||||
# pruning by age) can distinguish just-minted credentials
|
||||
# from stale ones. Without these, fresh device_code
|
||||
# entries get obtained_at=None and look older than they
|
||||
# are (#15099).
|
||||
@@ -1891,7 +1772,6 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
# via `hermes auth openai-codex`.
|
||||
if isinstance(tokens, dict) and tokens.get("access_token"):
|
||||
active_sources.add("device_code")
|
||||
custom_label = str(state.get("label") or "").strip()
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
@@ -1903,7 +1783,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"refresh_token": tokens.get("refresh_token"),
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": state.get("last_refresh"),
|
||||
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
"label": label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+6
-68
@@ -183,18 +183,6 @@ def get_archive_after_days() -> int:
|
||||
return DEFAULT_ARCHIVE_AFTER_DAYS
|
||||
|
||||
|
||||
def get_prune_builtins() -> bool:
|
||||
"""Whether the curator may prune (archive) bundled built-in skills too.
|
||||
|
||||
ON by default. When on, built-ins become curation candidates and are
|
||||
archived after the same inactivity period as agent-created skills, with a
|
||||
suppression list keeping them archived across `hermes update` re-seeds.
|
||||
Hub-installed skills are never pruned regardless of this flag.
|
||||
"""
|
||||
cfg = _load_config()
|
||||
return bool(cfg.get("prune_builtins", True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idle / interval check
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -266,17 +254,9 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
"""Walk every curator-managed skill and move active/stale/archived based on
|
||||
"""Walk every agent-created skill and move active/stale/archived based on
|
||||
the latest real activity timestamp. Pinned skills are never touched.
|
||||
|
||||
Built-ins (eligible only when ``curator.prune_builtins`` is on) are seeded
|
||||
with a baseline record the first time they're seen so their inactivity
|
||||
clock starts NOW rather than at epoch — a long-unused built-in is therefore
|
||||
archived only after a fresh ``archive_after_days`` of non-use, not on the
|
||||
first pass after the flag flips on.
|
||||
|
||||
Returns a counter dict describing what changed.
|
||||
"""
|
||||
Returns a counter dict describing what changed."""
|
||||
from tools import skill_usage as _u
|
||||
|
||||
if now is None:
|
||||
@@ -284,7 +264,7 @@ 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())
|
||||
|
||||
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0}
|
||||
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0}
|
||||
|
||||
for row in _u.agent_created_report():
|
||||
counts["checked"] += 1
|
||||
@@ -292,13 +272,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
||||
if row.get("pinned"):
|
||||
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):
|
||||
_u.seed_record_if_missing(name)
|
||||
counts["seeded"] += 1
|
||||
continue
|
||||
|
||||
last_activity = _parse_iso(row.get("last_activity_at"))
|
||||
# If never active, treat created_at as the anchor so new skills don't
|
||||
# immediately archive themselves.
|
||||
@@ -417,26 +390,7 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"(verification scripts, fixture generators, probes)\n"
|
||||
" Then archive the old sibling. Use `terminal` with `mkdir -p "
|
||||
"~/.hermes/skills/<umbrella>/references/ && mv ... <umbrella>/"
|
||||
"references/<topic>.md` (or templates/ / scripts/).\n\n"
|
||||
"Package integrity — not optional:\n"
|
||||
"Before demoting or archiving a skill, inspect it as a COMPLETE "
|
||||
"directory package, not just SKILL.md. A skill root may include "
|
||||
"`references/`, `templates/`, `scripts/`, and `assets/`; `skill_view` "
|
||||
"discovers those relative to the skill root. A reference markdown file "
|
||||
"inside another skill is NOT a new skill root and does not get its own "
|
||||
"linked-file discovery.\n"
|
||||
"If the source skill has support files OR SKILL.md contains relative "
|
||||
"links such as `references/...`, `templates/...`, `scripts/...`, or "
|
||||
"`assets/...`, DO NOT flatten only SKILL.md into "
|
||||
"`<umbrella>/references/<old>.md`. Choose one safe path instead:\n"
|
||||
" • keep it as a standalone skill, OR\n"
|
||||
" • fully merge it by re-homing every needed support file into the "
|
||||
"umbrella's canonical `references/`, `templates/`, `scripts/`, or "
|
||||
"`assets/` directories AND rewrite the destination instructions to "
|
||||
"the new paths, OR\n"
|
||||
" • archive the entire original skill package unchanged.\n"
|
||||
"Never leave archived/demoted instructions pointing at files that were "
|
||||
"left behind under the old skill directory.\n"
|
||||
"references/<topic>.md` (or templates/ / scripts/).\n"
|
||||
"4. Also flag skills whose NAME is too narrow (contains a PR number, "
|
||||
"a feature codename, a specific error string, an 'audit' / "
|
||||
"'diagnosis' / 'salvage' session artifact). These almost always "
|
||||
@@ -1511,30 +1465,14 @@ def run_curator_review(
|
||||
"error": None,
|
||||
}
|
||||
else:
|
||||
# When pruning built-ins is enabled, the candidate list now
|
||||
# includes bundled skills. Override the default "don't touch
|
||||
# bundled" rule for them — but only archiving is permitted, and
|
||||
# hub-installed skills remain strictly off-limits.
|
||||
builtins_note = ""
|
||||
if get_prune_builtins():
|
||||
builtins_note = (
|
||||
"\n\nPRUNE-BUILTINS MODE IS ON: bundled built-in skills "
|
||||
"ARE included in the candidate list below and MAY be "
|
||||
"archived for staleness/irrelevance, overriding hard "
|
||||
"rule #1 for bundled skills ONLY. Hub-installed skills "
|
||||
"remain strictly off-limits. Treat a stale built-in the "
|
||||
"same as a stale agent-created skill: archive it (never "
|
||||
"delete). It will be restored on `hermes update` only if "
|
||||
"the user explicitly restores it."
|
||||
)
|
||||
if dry_run:
|
||||
prompt = (
|
||||
f"{CURATOR_DRY_RUN_BANNER}\n\n"
|
||||
f"{CURATOR_REVIEW_PROMPT}{builtins_note}\n\n"
|
||||
f"{CURATOR_REVIEW_PROMPT}\n\n"
|
||||
f"{candidate_list}"
|
||||
)
|
||||
else:
|
||||
prompt = f"{CURATOR_REVIEW_PROMPT}{builtins_note}\n\n{candidate_list}"
|
||||
prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}"
|
||||
llm_meta = _run_llm_review(prompt)
|
||||
final_summary = (
|
||||
f"{prefix}{auto_summary}; llm: {llm_meta.get('summary', 'no change')}"
|
||||
|
||||
@@ -21,8 +21,6 @@ It DOES include:
|
||||
pointer — otherwise the curator would immediately re-fire on the next
|
||||
tick)
|
||||
- ``.bundled_manifest`` (so protection markers stay consistent)
|
||||
- ``.curator_suppressed`` (so rollback restores the set of pruned built-ins
|
||||
the re-seeder must leave archived)
|
||||
|
||||
Alongside the skills tarball, each snapshot also captures a copy of
|
||||
``~/.hermes/cron/jobs.json`` as ``cron-jobs.json`` when it exists. Cron
|
||||
@@ -41,9 +39,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -904,6 +904,10 @@ def get_cute_tool_message(
|
||||
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 == "web_crawl":
|
||||
url = args.get("url", "")
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
return _wrap(f"┊ 🕸️ crawl {_trunc(domain, 35)} {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
|
||||
@@ -44,10 +44,9 @@ class FailoverReason(enum.Enum):
|
||||
payload_too_large = "payload_too_large" # 413 — compress payload
|
||||
image_too_large = "image_too_large" # Native image part exceeds provider's per-image limit — shrink and retry
|
||||
|
||||
# Model / provider policy
|
||||
# Model
|
||||
model_not_found = "model_not_found" # 404 or invalid model — fallback to different model
|
||||
provider_policy_blocked = "provider_policy_blocked" # Aggregator (e.g. OpenRouter) blocked the only endpoint due to account data/privacy policy
|
||||
content_policy_blocked = "content_policy_blocked" # Provider safety filter rejected this prompt — deterministic per-request, don't retry unchanged
|
||||
|
||||
# Request format
|
||||
format_error = "format_error" # 400 bad request — abort or strip + retry
|
||||
@@ -98,20 +97,13 @@ _BILLING_PATTERNS = [
|
||||
"insufficient_quota",
|
||||
"insufficient balance",
|
||||
"credit balance",
|
||||
"credits exhausted",
|
||||
"credits have been exhausted",
|
||||
"no usable credits",
|
||||
"top up your credits",
|
||||
"payment required",
|
||||
"billing hard limit",
|
||||
"exceeded your current quota",
|
||||
"account is deactivated",
|
||||
"plan does not include",
|
||||
"out of funds",
|
||||
"run out of funds",
|
||||
"balance_depleted",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
]
|
||||
|
||||
# Patterns that indicate rate limiting (transient, will resolve)
|
||||
@@ -290,45 +282,6 @@ _PROVIDER_POLICY_BLOCKED_PATTERNS = [
|
||||
"no endpoints found matching your data policy",
|
||||
]
|
||||
|
||||
# Provider content-policy / safety-filter blocks. Distinct from
|
||||
# ``provider_policy_blocked`` above (which is an OpenRouter *account*-level
|
||||
# data/privacy guardrail) — these are *per-prompt* safety decisions made by
|
||||
# the upstream model provider. They are deterministic for the unchanged
|
||||
# request, so retrying the same prompt three times just reproduces the same
|
||||
# block and burns paid attempts on a refusal. The recovery is to switch to a
|
||||
# configured fallback model/provider immediately, or surface the block to
|
||||
# the user with actionable guidance if no fallback exists.
|
||||
#
|
||||
# Patterns are intentionally narrow — each phrase is a verbatim string from
|
||||
# a specific provider's safety pipeline, not a generic word like "policy" or
|
||||
# "violation" that could collide with billing/auth/format errors:
|
||||
# • OpenAI Codex cybersecurity refusal (gpt-5.5, the case from #18028)
|
||||
# • OpenAI moderation refusal ("violates our usage policies", with
|
||||
# "usage policies" disambiguating from billing's "exceeded ... policy")
|
||||
# • Anthropic safety refusal ("prompt was flagged by ... safety system")
|
||||
# • OpenAI Responses content filter
|
||||
_CONTENT_POLICY_BLOCKED_PATTERNS = [
|
||||
# OpenAI Codex (#18028) — message may arrive without an HTTP status
|
||||
"flagged for possible cybersecurity risk",
|
||||
"trusted access for cyber",
|
||||
# OpenAI moderation — chat completions / responses
|
||||
"violates our usage policies",
|
||||
"violates openai's usage policies",
|
||||
"your request was flagged by",
|
||||
# Anthropic safety system
|
||||
"prompt was flagged by our safety",
|
||||
"responses cannot be generated due to safety",
|
||||
# Generic content-filter wording seen on Azure / OpenAI Responses.
|
||||
# ``content_filter`` (underscore) is the OpenAI-standard error/finish
|
||||
# token surfaced verbatim by their SDKs when a request is blocked.
|
||||
# ``responsibleaipolicyviolation`` is Azure OpenAI's error code.
|
||||
# Deliberately NOT matching the space variant ("content filter") — it
|
||||
# appears in benign config descriptions and tooltip text that providers
|
||||
# echo back; the underscore form is provider-specific enough.
|
||||
"content_filter",
|
||||
"responsibleaipolicyviolation",
|
||||
]
|
||||
|
||||
# Auth patterns (non-status-code signals)
|
||||
_AUTH_PATTERNS = [
|
||||
"invalid api key",
|
||||
@@ -532,20 +485,6 @@ def classify_api_error(
|
||||
|
||||
# ── 1. Provider-specific patterns (highest priority) ────────────
|
||||
|
||||
# Provider content-policy / safety-filter block. The provider has made a
|
||||
# deterministic refusal decision about THIS prompt — retrying unchanged
|
||||
# just reproduces the same refusal and burns paid attempts. Must run
|
||||
# before status-based classification so a 400 safety block isn't
|
||||
# downgraded to a generic ``format_error`` and a status-less block
|
||||
# (OpenAI Codex SDK can raise without one) isn't left in the retryable
|
||||
# ``unknown`` bucket. See issue #18028.
|
||||
if any(p in error_msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS):
|
||||
return _result(
|
||||
FailoverReason.content_policy_blocked,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Anthropic thinking block signature invalid (400).
|
||||
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
|
||||
# provider may be "openrouter" even though the error is Anthropic-specific.
|
||||
@@ -751,13 +690,8 @@ def _classify_by_status(
|
||||
)
|
||||
|
||||
if status_code == 403:
|
||||
# OpenRouter 403 "key limit exceeded" is actually billing. Other
|
||||
# providers also use 403 for account-plan or credit exhaustion.
|
||||
if (
|
||||
"key limit exceeded" in error_msg
|
||||
or "spending limit" in error_msg
|
||||
or any(p in error_msg for p in _BILLING_PATTERNS)
|
||||
):
|
||||
# OpenRouter 403 "key limit exceeded" is actually billing
|
||||
if "key limit exceeded" in error_msg or "spending limit" in error_msg:
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
@@ -774,17 +708,6 @@ def _classify_by_status(
|
||||
return _classify_402(error_msg, result_fn)
|
||||
|
||||
if status_code == 404:
|
||||
# Nous API currently surfaces HA/NAS credit depletion as a paid model
|
||||
# becoming unavailable on the Free Tier, returned as 404 rather than
|
||||
# 402. Treat that as entitlement/billing exhaustion, not a missing
|
||||
# model, so the retry loop can show credit/top-up guidance.
|
||||
if any(p in error_msg for p in _BILLING_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
should_rotate_credential=True,
|
||||
should_fallback=True,
|
||||
)
|
||||
# OpenRouter policy-block 404 — distinct from "model not found".
|
||||
# The model exists; the user's account privacy setting excludes the
|
||||
# only endpoint serving it. Falling back to another provider won't
|
||||
@@ -1050,15 +973,7 @@ def _classify_by_error_code(
|
||||
should_rotate_credential=True,
|
||||
)
|
||||
|
||||
if code_lower in {
|
||||
"insufficient_quota",
|
||||
"billing_not_active",
|
||||
"payment_required",
|
||||
"insufficient_credits",
|
||||
"no_usable_credits",
|
||||
"balance_depleted",
|
||||
"model_not_supported_on_free_tier",
|
||||
}:
|
||||
if code_lower in {"insufficient_quota", "billing_not_active", "payment_required"}:
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
|
||||
@@ -249,10 +249,6 @@ def get_read_block_error(path: str) -> Optional[str]:
|
||||
".env",
|
||||
"webhook_subscriptions.json",
|
||||
os.path.join("auth", "google_oauth.json"),
|
||||
# Bitwarden Secrets Manager disk cache: stores plaintext secret values
|
||||
# to avoid re-fetching across back-to-back CLI invocations. The file
|
||||
# was introduced by #31968 but not added to this guard.
|
||||
os.path.join("cache", "bws_cache.json"),
|
||||
)
|
||||
for hd in hermes_dirs:
|
||||
for name in credential_file_names:
|
||||
@@ -451,190 +447,3 @@ def get_cross_profile_warning(path: str) -> Optional[str]:
|
||||
f"``cross_profile=True``. (Defense-in-depth — not a security "
|
||||
f"boundary; the terminal tool can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox-mirror write guard (#32049)
|
||||
#
|
||||
# Non-local terminal backends (Docker, Daytona, etc.) bind a sandbox-local
|
||||
# directory to the container's ``$HOME``. The on-disk layout looks like
|
||||
#
|
||||
# <HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...
|
||||
#
|
||||
# When the agent (running host-side) speculates that authoritative profile
|
||||
# state lives at one of those sandbox-mirror paths, the write lands on the
|
||||
# mirror — never read by the host process — while the host file is left
|
||||
# untouched. The agent reports success, the user sees no change, and on
|
||||
# disk two divergent copies accumulate. See #32049 for evidence.
|
||||
#
|
||||
# This guard is path-shape-only: it detects the
|
||||
# ``…/sandboxes/<backend>/<task>/home/.hermes/…`` segment and warns
|
||||
# regardless of which Hermes profile is active. It does NOT cover the
|
||||
# inner-container case where the bind mount strips the ``sandboxes/`` prefix
|
||||
# (the agent's view inside the container is plain ``/root/.hermes/...``);
|
||||
# that case needs a separate dispatch-layer or host-side ``profile_state``
|
||||
# tool.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_sandbox_mirror_segments(parts: tuple) -> Optional[int]:
|
||||
"""Return the index of the inner ``.hermes`` part in a sandbox-mirror path.
|
||||
|
||||
Matches ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and returns the
|
||||
index where the inner Hermes-state portion starts. Returns ``None`` for
|
||||
paths that do not contain the sandbox-mirror shape.
|
||||
"""
|
||||
for i, part in enumerate(parts):
|
||||
if part != "sandboxes":
|
||||
continue
|
||||
# Need at least: sandboxes / <backend> / <task> / home / .hermes / <thing>
|
||||
if i + 5 >= len(parts):
|
||||
continue
|
||||
if parts[i + 3] == "home" and parts[i + 4] == ".hermes":
|
||||
return i + 4
|
||||
return None
|
||||
|
||||
|
||||
def classify_sandbox_mirror_target(path: str) -> Optional[dict]:
|
||||
"""Classify a write target as a sandbox-mirror of authoritative Hermes state.
|
||||
|
||||
Returns ``None`` when the path does not match the sandbox-mirror shape.
|
||||
Otherwise returns a dict with:
|
||||
|
||||
* ``target_path``: the resolved path string
|
||||
* ``mirror_root``: the ``…/sandboxes/<backend>/<task>/home/.hermes``
|
||||
prefix (so callers can show users which sandbox owns the mirror)
|
||||
* ``inner_path``: the portion under the mirror's ``.hermes`` (what the
|
||||
agent likely meant to address on the host)
|
||||
|
||||
Detection is path-shape-only — does not require any Hermes resolver to
|
||||
succeed, so it works correctly even when called from contexts where
|
||||
HERMES_HOME resolution would be ambiguous.
|
||||
"""
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
parts = target.parts
|
||||
inner_idx = _find_sandbox_mirror_segments(parts)
|
||||
if inner_idx is None:
|
||||
return None
|
||||
|
||||
mirror_root = str(Path(*parts[: inner_idx + 1]))
|
||||
inner_path = str(Path(*parts[inner_idx + 1 :])) if inner_idx + 1 < len(parts) else ""
|
||||
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": mirror_root,
|
||||
"inner_path": inner_path,
|
||||
}
|
||||
|
||||
|
||||
def get_sandbox_mirror_warning(path: str) -> Optional[str]:
|
||||
"""Return a model-facing warning when ``path`` lands in a sandbox mirror.
|
||||
|
||||
Returns ``None`` when the path is not a sandbox-mirror target. Caller
|
||||
is expected to surface the warning to the agent as a tool-result
|
||||
error. The bypass kwarg (``cross_profile=True``) is shared with the
|
||||
cross-profile guard: both are soft "I know what I'm doing" overrides
|
||||
a user can authorise.
|
||||
|
||||
Defense-in-depth, NOT a security boundary: the terminal tool runs as
|
||||
the same OS user and can write the mirror path directly. The guard
|
||||
exists to surface the misclassification before the silent-success +
|
||||
divergent-copy footgun in #32049 fires.
|
||||
"""
|
||||
info = classify_sandbox_mirror_target(path)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is a per-task mirror "
|
||||
f"created by a non-local terminal backend (docker/daytona/etc.). "
|
||||
f"Writes here land on a copy that the host Hermes process never "
|
||||
f"reads — the authoritative file is likely {info['inner_path']!r} "
|
||||
f"under the real HERMES_HOME. Use the host-side tool for "
|
||||
f"authoritative state (e.g. ``memory`` for memories), or address "
|
||||
f"the host path directly. To bypass this guard after explicit "
|
||||
f"user direction, retry the call with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container-context mirror guard (inner-container case — #32049 follow-up)
|
||||
#
|
||||
# Brian's shape-based detector (#32213) catches paths that still carry the
|
||||
# full ``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix on the host.
|
||||
# But when file tools execute *inside* the container the bind-mount strips
|
||||
# that prefix: the agent sees plain ``/root/.hermes/…``. The root:root
|
||||
# ownership on the divergent SOUL.md in #32049 confirms this is the primary
|
||||
# failure mode.
|
||||
#
|
||||
# Fix: file_tools passes the active Docker mirror prefix when the terminal
|
||||
# backend is docker + persistent. This catches the very first file-tool call,
|
||||
# before a DockerEnvironment object necessarily exists.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def classify_container_mirror_target(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""Classify a write target as a container-side sandbox mirror.
|
||||
|
||||
``mirror_prefix`` must be supplied by the caller after it has established
|
||||
that file tools are executing in a container whose home is a sandbox
|
||||
mirror. Returns ``None`` when no such context is active or the path is not
|
||||
under the mirror prefix. Otherwise returns:
|
||||
|
||||
* ``target_path``: resolved path string
|
||||
* ``mirror_root``: the declared container mirror prefix
|
||||
* ``inner_path``: portion under the mirror root (what the agent
|
||||
likely meant to address in the host HERMES_HOME)
|
||||
"""
|
||||
if not mirror_prefix:
|
||||
return None
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
mirror = Path(os.path.expanduser(mirror_prefix)).resolve()
|
||||
inner = target.relative_to(mirror)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": str(mirror),
|
||||
"inner_path": inner.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def get_container_mirror_warning(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""Return a model-facing warning when *path* lands in the container's
|
||||
sandbox mirror of authoritative Hermes state.
|
||||
|
||||
The caller supplies ``mirror_prefix`` only when the current file-tool
|
||||
backend is known to execute inside a Docker sandbox. Same contract as
|
||||
``get_cross_profile_warning``: soft guard, returns ``None`` for
|
||||
non-mirror paths, caller surfaces as a tool-result error. Bypass via
|
||||
``cross_profile=True`` after explicit user direction.
|
||||
"""
|
||||
info = classify_container_mirror_target(path, mirror_prefix)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is the container's "
|
||||
f"bind-mounted home — a per-task mirror that the host Hermes "
|
||||
f"process never reads. The authoritative file is "
|
||||
f"{info['inner_path']!r} under the real HERMES_HOME. Use the "
|
||||
f"host-side tool for authoritative state (e.g. ``memory`` for "
|
||||
f"memories), or address the host path directly. To bypass after "
|
||||
f"explicit user direction, retry with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -899,15 +899,7 @@ def start_oauth_flow(
|
||||
try:
|
||||
import webbrowser
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
_can_open_graphical_browser as _can_open_gui,
|
||||
)
|
||||
except Exception:
|
||||
_can_open_gui = lambda: True # noqa: E731
|
||||
|
||||
if _can_open_gui():
|
||||
webbrowser.open(auth_url, new=1, autoraise=True)
|
||||
webbrowser.open(auth_url, new=1, autoraise=True)
|
||||
except Exception as exc:
|
||||
logger.debug("webbrowser.open failed: %s", exc)
|
||||
|
||||
|
||||
+14
-134
@@ -37,8 +37,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -48,102 +46,6 @@ logger = logging.getLogger(__name__)
|
||||
_VALID_MODES = frozenset({"auto", "native", "text"})
|
||||
|
||||
|
||||
# Image extensions used by extract_image_refs(). Kept tight on purpose — we
|
||||
# only auto-attach things the model can actually see. Documents/archives are
|
||||
# excluded because the gateway's broader extract_local_files() also routes
|
||||
# them differently (send_document), and we don't want to attach a PDF as a
|
||||
# vision part.
|
||||
_IMAGE_EXTS = (
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic",
|
||||
)
|
||||
_IMAGE_EXT_PATTERN = "|".join(e.lstrip(".") for e in _IMAGE_EXTS)
|
||||
|
||||
# Absolute / home-relative local image path. Matches the same shape gateway's
|
||||
# extract_local_files() uses: anchors to ``~/`` or ``/``, ignores matches inside
|
||||
# URLs (the ``(?<![/:\w.])`` lookbehind), and case-insensitive on the extension.
|
||||
_LOCAL_IMAGE_PATH_RE = re.compile(
|
||||
r"(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:" + _IMAGE_EXT_PATTERN + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# http(s) URL ending in an image extension (optionally followed by a
|
||||
# query string). Case-insensitive on the extension. Strict ``http(s)://``
|
||||
# scheme so we don't accidentally grab ``file://`` URLs or other shapes.
|
||||
_IMAGE_URL_RE = re.compile(
|
||||
r"https?://[^\s<>\"']+?\.(?:" + _IMAGE_EXT_PATTERN + r")(?:\?[^\s<>\"']*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_image_refs(text: str) -> Tuple[List[str], List[str]]:
|
||||
"""Scan free-form text for image references the model should see.
|
||||
|
||||
Returns ``(local_paths, urls)``:
|
||||
|
||||
* ``local_paths`` — absolute (``/``) or home-relative (``~/``) paths
|
||||
whose suffix is an image extension AND whose expanded form exists
|
||||
on disk as a file. Order-preserving, deduplicated.
|
||||
* ``urls`` — ``http(s)://…`` URLs whose path ends in an image
|
||||
extension (a ``?query`` is allowed after the extension).
|
||||
Order-preserving, deduplicated.
|
||||
|
||||
Matches inside fenced code blocks (``` ``` ```) and inline backticks
|
||||
(`` `…` ``) are skipped so that snippets pasted into a task body for
|
||||
reference aren't mistaken for live attachments. This mirrors the
|
||||
behaviour of ``gateway.platforms.base.BaseAdapter.extract_local_files``.
|
||||
|
||||
Local paths are validated against the filesystem; URLs are not
|
||||
(the provider fetches them at request time).
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return [], []
|
||||
|
||||
# Build spans covered by fenced code blocks and inline code so we can
|
||||
# ignore references the author embedded purely as example text.
|
||||
code_spans: list[tuple[int, int]] = []
|
||||
for m in re.finditer(r"```[^\n]*\n.*?```", text, re.DOTALL):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
for m in re.finditer(r"`[^`\n]+`", text):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
|
||||
def _in_code(pos: int) -> bool:
|
||||
return any(s <= pos < e for s, e in code_spans)
|
||||
|
||||
local_paths: list[str] = []
|
||||
seen_paths: set[str] = set()
|
||||
for match in _LOCAL_IMAGE_PATH_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
raw = match.group(0)
|
||||
expanded = os.path.expanduser(raw)
|
||||
try:
|
||||
if not os.path.isfile(expanded):
|
||||
continue
|
||||
except OSError:
|
||||
# ENAMETOOLONG / EINVAL on pathological inputs — skip rather than crash.
|
||||
continue
|
||||
if expanded in seen_paths:
|
||||
continue
|
||||
seen_paths.add(expanded)
|
||||
local_paths.append(expanded)
|
||||
|
||||
urls: list[str] = []
|
||||
seen_urls: set[str] = set()
|
||||
for match in _IMAGE_URL_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
url = match.group(0)
|
||||
# Strip trailing punctuation that's almost certainly prose, not part
|
||||
# of the URL (e.g. "see https://x.com/a.png." or "/a.png)").
|
||||
url = url.rstrip(".,;:!?)]>")
|
||||
if url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
urls.append(url)
|
||||
|
||||
return local_paths, urls
|
||||
|
||||
|
||||
# Strict YAML/JSON boolean coercion for capability overrides.
|
||||
#
|
||||
# ``bool("false")`` is True in Python because non-empty strings are truthy, so
|
||||
@@ -418,29 +320,20 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
def build_native_content_parts(
|
||||
user_text: str,
|
||||
image_paths: List[str],
|
||||
image_urls: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""Build an OpenAI-style ``content`` list for a user turn.
|
||||
|
||||
Shape:
|
||||
[{"type": "text", "text": "...\\n\\n[Image attached at: /local/path]"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
|
||||
...]
|
||||
|
||||
Local paths are read from disk and embedded as base64 ``data:`` URLs.
|
||||
Remote URLs (``http(s)://``) are passed through verbatim — the provider
|
||||
fetches them server-side. The model still sees the pixels either way.
|
||||
|
||||
For each successfully attached image, a hint is appended to the text
|
||||
part:
|
||||
|
||||
* local path → ``[Image attached at: <path>]``
|
||||
* URL → ``[Image attached: <url>]``
|
||||
|
||||
The hint gives the model a string handle so MCP/skill tools that take
|
||||
an image path or URL argument can be invoked on the same image without
|
||||
an extra round-trip. This parallels the text-mode hint produced by
|
||||
The local path of each successfully attached image is appended to the
|
||||
text part as ``[Image attached at: <path>]``. The model still sees the
|
||||
pixels via the ``image_url`` part (full native vision); the path note
|
||||
just gives it a string handle so MCP/skill tools that take an image
|
||||
path or URL argument can be invoked on the same image without an
|
||||
extra round-trip. This parallels the text-mode hint produced by
|
||||
``Runner._enrich_message_with_vision`` (``vision_analyze using image_url:
|
||||
<path>``) so behaviour is consistent across both image input modes.
|
||||
|
||||
@@ -449,14 +342,12 @@ def build_native_content_parts(
|
||||
ceiling), the agent's retry loop transparently shrinks and retries
|
||||
once — see ``run_agent._try_shrink_image_parts_in_messages``.
|
||||
|
||||
Returns (content_parts, skipped). Skipped entries are local paths
|
||||
that couldn't be read from disk; URLs are never skipped (they're
|
||||
not validated here).
|
||||
Returns (content_parts, skipped_paths). Skipped paths are files that
|
||||
couldn't be read from disk and are NOT advertised in the path hints.
|
||||
"""
|
||||
skipped: List[str] = []
|
||||
image_parts: List[Dict[str, Any]] = []
|
||||
attached_paths: List[str] = []
|
||||
attached_urls: List[str] = []
|
||||
|
||||
for raw_path in image_paths:
|
||||
p = Path(raw_path)
|
||||
@@ -473,26 +364,16 @@ def build_native_content_parts(
|
||||
})
|
||||
attached_paths.append(str(raw_path))
|
||||
|
||||
for url in image_urls or []:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
image_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
})
|
||||
attached_urls.append(url)
|
||||
|
||||
text = (user_text or "").strip()
|
||||
|
||||
# If at least one image attached, build a single text part that combines
|
||||
# the user's caption (or a neutral default) with one hint per image.
|
||||
if attached_paths or attached_urls:
|
||||
# the user's caption (or a neutral default) with one path hint per image.
|
||||
if attached_paths:
|
||||
base_text = text or "What do you see in this image?"
|
||||
hint_lines: List[str] = []
|
||||
hint_lines.extend(f"[Image attached at: {p}]" for p in attached_paths)
|
||||
hint_lines.extend(f"[Image attached: {u}]" for u in attached_urls)
|
||||
combined_text = f"{base_text}\n\n" + "\n".join(hint_lines)
|
||||
path_hints = "\n".join(
|
||||
f"[Image attached at: {p}]" for p in attached_paths
|
||||
)
|
||||
combined_text = f"{base_text}\n\n{path_hints}"
|
||||
parts: List[Dict[str, Any]] = [{"type": "text", "text": combined_text}]
|
||||
parts.extend(image_parts)
|
||||
return parts, skipped
|
||||
@@ -507,5 +388,4 @@ def build_native_content_parts(
|
||||
__all__ = [
|
||||
"decide_image_input_mode",
|
||||
"build_native_content_parts",
|
||||
"extract_image_refs",
|
||||
]
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Best-effort early import for the OpenAI SDK's native streaming parser.
|
||||
|
||||
The OpenAI SDK imports ``jiter`` while constructing streaming chat-completion
|
||||
responses. On some Windows installs the native extension can be imported
|
||||
directly from the Hermes venv, but the first import fails when it happens later
|
||||
inside the threaded streaming request path. Loading it once during agent
|
||||
package import avoids that import-order failure while preserving the normal
|
||||
SDK error path for genuinely missing or broken installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
_JITER_PRELOADED = False
|
||||
_JITER_PRELOAD_ERROR: Exception | None = None
|
||||
|
||||
|
||||
def preload_jiter_native_extension() -> bool:
|
||||
"""Import jiter's native extension early if it is available."""
|
||||
|
||||
global _JITER_PRELOADED, _JITER_PRELOAD_ERROR
|
||||
|
||||
if _JITER_PRELOADED:
|
||||
return True
|
||||
|
||||
try:
|
||||
importlib.import_module("jiter.jiter")
|
||||
from jiter import from_json as _from_json # noqa: F401
|
||||
except Exception as exc:
|
||||
_JITER_PRELOAD_ERROR = exc
|
||||
return False
|
||||
|
||||
_JITER_PRELOADED = True
|
||||
_JITER_PRELOAD_ERROR = None
|
||||
return True
|
||||
|
||||
|
||||
preload_jiter_native_extension()
|
||||
+15
-6
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def register_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -247,13 +248,19 @@ def _cmd_restart() -> int:
|
||||
|
||||
|
||||
def _cmd_which(server_id: str) -> int:
|
||||
from agent.lsp.install import INSTALL_RECIPES, _existing_binary
|
||||
from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
|
||||
import os
|
||||
import shutil as _shutil
|
||||
|
||||
recipe = INSTALL_RECIPES.get(server_id)
|
||||
bin_name = (recipe or {}).get("bin", server_id)
|
||||
resolved = _existing_binary(bin_name)
|
||||
if resolved:
|
||||
sys.stdout.write(resolved + "\n")
|
||||
staged = hermes_lsp_bin_dir() / bin_name
|
||||
if staged.exists():
|
||||
sys.stdout.write(str(staged) + "\n")
|
||||
return 0
|
||||
on_path = _shutil.which(bin_name)
|
||||
if on_path:
|
||||
sys.stdout.write(on_path + "\n")
|
||||
return 0
|
||||
sys.stderr.write(f"{server_id}: not installed\n")
|
||||
return 1
|
||||
@@ -287,9 +294,11 @@ def _backend_warnings() -> list:
|
||||
suggestion across common platforms.
|
||||
"""
|
||||
import shutil as _shutil
|
||||
from agent.lsp.install import _existing_binary
|
||||
from agent.lsp.install import hermes_lsp_bin_dir
|
||||
notes: list = []
|
||||
bash_installed = _existing_binary("bash-language-server") is not None
|
||||
bash_installed = _shutil.which("bash-language-server") is not None or (
|
||||
(hermes_lsp_bin_dir() / "bash-language-server").exists()
|
||||
)
|
||||
if bash_installed and _shutil.which("shellcheck") is None:
|
||||
notes.append(
|
||||
"bash-language-server is installed but shellcheck is missing — "
|
||||
|
||||
+3
-16
@@ -44,7 +44,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -245,27 +244,15 @@ class LSPClient:
|
||||
await self._cleanup_process()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _win_wrap_cmd(cmd: List[str]) -> List[str]:
|
||||
"""On Windows, wrap .cmd/.bat shims so CreateProcess can run them."""
|
||||
exe = cmd[0]
|
||||
if exe.lower().endswith((".cmd", ".bat")):
|
||||
return ["cmd.exe", "/c", *cmd]
|
||||
return cmd
|
||||
|
||||
async def _spawn(self) -> None:
|
||||
env = dict(os.environ)
|
||||
if self._env:
|
||||
env.update(self._env)
|
||||
|
||||
cmd = self._command
|
||||
if sys.platform == "win32":
|
||||
cmd = self._win_wrap_cmd(cmd)
|
||||
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
cmd[0],
|
||||
*cmd[1:],
|
||||
self._command[0],
|
||||
*self._command[1:],
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -274,7 +261,7 @@ class LSPClient:
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise LSPProtocolError(
|
||||
f"LSP server binary not found: {cmd[0]} ({e})"
|
||||
f"LSP server binary not found: {self._command[0]} ({e})"
|
||||
) from e
|
||||
|
||||
# Drain stderr at debug level — if we don't, the pipe buffer
|
||||
|
||||
+23
-47
@@ -108,11 +108,6 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
||||
_install_locks: Dict[str, threading.Lock] = {}
|
||||
_install_results: Dict[str, Optional[str]] = {}
|
||||
_install_lock_meta = threading.Lock()
|
||||
_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def hermes_lsp_bin_dir() -> Path:
|
||||
@@ -125,33 +120,14 @@ def hermes_lsp_bin_dir() -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def _native_binary_candidates(base: Path) -> list[Path]:
|
||||
"""Return platform-native executable candidates for a staged binary."""
|
||||
candidates = [base]
|
||||
if _is_windows():
|
||||
existing = {str(base).lower()}
|
||||
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
||||
candidate = Path(str(base) + suffix)
|
||||
key = str(candidate).lower()
|
||||
if key not in existing:
|
||||
candidates.append(candidate)
|
||||
existing.add(key)
|
||||
return candidates
|
||||
|
||||
|
||||
def _existing_binary(name: str) -> Optional[str]:
|
||||
"""Probe the staging dir + PATH for a binary named ``name``."""
|
||||
for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
|
||||
if staged.exists() and os.access(staged, os.X_OK):
|
||||
return str(staged)
|
||||
staged = hermes_lsp_bin_dir() / name
|
||||
if staged.exists() and os.access(staged, os.X_OK):
|
||||
return str(staged)
|
||||
on_path = shutil.which(name)
|
||||
if on_path:
|
||||
return on_path
|
||||
if _is_windows():
|
||||
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
||||
on_path = shutil.which(f"{name}{suffix}")
|
||||
if on_path:
|
||||
return on_path
|
||||
return None
|
||||
|
||||
|
||||
@@ -274,7 +250,12 @@ def _install_npm(
|
||||
|
||||
# Find the bin
|
||||
nm_bin = staging / "node_modules" / ".bin" / bin_name
|
||||
for c in _native_binary_candidates(nm_bin):
|
||||
if os.name == "nt":
|
||||
# On Windows npm sometimes drops `.cmd` shims
|
||||
candidates = [nm_bin, nm_bin.with_suffix(".cmd")]
|
||||
else:
|
||||
candidates = [nm_bin]
|
||||
for c in candidates:
|
||||
if c.exists():
|
||||
# Symlink into our `lsp/bin/` for stable PATH access.
|
||||
link = hermes_lsp_bin_dir() / c.name
|
||||
@@ -320,7 +301,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
||||
logger.warning("[install] go install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
bin_path = staging / bin_name
|
||||
if _is_windows():
|
||||
if os.name == "nt":
|
||||
bin_path = bin_path.with_suffix(".exe")
|
||||
if bin_path.exists():
|
||||
return str(bin_path)
|
||||
@@ -356,24 +337,19 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("[install] pip install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
# Look for the console script. POSIX wheels generally write to bin/,
|
||||
# while native Windows installs use Scripts/.
|
||||
script_dirs = [pip_target / "bin"]
|
||||
if _is_windows():
|
||||
script_dirs.append(pip_target / "Scripts")
|
||||
for script_dir in script_dirs:
|
||||
for bin_path in _native_binary_candidates(script_dir / bin_name):
|
||||
if bin_path.exists():
|
||||
link = hermes_lsp_bin_dir() / bin_path.name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(bin_path)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
shutil.copy2(bin_path, link)
|
||||
except OSError:
|
||||
return str(bin_path)
|
||||
return str(link if link.exists() else bin_path)
|
||||
# Look for the script
|
||||
bin_path = pip_target / "bin" / bin_name
|
||||
if bin_path.exists():
|
||||
link = hermes_lsp_bin_dir() / bin_name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(bin_path)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
shutil.copy2(bin_path, link)
|
||||
except OSError:
|
||||
return str(bin_path)
|
||||
return str(link if link.exists() else bin_path)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -39,20 +39,25 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future as ConcurrentFuture
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.lsp import eventlog
|
||||
from agent.lsp.client import (
|
||||
DIAGNOSTICS_DOCUMENT_WAIT,
|
||||
LSPClient,
|
||||
file_uri,
|
||||
)
|
||||
from agent.lsp.servers import (
|
||||
ServerContext,
|
||||
ServerDef,
|
||||
SpawnSpec,
|
||||
find_server_for_file,
|
||||
language_id_for,
|
||||
)
|
||||
from agent.lsp.workspace import (
|
||||
clear_cache,
|
||||
is_inside_workspace,
|
||||
resolve_workspace_for_file,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from agent.lsp.workspace import nearest_root
|
||||
from agent.lsp.workspace import nearest_root, normalize_path
|
||||
|
||||
logger = logging.getLogger("agent.lsp.servers")
|
||||
|
||||
|
||||
+2
-46
@@ -368,42 +368,11 @@ class MemoryManager:
|
||||
|
||||
# -- Sync ----------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _provider_sync_accepts_messages(provider: MemoryProvider) -> bool:
|
||||
"""Return whether sync_turn accepts a messages keyword."""
|
||||
try:
|
||||
signature = inspect.signature(provider.sync_turn)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
params = list(signature.parameters.values())
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params):
|
||||
return True
|
||||
return "messages" in signature.parameters
|
||||
|
||||
def sync_all(
|
||||
self,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
*,
|
||||
session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Sync a completed turn to all providers."""
|
||||
for provider in self._providers:
|
||||
try:
|
||||
if messages is not None and self._provider_sync_accepts_messages(provider):
|
||||
provider.sync_turn(
|
||||
user_content,
|
||||
assistant_content,
|
||||
session_id=session_id,
|
||||
messages=messages,
|
||||
)
|
||||
else:
|
||||
provider.sync_turn(
|
||||
user_content,
|
||||
assistant_content,
|
||||
session_id=session_id,
|
||||
)
|
||||
provider.sync_turn(user_content, assistant_content, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Memory provider '%s' sync_turn failed: %s",
|
||||
@@ -491,7 +460,6 @@ class MemoryManager:
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
rewound: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Notify all providers that the agent's session_id has rotated.
|
||||
@@ -504,21 +472,9 @@ class MemoryManager:
|
||||
per-session state so subsequent writes land in the correct
|
||||
session's record. See ``MemoryProvider.on_session_switch`` for
|
||||
the full contract.
|
||||
|
||||
``rewound=True`` signals that session_id is unchanged but the
|
||||
transcript was truncated; providers caching per-turn document
|
||||
state should invalidate.
|
||||
"""
|
||||
if not new_session_id:
|
||||
return
|
||||
# Only forward ``rewound`` when it's actually set. Passing it
|
||||
# unconditionally would inject ``rewound=False`` into every
|
||||
# provider's **kwargs for the common /resume, /branch, /new, and
|
||||
# compression paths, polluting providers that capture extra kwargs
|
||||
# (and breaking exact-dict assertions). The /undo path sets
|
||||
# rewound=True explicitly; everyone else stays clean.
|
||||
if rewound:
|
||||
kwargs["rewound"] = True
|
||||
for provider in self._providers:
|
||||
try:
|
||||
provider.on_session_switch(
|
||||
|
||||
@@ -78,7 +78,6 @@ class MemoryProvider(ABC):
|
||||
- agent_workspace (str): Shared workspace name (e.g. "hermes").
|
||||
- parent_session_id (str): For subagents, the parent's session_id.
|
||||
- user_id (str): Platform user identifier (gateway sessions).
|
||||
- user_id_alt (str): Optional alternate stable platform user identifier.
|
||||
"""
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
@@ -112,22 +111,11 @@ class MemoryProvider(ABC):
|
||||
that do background prefetching should override this.
|
||||
"""
|
||||
|
||||
def sync_turn(
|
||||
self,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
*,
|
||||
session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Persist a completed turn to the backend.
|
||||
|
||||
Called after each turn. Should be non-blocking — queue for
|
||||
background processing if the backend has latency.
|
||||
|
||||
``messages`` is the OpenAI-style conversation message list as of the
|
||||
completed turn, including any assistant tool calls and tool results.
|
||||
Providers that do not need raw turn context can ignore it.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -178,7 +166,6 @@ class MemoryProvider(ABC):
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
rewound: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Called when the agent switches session_id mid-process.
|
||||
@@ -208,10 +195,6 @@ class MemoryProvider(ABC):
|
||||
(``_session_turns``, ``_turn_counter``, etc.) when this is
|
||||
set. ``False`` for ``/resume`` / ``/branch`` / compression
|
||||
where the logical conversation continues under the new id.
|
||||
rewound:
|
||||
``True`` if session_id is unchanged but the transcript was
|
||||
truncated; providers caching per-turn document state should
|
||||
invalidate.
|
||||
|
||||
Default is no-op for backward compatibility.
|
||||
"""
|
||||
|
||||
+3
-55
@@ -141,8 +141,6 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
|
||||
# substring of "anthropic/claude-sonnet-4.6").
|
||||
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
|
||||
"claude-opus-4-8": 1000000,
|
||||
"claude-opus-4.8": 1000000,
|
||||
"claude-opus-4-7": 1000000,
|
||||
"claude-opus-4.7": 1000000,
|
||||
"claude-opus-4-6": 1000000,
|
||||
@@ -200,12 +198,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"qwen3-coder-plus": 1000000, # 1M context
|
||||
"qwen3-coder": 262144, # 256K context
|
||||
"qwen": 131072,
|
||||
# MiniMax — M3 is 1M context (max output 512K); M2.x series is 204,800.
|
||||
# Keys use substring matching (longest-first), so "minimax-m3" wins over
|
||||
# the generic "minimax" catch-all for the M3 slug on every surface
|
||||
# (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
|
||||
# https://platform.minimax.io/docs/api-reference/text-chat-openai
|
||||
"minimax-m3": 1000000,
|
||||
# MiniMax — official docs: 204,800 context for all models
|
||||
# https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
||||
"minimax": 204800,
|
||||
# GLM
|
||||
"glm": 202752,
|
||||
@@ -917,33 +911,12 @@ def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def get_context_length_from_provider_error(
|
||||
error_msg: str,
|
||||
current_context_length: int,
|
||||
) -> Optional[int]:
|
||||
"""Return a provider-reported lower context limit, if one is present.
|
||||
|
||||
Context-overflow recovery must not invent a new model window size. Some
|
||||
providers only say that the input exceeds the context window without
|
||||
reporting the actual maximum. In that case callers should keep the
|
||||
configured context length and try compression only, rather than stepping
|
||||
down through guessed probe tiers (1M → 256K → 128K → ...).
|
||||
"""
|
||||
parsed_limit = parse_context_limit_from_error(error_msg)
|
||||
if parsed_limit is None:
|
||||
return None
|
||||
if parsed_limit < current_context_length:
|
||||
return parsed_limit
|
||||
return None
|
||||
|
||||
|
||||
def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
"""Detect an "output cap too large" error and return how many output tokens are available.
|
||||
|
||||
Background — two distinct context errors exist:
|
||||
1. "Prompt too long" — the INPUT itself exceeds the context window.
|
||||
Fix: compress history, and only reduce context_length if the
|
||||
provider explicitly reports the actual lower limit.
|
||||
Fix: compress history and/or halve context_length.
|
||||
2. "max_tokens too large" — input is fine, but input + requested_output > window.
|
||||
Fix: reduce max_tokens (the output cap) for this call.
|
||||
Do NOT touch context_length — the window hasn't shrunk.
|
||||
@@ -1128,18 +1101,6 @@ def _model_name_suggests_kimi(model: str) -> bool:
|
||||
return lower.startswith("kimi") or "moonshot" in lower
|
||||
|
||||
|
||||
def _model_name_suggests_minimax_m3(model: str) -> bool:
|
||||
"""Return True if the model name looks like MiniMax M3.
|
||||
|
||||
Catches ``MiniMax-M3``, ``minimax/minimax-m3``, and similar variants
|
||||
across surfaces (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
|
||||
Used as a guard against stale cache entries seeded by pre-catalog builds
|
||||
that resolved M3 via the generic ``minimax`` catch-all (204,800) before
|
||||
the ``minimax-m3`` (1M) entry existed in DEFAULT_CONTEXT_LENGTHS.
|
||||
"""
|
||||
return "minimax-m3" in model.lower()
|
||||
|
||||
|
||||
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Query a local server for the model's context length."""
|
||||
import httpx
|
||||
@@ -1551,19 +1512,6 @@ def get_model_context_length(
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Invalidate stale ≤204,800 cache entries for MiniMax-M3. Pre-catalog
|
||||
# builds resolved M3 via the generic ``minimax`` catch-all (204,800)
|
||||
# and persisted it before the ``minimax-m3`` (1M) entry existed; that
|
||||
# stale value would otherwise stick forever here at step 1. M3 is 1M,
|
||||
# so any sub-256K cached value for an M3 slug is a leftover — drop it
|
||||
# and fall through to the hardcoded default.
|
||||
elif cached <= 204_800 and _model_name_suggests_minimax_m3(model):
|
||||
logger.info(
|
||||
"Dropping stale MiniMax-M3 cache entry %s@%s -> %s (pre-catalog value); "
|
||||
"re-resolving via hardcoded defaults",
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Nous Portal: the portal /v1/models endpoint is authoritative.
|
||||
# Bypass the persistent cache so step 5b can always reconcile
|
||||
# against it — this corrects pre-fix entries seeded from the
|
||||
|
||||
@@ -15,6 +15,18 @@ 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. ``enum`` arrays on scalar-typed nodes may not contain ``null`` or empty
|
||||
strings. Strip those entries (drop the enum entirely if it becomes empty).
|
||||
4. ``$ref`` nodes may not carry sibling keywords. Moonshot expands the
|
||||
reference before validation and then rejects the node if sibling keys
|
||||
like ``description`` remain on the same node as ``$ref``. Strip every
|
||||
sibling from ``$ref`` nodes so only ``{"$ref": "..."}`` survives.
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
5. ``items`` may not be a tuple-style array (``items: [schemaA, schemaB]``
|
||||
for positional element schemas). Moonshot's schema engine requires a
|
||||
single object schema applied to every array element. Collapse tuple
|
||||
``items`` to the first element schema (or ``{}`` if the tuple is empty).
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
|
||||
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||||
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||||
@@ -66,6 +78,16 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
}
|
||||
elif key in _SCHEMA_LIST_KEYS and isinstance(value, list):
|
||||
repaired[key] = [_repair_schema(v, is_schema=True) for v in value]
|
||||
elif key == "items" and isinstance(value, list):
|
||||
# Rule 5: tuple-style ``items`` arrays (positional element
|
||||
# schemas) are not accepted by Moonshot. Collapse to the
|
||||
# first element schema if present, else to ``{}``. This
|
||||
# matches opencode's behaviour for moonshotai / kimi models.
|
||||
first = value[0] if value else {}
|
||||
if isinstance(first, dict):
|
||||
repaired[key] = _repair_schema(first, is_schema=True)
|
||||
else:
|
||||
repaired[key] = first
|
||||
elif key in _SCHEMA_NODE_KEYS:
|
||||
# items / not / additionalProperties: single nested schema.
|
||||
# additionalProperties can also be a bool — leave those alone.
|
||||
@@ -130,6 +152,15 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
else:
|
||||
repaired.pop("enum")
|
||||
|
||||
# Rule 4: $ref nodes must not have sibling keywords. Moonshot expands
|
||||
# the reference before validation and then rejects the node if siblings
|
||||
# like ``description`` / ``type`` / ``default`` appear alongside $ref.
|
||||
# The referenced definition still carries its own description on the
|
||||
# target node, which Moonshot accepts.
|
||||
# (Ported from anomalyco/opencode#24730.)
|
||||
if "$ref" in repaired:
|
||||
return {"$ref": repaired["$ref"]}
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
|
||||
+2
-59
@@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
@@ -14,7 +15,6 @@ from pathlib import Path
|
||||
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
|
||||
from typing import Optional
|
||||
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
from agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
@@ -236,11 +236,6 @@ KANBAN_GUIDANCE = (
|
||||
"- Do not shell out to `hermes kanban <verb>` for board operations. Use "
|
||||
"the `kanban_*` tools — they work across all terminal backends.\n"
|
||||
"- Do not complete a task you didn't actually finish. Block it.\n"
|
||||
"- Do not call `clarify` to ask questions. You are running headless — "
|
||||
"there is no live user to answer. The call will time out and the task "
|
||||
"will sit silently in `running` with no signal to the operator. Instead: "
|
||||
"`kanban_comment` the context, then `kanban_block(reason=...)` so the "
|
||||
"task surfaces on the board as needing input.\n"
|
||||
"- Do not assign follow-up work to yourself. Assign it to the right "
|
||||
"specialist profile.\n"
|
||||
"- Do not call `delegate_task` as a board substitute. `delegate_task` is "
|
||||
@@ -267,37 +262,6 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
# Add new patterns here when a model family needs explicit steering.
|
||||
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek")
|
||||
|
||||
# Universal "finish the job" guidance — applied to ALL models, not gated
|
||||
# by model family. Addresses two cross-model failure modes:
|
||||
# 1. Stopping after a stub: writing a tiny file or running one command
|
||||
# and then ending the turn with a description of the plan instead
|
||||
# of the finished artifact. (Observed on Opus during a real
|
||||
# Sarasota real-estate build task: 3 API calls, 85-byte file,
|
||||
# one terminal command, finish_reason=stop.)
|
||||
# 2. Fabricating output when a real path is blocked. When `pip` or a
|
||||
# tool fails, some models will synthesize plausible-looking results
|
||||
# (fake addresses, fake JSON, fake numbers) instead of reporting
|
||||
# the blocker. (Observed on DeepSeek v4-flash on the same task:
|
||||
# pushed through PEP-668 wall, then returned fabricated listings.)
|
||||
#
|
||||
# Short on purpose. This block is shipped to every user, every session,
|
||||
# in the cached system prompt — token cost is paid once at install and
|
||||
# then amortised across all sessions via prefix caching. Keep it tight.
|
||||
TASK_COMPLETION_GUIDANCE = (
|
||||
"# Finishing the job\n"
|
||||
"When the user asks you to build, run, or verify something, the deliverable is "
|
||||
"a working artifact backed by real tool output — not a description of one. "
|
||||
"Do not stop after writing a stub, a plan, or a single command. Keep working "
|
||||
"until you have actually exercised the code or produced the requested result, "
|
||||
"then report what real execution returned.\n"
|
||||
"If a tool, install, or network call fails and blocks the real path, say so "
|
||||
"directly and try an alternative (different package manager, different "
|
||||
"approach, ask the user). NEVER substitute plausible-looking fabricated "
|
||||
"output (made-up data, invented file contents, synthesised API responses) "
|
||||
"for results you couldn't actually produce. Reporting a blocker honestly "
|
||||
"is always better than inventing a result."
|
||||
)
|
||||
|
||||
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
||||
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
||||
# hallucinate instead of using tools, and declare "done" without verification.
|
||||
@@ -803,7 +767,7 @@ def build_environment_hints() -> str:
|
||||
|
||||
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
|
||||
try:
|
||||
host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
|
||||
host_lines.append(f"Current working directory: {os.getcwd()}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -849,27 +813,6 @@ def build_environment_hints() -> str:
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
|
||||
# Embedder-supplied environment description. Lets a host that wraps Hermes
|
||||
# (e.g. a sandbox runner / managed platform) explain the environment the
|
||||
# agent is running in — proxy, credential handling, mount layout — without
|
||||
# forking the identity slot (SOUL.md). Read once at prompt-build time, so
|
||||
# it's part of the stable, cache-safe system prompt. The env var is the
|
||||
# build-time/embedder mechanism (set in a container ENV); config.yaml
|
||||
# ``agent.environment_hint`` is the user-facing surface. Env var wins.
|
||||
extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip()
|
||||
if not extra:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
extra = str(
|
||||
(load_config().get("agent", {}) or {}).get("environment_hint", "")
|
||||
).strip()
|
||||
except Exception as e:
|
||||
logger.debug("Could not read agent.environment_hint from config: %s", e)
|
||||
if extra:
|
||||
hints.append(extra)
|
||||
|
||||
return "\n\n".join(hints)
|
||||
|
||||
|
||||
|
||||
+22
-9
@@ -150,6 +150,10 @@ _JWT_RE = re.compile(
|
||||
r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature
|
||||
)
|
||||
|
||||
# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678>
|
||||
# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts.
|
||||
_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>")
|
||||
|
||||
# E.164 phone numbers: +<country><number>, 7-15 digits
|
||||
# Negative lookahead prevents matching hex strings or identifiers
|
||||
_SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])")
|
||||
@@ -327,7 +331,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Set force=True for safety boundaries that must never return raw secrets
|
||||
regardless of the user's global logging redaction preference.
|
||||
|
||||
@@ -402,19 +406,28 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
if "eyJ" in text:
|
||||
text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text)
|
||||
|
||||
# NOTE: Web-URL redaction (query params + userinfo + HTTP access-log
|
||||
# request targets) is intentionally OFF. Many legitimate workflows pass
|
||||
# opaque tokens through query strings — magic-link checkouts, OAuth
|
||||
# callbacks the agent is meant to follow, pre-signed share URLs — and
|
||||
# blanket-redacting param values by name breaks those skills mid-flow.
|
||||
# Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still
|
||||
# caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords
|
||||
# are still caught by _DB_CONNSTR_RE.
|
||||
# URL userinfo (http(s)://user:pass@host) — redact for non-DB schemes.
|
||||
# DB schemes are handled above by _DB_CONNSTR_RE.
|
||||
if "://" in text:
|
||||
text = _redact_url_userinfo(text)
|
||||
|
||||
# URL query params containing opaque tokens (?access_token=…&code=…)
|
||||
if "?" in text:
|
||||
text = _redact_url_query_params(text)
|
||||
|
||||
# HTTP access logs can contain relative request targets with query params
|
||||
# and no URL scheme, e.g. `"POST /hook?password=... HTTP/1.1"`.
|
||||
if "?" in text and "=" in text and _has_http_method_substring(text):
|
||||
text = _redact_http_request_target_query_params(text)
|
||||
|
||||
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
|
||||
if "&" in text and "=" in text:
|
||||
text = _redact_form_body(text)
|
||||
|
||||
# Discord user/role mentions (<@snowflake_id>)
|
||||
if "<@" in text:
|
||||
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
|
||||
|
||||
# E.164 phone numbers (Signal, WhatsApp)
|
||||
if "+" in text:
|
||||
def _redact_phone(m):
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Single source of truth for the agent working directory.
|
||||
|
||||
`TERMINAL_CWD` is the runtime carrier for the configured working directory
|
||||
(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
|
||||
gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
relies on the launch dir. Reading it in one place keeps the system prompt, the
|
||||
tool surfaces, and context-file discovery agreeing on where the agent lives.
|
||||
|
||||
Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
|
||||
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_UNSET: Any = object()
|
||||
|
||||
_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)
|
||||
|
||||
|
||||
def set_session_cwd(cwd: str | None) -> Token:
|
||||
"""Pin the logical cwd for the current context."""
|
||||
return _SESSION_CWD.set((cwd or "").strip())
|
||||
|
||||
|
||||
def clear_session_cwd() -> None:
|
||||
_SESSION_CWD.set("")
|
||||
|
||||
|
||||
def _session_cwd_override() -> str:
|
||||
value = _SESSION_CWD.get()
|
||||
if value is _UNSET:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
p = Path(override).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py)
|
||||
# or, per session, the _SESSION_CWD contextvar above.
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
@@ -37,6 +37,7 @@ import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
@@ -258,7 +258,7 @@ def emit_stream_drop(
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
agent._buffer_status(
|
||||
agent._emit_status(
|
||||
f"⚠️ {provider} stream {kind} ({type(error).__name__}){_suffix} "
|
||||
f"— reconnecting, retry {attempt}/{max_attempts}"
|
||||
)
|
||||
|
||||
+7
-33
@@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import (
|
||||
@@ -36,11 +37,9 @@ from agent.prompt_builder import (
|
||||
PLATFORM_HINTS,
|
||||
SESSION_SEARCH_GUIDANCE,
|
||||
SKILLS_GUIDANCE,
|
||||
TASK_COMPLETION_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
|
||||
|
||||
def _ra():
|
||||
@@ -101,15 +100,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# Pointer to the hermes-agent skill + docs for user questions about Hermes itself.
|
||||
stable_parts.append(HERMES_AGENT_HELP_GUIDANCE)
|
||||
|
||||
# Universal task-completion / no-fabrication guidance. Applied to ALL
|
||||
# models regardless of tool_use_enforcement gating — the failure modes
|
||||
# this targets (stopping after a stub; fabricating output when a real
|
||||
# path is blocked) are not model-family specific. Gated only by
|
||||
# config.yaml ``agent.task_completion_guidance`` (default True) so
|
||||
# users who want a leaner prompt can turn it off.
|
||||
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
|
||||
stable_parts.append(TASK_COMPLETION_GUIDANCE)
|
||||
|
||||
# Tool-aware behavioral guidance: only inject when the tools are loaded
|
||||
tool_guidance = []
|
||||
if "memory" in agent.valid_tool_names:
|
||||
@@ -215,23 +205,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
if _env_hints:
|
||||
stable_parts.append(_env_hints)
|
||||
|
||||
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
|
||||
# something is non-default so the model can pick the right install
|
||||
# strategy without discovering by failure. Emits a single line; emits
|
||||
# NOTHING when the environment is clean (no token cost). Skipped
|
||||
# entirely for remote terminal backends (the host's Python state is
|
||||
# irrelevant when tools run inside docker/modal/ssh). Gated by
|
||||
# config.yaml ``agent.environment_probe`` (default True).
|
||||
if getattr(agent, "_environment_probe", True):
|
||||
try:
|
||||
from tools.env_probe import get_environment_probe_line
|
||||
_probe_line = get_environment_probe_line()
|
||||
if _probe_line:
|
||||
stable_parts.append(_probe_line)
|
||||
except Exception:
|
||||
# Probe failure must never block prompt build.
|
||||
pass
|
||||
|
||||
# Active-profile hint — names the Hermes profile the agent is running
|
||||
# under so it doesn't conflate ~/.hermes/skills/ (default profile) with
|
||||
# ~/.hermes/profiles/<active>/skills/ (this profile's). Deterministic
|
||||
@@ -288,12 +261,13 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
context_parts.append(system_message)
|
||||
|
||||
if not agent.skip_context_files:
|
||||
# Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
|
||||
# CLI), None lets build_context_files_prompt fall back to the launch
|
||||
# dir — the user's real cwd there, but the install dir for the gateway
|
||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||
# Use TERMINAL_CWD for context file discovery when set (gateway
|
||||
# mode). The gateway process runs from the hermes-agent install
|
||||
# dir, so os.getcwd() would pick up the repo's AGENTS.md and
|
||||
# other dev files — inflating token usage by ~10k for no benefit.
|
||||
_context_cwd = os.getenv("TERMINAL_CWD") or None
|
||||
context_files_prompt = _r.build_context_files_prompt(
|
||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
|
||||
cwd=_context_cwd, skip_soul=_soul_loaded)
|
||||
if context_files_prompt:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
|
||||
+106
-210
@@ -13,13 +13,14 @@ extracted functions reach back through the ``run_agent`` module via
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.display import (
|
||||
KawaiiSpinner,
|
||||
@@ -37,9 +38,12 @@ from agent.tool_dispatch_helpers import (
|
||||
make_tool_result_message,
|
||||
)
|
||||
from tools.terminal_tool import (
|
||||
_get_approval_callback,
|
||||
_get_sudo_password_callback,
|
||||
set_approval_callback as _set_approval_callback,
|
||||
set_sudo_password_callback as _set_sudo_password_callback,
|
||||
get_active_env,
|
||||
)
|
||||
from tools.thread_context import propagate_context_to_thread
|
||||
from tools.tool_result_storage import (
|
||||
maybe_persist_tool_result,
|
||||
enforce_turn_budget,
|
||||
@@ -58,55 +62,6 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _tool_search_scoped_names(agent) -> frozenset:
|
||||
"""Return the deferrable tool names the session may invoke via tool_call.
|
||||
|
||||
The Tool Search unwrap dispatches the underlying tool directly, bypassing
|
||||
the bridge branch (and its scope check) in
|
||||
``model_tools.handle_function_call``. To keep a restricted-toolset session
|
||||
(subagent, kanban worker, curated gateway session) from reaching tools it
|
||||
was never granted, the unwrap validates the underlying name against this
|
||||
set: the deferrable subset of the session's own enabled/disabled toolset
|
||||
scope.
|
||||
|
||||
Result is cached on the agent and refreshed when the tool registry's
|
||||
generation changes (e.g. an MCP server reconnects), so the common case is
|
||||
a dict lookup, not a full tool-defs rebuild on every tool call.
|
||||
"""
|
||||
try:
|
||||
import model_tools
|
||||
from tools import tool_search as _ts
|
||||
from tools.registry import registry as _registry
|
||||
except Exception:
|
||||
return frozenset()
|
||||
|
||||
enabled = getattr(agent, "enabled_toolsets", None)
|
||||
disabled = getattr(agent, "disabled_toolsets", None)
|
||||
cache_key = (
|
||||
getattr(_registry, "_generation", 0),
|
||||
frozenset(enabled) if enabled is not None else None,
|
||||
frozenset(disabled) if disabled is not None else None,
|
||||
)
|
||||
cached = getattr(agent, "_tool_search_scope_cache", None)
|
||||
if cached is not None and cached[0] == cache_key:
|
||||
return cached[1]
|
||||
try:
|
||||
scoped_defs = model_tools.get_tool_definitions(
|
||||
enabled_toolsets=enabled,
|
||||
disabled_toolsets=disabled,
|
||||
quiet_mode=True,
|
||||
skip_tool_search_assembly=True,
|
||||
) or []
|
||||
names = _ts.scoped_deferrable_names(scoped_defs)
|
||||
except Exception:
|
||||
names = frozenset()
|
||||
try:
|
||||
agent._tool_search_scope_cache = (cache_key, names)
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
"""Execute multiple tool calls concurrently using a thread pool.
|
||||
|
||||
@@ -145,89 +100,45 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# ── Tool Search unwrap ────────────────────────────────────────
|
||||
# When the model invokes the tool_call bridge, peel it open so
|
||||
# every downstream check (checkpointing, guardrails, plugin
|
||||
# pre-tool-call hooks, the display/activity feed, the post-call
|
||||
# callback) sees the underlying tool — not the bridge. This is
|
||||
# the OpenClaw lesson: hooks must observe the real tool name.
|
||||
#
|
||||
# The original tool_call entry on ``tool_call.function`` is left
|
||||
# untouched so the conversation transcript and the matching
|
||||
# tool_call_id are preserved exactly as the model emitted them.
|
||||
#
|
||||
# Scope gate: the unwrap dispatches the underlying tool directly
|
||||
# (bypassing the bridge branch in handle_function_call and its
|
||||
# scope check), so we enforce session toolset scope HERE. A tool
|
||||
# the session was not granted is rejected before any checkpoint,
|
||||
# hook, or dispatch fires.
|
||||
_ts_scope_block = None
|
||||
try:
|
||||
from tools import tool_search as _ts
|
||||
if function_name == _ts.TOOL_CALL_NAME:
|
||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = json.dumps({
|
||||
"error": (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint before destructive terminal commands
|
||||
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
cmd = function_args.get("command", "")
|
||||
if _is_destructive_command(cmd):
|
||||
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
cwd, f"before terminal: {cmd[:60]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Block evaluation (BEFORE checkpoint preflight) ───────────
|
||||
# We must know whether the tool will execute before touching
|
||||
# checkpoint state (dedup slot, real snapshots).
|
||||
block_result = None
|
||||
blocked_by_guardrail = False
|
||||
if _ts_scope_block is not None:
|
||||
# Out-of-scope tool_call: reject before hooks/guardrails/dispatch.
|
||||
block_result = _ts_scope_block
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
|
||||
if block_message is not None:
|
||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
|
||||
if block_message is not None:
|
||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
else:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
if not guardrail_decision.allows_execution:
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
|
||||
# ── Checkpoint preflight (only for tools that will execute) ──
|
||||
if block_result is None:
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint before destructive terminal commands
|
||||
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
cmd = function_args.get("command", "")
|
||||
if _is_destructive_command(cmd):
|
||||
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
cwd, f"before terminal: {cmd[:60]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
if not guardrail_decision.allows_execution:
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
|
||||
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
||||
|
||||
@@ -275,6 +186,14 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
agent._current_tool = tool_names_str
|
||||
agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}")
|
||||
|
||||
# Capture CLI callbacks from the agent thread so worker threads can
|
||||
# register them locally. Without this, _get_approval_callback() in
|
||||
# terminal_tool returns None in ThreadPoolExecutor workers, causing
|
||||
# the dangerous-command prompt to fall back to input() — which
|
||||
# deadlocks against prompt_toolkit's raw terminal mode (#13617).
|
||||
_parent_approval_cb = _get_approval_callback()
|
||||
_parent_sudo_cb = _get_sudo_password_callback()
|
||||
|
||||
def _run_tool(index, tool_call, function_name, function_args):
|
||||
"""Worker function executed in a thread."""
|
||||
# Register this worker tid so the agent can fan out an interrupt
|
||||
@@ -301,43 +220,54 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
set_activity_callback(agent._touch_activity)
|
||||
except Exception:
|
||||
pass
|
||||
# Approval/sudo callbacks (thread-local) and the agent turn's
|
||||
# ContextVars are propagated by propagate_context_to_thread() at the
|
||||
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
|
||||
start = time.time()
|
||||
try:
|
||||
# Propagate approval/sudo callbacks to this worker thread.
|
||||
# Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr).
|
||||
if _parent_approval_cb is not None:
|
||||
try:
|
||||
result = agent._invoke_tool(
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
tool_call.id,
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
)
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
duration = time.time() - start
|
||||
is_error, _ = _detect_tool_failure(function_name, result)
|
||||
if is_error:
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
else:
|
||||
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False)
|
||||
finally:
|
||||
# Tear down worker-tid tracking. Clear any interrupt bit we may
|
||||
# have set so the next task scheduled onto this recycled tid
|
||||
# starts with a clean slate. This MUST be in a finally block
|
||||
# because BaseException subclasses (CancelledError, KeyboardInterrupt)
|
||||
# bypass ``except Exception`` and would otherwise leak the tid
|
||||
# into _interrupted_threads, poisoning the recycled thread.
|
||||
with agent._tool_worker_threads_lock:
|
||||
agent._tool_worker_threads.discard(_worker_tid)
|
||||
try:
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
_set_approval_callback(_parent_approval_cb)
|
||||
except Exception:
|
||||
pass
|
||||
if _parent_sudo_cb is not None:
|
||||
try:
|
||||
_set_sudo_password_callback(_parent_sudo_cb)
|
||||
except Exception:
|
||||
pass
|
||||
start = time.time()
|
||||
try:
|
||||
result = agent._invoke_tool(
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
tool_call.id,
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
)
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
duration = time.time() - start
|
||||
is_error, _ = _detect_tool_failure(function_name, result)
|
||||
if is_error:
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
else:
|
||||
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False)
|
||||
# Tear down worker-tid tracking. Clear any interrupt bit we may
|
||||
# have set so the next task scheduled onto this recycled tid
|
||||
# starts with a clean slate.
|
||||
with agent._tool_worker_threads_lock:
|
||||
agent._tool_worker_threads.discard(_worker_tid)
|
||||
try:
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
except Exception:
|
||||
pass
|
||||
# Clear thread-local callbacks so a recycled worker thread
|
||||
# doesn't hold stale references to a disposed CLI instance.
|
||||
try:
|
||||
_set_approval_callback(None)
|
||||
_set_sudo_password_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start spinner for CLI mode (skip when TUI handles tool progress)
|
||||
spinner = None
|
||||
@@ -357,12 +287,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for i, tc, name, args in runnable_calls:
|
||||
# Propagate the agent turn's ContextVars (e.g.
|
||||
# _approval_session_key) AND thread-local approval/sudo
|
||||
# callbacks into the worker thread; clears callbacks on exit.
|
||||
f = executor.submit(
|
||||
propagate_context_to_thread(_run_tool), i, tc, name, args
|
||||
)
|
||||
# Propagate ContextVars (e.g. _approval_session_key); mirrors asyncio.to_thread.
|
||||
ctx = contextvars.copy_context()
|
||||
f = executor.submit(ctx.run, _run_tool, i, tc, name, args)
|
||||
futures.append(f)
|
||||
|
||||
# Wait for all to complete with periodic heartbeats so the
|
||||
@@ -570,38 +497,15 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# Tool Search unwrap — see execute_tool_calls_concurrent for full
|
||||
# rationale, including the scope gate (the unwrap dispatches the
|
||||
# underlying tool directly, so session toolset scope is enforced here).
|
||||
_ts_scope_block: Optional[str] = None
|
||||
try:
|
||||
from tools import tool_search as _ts
|
||||
if function_name == _ts.TOOL_CALL_NAME:
|
||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check plugin hooks for a block directive before executing.
|
||||
_block_msg: Optional[str] = None
|
||||
if _ts_scope_block is not None:
|
||||
_block_msg = _ts_scope_block
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_guardrail_block_decision: ToolGuardrailDecision | None = None
|
||||
if _block_msg is None:
|
||||
@@ -763,14 +667,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
elif function_name == "delegate_task":
|
||||
tasks_arg = function_args.get("tasks")
|
||||
if tasks_arg and isinstance(tasks_arg, list):
|
||||
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks · (/agents to monitor)"
|
||||
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks"
|
||||
else:
|
||||
goal_preview = (function_args.get("goal") or "")[:30]
|
||||
spinner_label = (
|
||||
f"🔀 {goal_preview} · (/agents to monitor)"
|
||||
if goal_preview
|
||||
else "🔀 delegating · (/agents to monitor)"
|
||||
)
|
||||
spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating"
|
||||
spinner = None
|
||||
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
@@ -852,8 +752,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
_spinner_result = function_result
|
||||
except Exception as tool_error:
|
||||
@@ -874,8 +772,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
except Exception as tool_error:
|
||||
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
|
||||
@@ -10,7 +10,7 @@ reasoning configuration, temperature handling, and extra_body assembly.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools
|
||||
@@ -476,17 +476,13 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
ephemeral = params.get("ephemeral_max_output_tokens")
|
||||
user_max = params.get("max_tokens")
|
||||
anthropic_max = params.get("anthropic_max_output")
|
||||
# Per-model default cap — profiles override get_max_tokens() when
|
||||
# they front several backends with different completion-token limits
|
||||
# (e.g. opencode-go: mimo-v2.5-pro = 131072).
|
||||
profile_max = profile.get_max_tokens(model)
|
||||
|
||||
if ephemeral is not None and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(ephemeral))
|
||||
elif user_max is not None and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(user_max))
|
||||
elif profile_max and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(profile_max))
|
||||
elif profile.default_max_tokens and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(profile.default_max_tokens))
|
||||
elif anthropic_max is not None:
|
||||
api_kwargs["max_tokens"] = anthropic_max
|
||||
|
||||
|
||||
@@ -128,14 +128,6 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||
|
||||
response_tools = _responses_tools(tools)
|
||||
# ``tools`` MUST be omitted entirely when there are no functions to
|
||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
||||
# without a None guard, so passing ``tools=None`` raises
|
||||
# ``TypeError: 'NoneType' object is not iterable`` before any HTTP
|
||||
# request is issued (openai==2.24.0). Reported for the
|
||||
# ``openai-codex`` / ``gpt-5.5`` combo on chatgpt.com/backend-api/codex
|
||||
# (#32892) when the agent runs without external tools registered.
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"instructions": instructions,
|
||||
@@ -145,10 +137,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
replay_encrypted_reasoning=replay_encrypted_reasoning,
|
||||
current_issuer_kind=issuer_kind,
|
||||
),
|
||||
"tools": response_tools,
|
||||
"store": False,
|
||||
}
|
||||
if response_tools:
|
||||
kwargs["tools"] = response_tools
|
||||
kwargs["tool_choice"] = "auto"
|
||||
kwargs["parallel_tool_calls"] = True
|
||||
|
||||
@@ -192,17 +184,6 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
if request_overrides:
|
||||
kwargs.update(request_overrides)
|
||||
|
||||
# xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not
|
||||
# supported: service_tier") — hit when ``/fast`` priority-processing
|
||||
# mode lingers from a prior model in the same session, or when a
|
||||
# user explicitly sets ``agent.service_tier`` in config.yaml. The
|
||||
# main-loop guard (``resolve_fast_mode_overrides`` only returns
|
||||
# ``service_tier`` for OpenAI fast-eligible models) doesn't cover
|
||||
# those leak paths, so strip defensively when targeting xAI. See
|
||||
# #28490 for the original report.
|
||||
if is_xai_responses:
|
||||
kwargs.pop("service_tier", None)
|
||||
|
||||
# Forward per-request timeout to the SDK so OpenAI/Anthropic clients
|
||||
# honor it. Without this, ``providers.<id>.request_timeout_seconds``
|
||||
# is silently dropped on the main agent Codex path while the
|
||||
|
||||
@@ -23,7 +23,7 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# Default minimum codex version we test against. The PR sets this from the
|
||||
# `codex --version` parsed at install time; bumping is a one-line change here.
|
||||
|
||||
@@ -31,7 +31,6 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.codex_responses_adapter import _format_responses_error
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.transports.codex_app_server import (
|
||||
CodexAppServerClient,
|
||||
@@ -582,7 +581,7 @@ class CodexAppServerSession:
|
||||
(note.get("params") or {}).get("turn") or {}
|
||||
).get("error")
|
||||
if err_obj:
|
||||
err_msg = _format_responses_error(err_obj, str(turn_status))
|
||||
err_msg = err_obj.get("message") or str(err_obj)
|
||||
# If the turn failed for an auth/refresh reason,
|
||||
# rewrite the error into a re-auth hint AND mark
|
||||
# the session for retirement.
|
||||
|
||||
@@ -83,34 +83,6 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
|
||||
# Official docs snapshot entries. Models whose published pricing and cache
|
||||
# semantics are stable enough to encode exactly.
|
||||
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
|
||||
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
|
||||
# model ID with 2x premium (vs the 6x premium on older Opus generations).
|
||||
# Source: https://openrouter.ai/anthropic/claude-opus-4.8
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-8",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-8-fast",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("10.00"),
|
||||
output_cost_per_million=Decimal("50.00"),
|
||||
cache_read_cost_per_million=Decimal("1.00"),
|
||||
cache_write_cost_per_million=Decimal("12.50"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openrouter.ai/anthropic/claude-opus-4.8-fast",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.7 ─────────────────────────────────────────────
|
||||
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
|
||||
# tokens for the same text).
|
||||
|
||||
@@ -61,14 +61,14 @@ from typing import Any, Dict, List
|
||||
|
||||
|
||||
class WebSearchProvider(abc.ABC):
|
||||
"""Abstract base class for a web search/extract backend.
|
||||
"""Abstract base class for a web search/extract/crawl backend.
|
||||
|
||||
Subclasses must implement :meth:`is_available` and at least one of
|
||||
:meth:`search` / :meth:`extract`. The :meth:`supports_search` /
|
||||
:meth:`supports_extract` capability flags let the registry route each
|
||||
tool call to the right provider, and let multi-capability providers
|
||||
(Firecrawl, Tavily, Exa, …) advertise multiple capabilities from a
|
||||
single class.
|
||||
:meth:`search` / :meth:`extract` / :meth:`crawl`. The
|
||||
:meth:`supports_search` / :meth:`supports_extract` / :meth:`supports_crawl`
|
||||
capability flags let the registry route each tool call to the right
|
||||
provider, and let multi-capability providers (Firecrawl, Tavily, Exa,
|
||||
…) advertise multiple capabilities from a single class.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -113,6 +113,22 @@ class WebSearchProvider(abc.ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def supports_crawl(self) -> bool:
|
||||
"""Return True if this provider implements :meth:`crawl`.
|
||||
|
||||
Crawl differs from extract in that the agent provides a *seed URL*
|
||||
and the provider walks linked pages on its own — useful for
|
||||
documentation sites where the agent doesn't know all relevant
|
||||
URLs upfront. Tavily is the only built-in backend that natively
|
||||
crawls today; Firecrawl provides a similar capability that we
|
||||
don't currently surface as a tool.
|
||||
|
||||
Providers that don't crawl should leave this as False; the
|
||||
dispatcher in :func:`tools.web_tools.web_crawl_tool` will fall
|
||||
back to its auxiliary-model summarization path.
|
||||
"""
|
||||
return False
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
|
||||
"""Execute a web search.
|
||||
|
||||
@@ -157,6 +173,26 @@ class WebSearchProvider(abc.ABC):
|
||||
f"{self.name} does not support extract (override supports_extract)"
|
||||
)
|
||||
|
||||
def crawl(self, url: str, **kwargs: Any) -> Any:
|
||||
"""Crawl a seed URL and return results.
|
||||
|
||||
Override when :meth:`supports_crawl` returns True. The default
|
||||
raises NotImplementedError; callers should gate on
|
||||
:meth:`supports_crawl` before calling.
|
||||
|
||||
Return shape: ``{"results": [{"url": str, "title": str,
|
||||
"content": str, ...}, ...]}`` matching what
|
||||
:func:`tools.web_tools.web_crawl_tool` post-processing expects.
|
||||
|
||||
Implementations MAY be ``async def``.
|
||||
|
||||
``kwargs`` may carry forward-compat fields (e.g. ``max_depth``,
|
||||
``include_domains``) — implementations should ignore unknown keys.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.name} does not support crawl (override supports_crawl)"
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
"""Return provider metadata for the ``hermes tools`` picker.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Active selection
|
||||
----------------
|
||||
The active provider is chosen by configuration with this precedence:
|
||||
|
||||
1. ``web.search_backend`` / ``web.extract_backend``
|
||||
1. ``web.search_backend`` / ``web.extract_backend`` / ``web.crawl_backend``
|
||||
(per-capability override).
|
||||
2. ``web.backend`` (shared fallback).
|
||||
3. If exactly one capability-eligible provider is registered AND available,
|
||||
@@ -24,10 +24,10 @@ The active provider is chosen by configuration with this precedence:
|
||||
5. Otherwise ``None`` — the tool surfaces a helpful error pointing at
|
||||
``hermes tools``.
|
||||
|
||||
The capability filter (``supports_search`` / ``supports_extract``) is
|
||||
applied at every step so a search-only provider (``brave-free``)
|
||||
configured as ``web.extract_backend`` correctly falls through to an
|
||||
extract-capable backend.
|
||||
The capability filter (``supports_search`` / ``supports_extract`` /
|
||||
``supports_crawl``) is applied at every step so a search-only provider
|
||||
(``brave-free``) configured as ``web.extract_backend`` correctly falls
|
||||
through to an extract-capable backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -131,7 +131,7 @@ _LEGACY_PREFERENCE = (
|
||||
|
||||
|
||||
def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]:
|
||||
"""Resolve the active provider for a capability ("search" | "extract").
|
||||
"""Resolve the active provider for a capability ("search" | "extract" | "crawl").
|
||||
|
||||
Resolution rules (in order):
|
||||
|
||||
@@ -168,6 +168,8 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc
|
||||
return bool(p.supports_search())
|
||||
if capability == "extract":
|
||||
return bool(p.supports_extract())
|
||||
if capability == "crawl":
|
||||
return bool(p.supports_crawl())
|
||||
return False
|
||||
|
||||
def _is_available_safe(p: WebSearchProvider) -> bool:
|
||||
@@ -239,6 +241,21 @@ def get_active_extract_provider() -> Optional[WebSearchProvider]:
|
||||
return _resolve(explicit, capability="extract")
|
||||
|
||||
|
||||
def get_active_crawl_provider() -> Optional[WebSearchProvider]:
|
||||
"""Resolve the currently-active web crawl provider.
|
||||
|
||||
Reads ``web.crawl_backend`` (preferred) or ``web.backend`` (shared
|
||||
fallback) from config.yaml; falls back per the module docstring.
|
||||
|
||||
Crawl is a niche capability — among built-in providers only Tavily and
|
||||
Firecrawl implement it. Callers should expect ``None`` and fall back to
|
||||
a different strategy (e.g. summarize-via-LLM) when neither is
|
||||
configured.
|
||||
"""
|
||||
explicit = _read_config_key("web", "crawl_backend") or _read_config_key("web", "backend")
|
||||
return _resolve(explicit, capability="crawl")
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the registry. **Test-only.**"""
|
||||
with _lock:
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Rust / Cargo
|
||||
/src-tauri/target/
|
||||
/src-tauri/Cargo.lock
|
||||
|
||||
# Vite / build output
|
||||
/dist/
|
||||
/dist-ssr/
|
||||
*.local
|
||||
|
||||
# TypeScript build info + tsc emit (we don't ship .js for the
|
||||
# vite.config.ts; Vite reads it directly via ts-node-style loader).
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
|
||||
# Tauri generated artifacts (regenerated on each build)
|
||||
/src-tauri/gen/schemas/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Internal placeholder (re-create if needed)
|
||||
.tauri-note
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "@hermes/bootstrap-installer",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5175",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@vscode/codicons": "^0.0.45",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-shimmer": "^0.4.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
[package]
|
||||
name = "hermes-bootstrap"
|
||||
version = "0.0.1"
|
||||
description = "Hermes Setup — signed installer that drives scripts/install.ps1"
|
||||
authors = ["Nous Research <info@nousresearch.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
# Rename the output binary so the distributed artifact is literally
|
||||
# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees
|
||||
# what we hand her, period. Tauri honors [[bin]] over [package].name
|
||||
# for the produced executable name.
|
||||
[[bin]]
|
||||
name = "Hermes-Setup"
|
||||
path = "src/main.rs"
|
||||
|
||||
# The library target name MUST match the `withGlobalTauri` binding name that
|
||||
# tauri.conf.json's `app.windows[].label` references. We don't ship a separate
|
||||
# lib for now; everything is in src/.
|
||||
[lib]
|
||||
name = "hermes_bootstrap_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Tauri runtime + plugins
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
# Async + IO
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP — rustls so we don't need OpenSSL on the build box
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
|
||||
# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the
|
||||
# webview console via Tauri's event channel.
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tracing-appender = "0.2"
|
||||
|
||||
# Paths + utils
|
||||
dirs = "5"
|
||||
which = "6"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
once_cell = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Process control on Windows (CREATE_NO_WINDOW etc.)
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Console",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit.
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -1,190 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// -----------------------------------------------------------------
|
||||
// Bake the install.ps1 pin into the binary at compile time.
|
||||
//
|
||||
// BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's
|
||||
// `option_env!()` macro to default the install-script reference.
|
||||
// Precedence (matches install.ps1's own arg precedence): commit > branch.
|
||||
//
|
||||
// The COMMIT pin is opt-in. By default a dev build pins ONLY the branch,
|
||||
// so the produced installer follows that branch's HEAD at install time
|
||||
// (tolerant of fast-forwards/new commits, and never references a SHA the
|
||||
// local checkout hasn't pushed). Set HERMES_BUILD_PIN_COMMIT to bake an
|
||||
// immutable commit pin for reproducible/release installers.
|
||||
//
|
||||
// Commit pin resolution:
|
||||
// - HERMES_BUILD_PIN_COMMIT, if set and non-empty. Accepts a SHA, tag,
|
||||
// or branch name; resolved to an immutable SHA via `git rev-parse`
|
||||
// when possible, else used verbatim if it already looks like a SHA.
|
||||
// - Otherwise: NO commit pin (branch-follow is the default).
|
||||
//
|
||||
// Branch pin resolution:
|
||||
// 1. HERMES_BUILD_PIN_BRANCH, if set and non-empty.
|
||||
// 2. `git rev-parse --abbrev-ref HEAD` of the checkout this build.rs
|
||||
// lives in — the current branch. (None on a detached HEAD.)
|
||||
// 3. Last-resort fallback handled below: if neither commit nor branch
|
||||
// resolves, warn — the binary needs a runtime arg or dev-repo env.
|
||||
//
|
||||
// Build script reruns on git HEAD change so a new commit triggers
|
||||
// a rebuild without `cargo clean`.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
let commit = resolve_commit_pin();
|
||||
let branch = resolve_branch_pin();
|
||||
|
||||
if let Some(c) = &commit {
|
||||
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: pinning to commit {}",
|
||||
short(c)
|
||||
);
|
||||
}
|
||||
if let Some(b) = &branch {
|
||||
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
|
||||
match &commit {
|
||||
Some(_) => println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"),
|
||||
None => println!(
|
||||
"cargo:warning=hermes-bootstrap: following branch {b} HEAD (no commit pin; \
|
||||
set HERMES_BUILD_PIN_COMMIT for an immutable pin)"
|
||||
),
|
||||
}
|
||||
}
|
||||
if commit.is_none() && branch.is_none() {
|
||||
// Fail loudly rather than silently produce a binary that errors
|
||||
// at runtime with "no install-script pin supplied". A build that
|
||||
// can't resolve a pin almost certainly indicates a misconfigured
|
||||
// build environment.
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args"
|
||||
);
|
||||
}
|
||||
|
||||
// Rerun build.rs when HEAD moves. With branch-follow as the default the
|
||||
// baked commit no longer changes per-commit, but a branch *switch* changes
|
||||
// the detected branch name, so we still re-trigger. When an explicit
|
||||
// HERMES_BUILD_PIN_COMMIT resolves a moving ref (tag/branch) to a SHA, a
|
||||
// HEAD move can also change that resolution. .git/HEAD changes on every
|
||||
// commit / branch switch / rebase.
|
||||
let git_dir = locate_git_dir();
|
||||
if let Some(gd) = &git_dir {
|
||||
println!("cargo:rerun-if-changed={}/HEAD", gd.display());
|
||||
// .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`);
|
||||
// also watch the ref itself so a new commit on the same branch
|
||||
// re-triggers.
|
||||
if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) {
|
||||
if let Some(rest) = head.trim().strip_prefix("ref: ") {
|
||||
println!("cargo:rerun-if-changed={}/{}", gd.display(), rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT");
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH");
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Tauri windows manifest. See hermes-setup.manifest for rationale —
|
||||
// declares level="asInvoker" so Windows's installer-detection
|
||||
// heuristic doesn't refuse to launch us without UAC elevation.
|
||||
// -----------------------------------------------------------------
|
||||
#[cfg(target_os = "windows")]
|
||||
let attrs = {
|
||||
let manifest = include_str!("hermes-setup.manifest");
|
||||
let win = tauri_build::WindowsAttributes::new().app_manifest(manifest);
|
||||
tauri_build::Attributes::new().windows_attributes(win)
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let attrs = tauri_build::Attributes::new();
|
||||
|
||||
tauri_build::try_build(attrs).expect("failed to run tauri-build");
|
||||
}
|
||||
|
||||
fn resolve_commit_pin() -> Option<String> {
|
||||
// Commit pinning is OPT-IN. Only bake a commit when the caller explicitly
|
||||
// asks for one via HERMES_BUILD_PIN_COMMIT. With no env var, we return
|
||||
// None and the installer follows the branch HEAD at install time.
|
||||
let requested = std::env::var("HERMES_BUILD_PIN_COMMIT").ok()?;
|
||||
let requested = requested.trim();
|
||||
if requested.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Resolve the request (which may be a SHA, tag, or branch name) to an
|
||||
// immutable commit SHA so the baked pin is reproducible. `^{commit}`
|
||||
// dereferences tags to the commit they point at.
|
||||
if let Ok(out) = Command::new("git")
|
||||
.args(["rev-parse", "--verify", &format!("{requested}^{{commit}}")])
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
if let Ok(s) = String::from_utf8(out.stdout) {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Couldn't resolve via git (e.g. building outside a checkout). Accept the
|
||||
// literal value only if it already looks like a SHA; otherwise fail loud
|
||||
// rather than bake an unresolvable ref into the binary.
|
||||
if is_sha(requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
panic!(
|
||||
"HERMES_BUILD_PIN_COMMIT={requested:?} could not be resolved to a commit \
|
||||
(git rev-parse failed and it is not a valid SHA)"
|
||||
);
|
||||
}
|
||||
|
||||
/// True if `s` looks like an abbreviated-or-full git SHA (7..=40 hex chars).
|
||||
fn is_sha(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn resolve_branch_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
// "HEAD" is what you get on a detached checkout — no meaningful branch
|
||||
// to pin to. The commit pin still applies; just don't emit a branch.
|
||||
if s.is_empty() || s == "HEAD" {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_git_dir() -> Option<std::path::PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::PathBuf::from(s))
|
||||
}
|
||||
|
||||
fn short(commit: &str) -> &str {
|
||||
if commit.len() >= 12 {
|
||||
&commit[..12]
|
||||
} else {
|
||||
commit
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:event:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"process:default",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
Hermes Setup application manifest.
|
||||
|
||||
The TL;DR: tell Windows we are NOT an installer in the classic "needs
|
||||
UAC elevation" sense, despite the product name. We provision into
|
||||
%LOCALAPPDATA%\hermes which is user-scoped and never touch HKLM or
|
||||
Program Files. install.ps1 runs as a child process and elevates
|
||||
itself only if a future stage explicitly needs HKLM access.
|
||||
|
||||
Without this manifest, the "Hermes Setup" productName embedded in
|
||||
the binary's resource trips Windows's installer-detection heuristic
|
||||
(https://learn.microsoft.com/en-us/windows/security/identity-protection/
|
||||
user-account-control/how-user-account-control-works#installer-detection)
|
||||
and CreateProcess fails with ERROR_ELEVATION_REQUIRED (740) when the
|
||||
user double-clicks. asInvoker disables that.
|
||||
-->
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="0.0.1.0"
|
||||
processorArchitecture="*"
|
||||
name="NousResearch.Hermes.Setup"
|
||||
type="win32"
|
||||
/>
|
||||
<description>Hermes Setup</description>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<!-- Tell Windows we know about all supported OSes (10 + 11) so it
|
||||
doesn't shim us into Vista-compat mode. -->
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||
<!-- Windows Vista -->
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!-- Per-monitor v2 DPI awareness so the installer doesn't go blurry
|
||||
on high-DPI displays when dragged between monitors. -->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<!-- Use the modern common controls (v6 themes). Without this, our
|
||||
file picker / shell dialogs fall back to 1990s-era visuals. -->
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 674 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 674 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 674 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,741 +0,0 @@
|
||||
//! Bootstrap orchestration.
|
||||
//!
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
|
||||
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
|
||||
//! over the Tauri `bootstrap` channel, writes a forensic log to
|
||||
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. `start_bootstrap` (Tauri command) → spawns the worker task.
|
||||
//! 2. Worker resolves install script (dev/cache/download).
|
||||
//! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event.
|
||||
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
||||
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::events::{BootstrapEvent, Manifest, StageState};
|
||||
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
|
||||
use crate::powershell::{self, StreamSink};
|
||||
use crate::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Frontend → Rust: kick off the install.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StartBootstrapArgs {
|
||||
/// Optional override for the commit pin. Defaults to the build-time
|
||||
/// pin baked in via `BUILD_PIN_COMMIT`.
|
||||
pub commit: Option<String>,
|
||||
/// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`.
|
||||
pub branch: Option<String>,
|
||||
/// Include Stage-Desktop (build apps/desktop) in the manifest. The
|
||||
/// signed bootstrap installer passes true; the deprecated Electron-side
|
||||
/// bootstrap-runner passes false to avoid building-while-running.
|
||||
#[serde(default = "default_true")]
|
||||
pub include_desktop: bool,
|
||||
/// Optional override for HERMES_HOME. Tests use this; production
|
||||
/// almost always falls back to the OS default.
|
||||
pub hermes_home: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BootstrapStatus {
|
||||
pub running: bool,
|
||||
pub completed: bool,
|
||||
pub install_root: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Handle stored in AppState while a bootstrap run is in flight. Carries
|
||||
/// the cancellation channel and the most recent terminal status so the
|
||||
/// frontend can re-query after a window refresh.
|
||||
pub struct BootstrapHandle {
|
||||
pub cancel_tx: mpsc::Sender<()>,
|
||||
pub started_at: Instant,
|
||||
pub status: BootstrapStatus,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_bootstrap(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
args: StartBootstrapArgs,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
if h.status.running {
|
||||
return Err("Bootstrap is already running".into());
|
||||
}
|
||||
}
|
||||
|
||||
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
||||
let handle = BootstrapHandle {
|
||||
cancel_tx,
|
||||
started_at: Instant::now(),
|
||||
status: BootstrapStatus {
|
||||
running: true,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
};
|
||||
*guard = Some(handle);
|
||||
drop(guard);
|
||||
|
||||
let app_for_task = app.clone();
|
||||
let state_for_task = state.inner().clone();
|
||||
let args_for_task = args;
|
||||
let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await;
|
||||
|
||||
// Reflect terminal state into AppState so get_bootstrap_status()
|
||||
// can serve it after the task exits.
|
||||
let mut guard = state_for_task.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_mut() {
|
||||
h.status.running = false;
|
||||
match &result {
|
||||
Ok(install_root) => {
|
||||
h.status.completed = true;
|
||||
h.status.install_root = Some(install_root.clone());
|
||||
h.status.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
h.status.completed = false;
|
||||
h.status.last_error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_bootstrap(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
let _ = h.cancel_tx.try_send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bootstrap_status(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
) -> Result<BootstrapStatus, String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
Ok(match guard.as_ref() {
|
||||
Some(h) => BootstrapStatus {
|
||||
running: h.status.running,
|
||||
completed: h.status.completed,
|
||||
install_root: h.status.install_root.clone(),
|
||||
last_error: h.status.last_error.clone(),
|
||||
},
|
||||
None => BootstrapStatus {
|
||||
running: false,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the locally-built Hermes desktop binary, then close the installer
|
||||
/// window. Caller resolves the binary path from `install_root`.
|
||||
///
|
||||
/// Returns Err with a human-readable message if the binary doesn't exist
|
||||
/// (e.g. when Stage-Desktop was skipped) so the frontend can present
|
||||
/// actionable failure UI rather than silently doing nothing.
|
||||
#[tauri::command]
|
||||
pub async fn launch_hermes_desktop(
|
||||
app: AppHandle,
|
||||
install_root: String,
|
||||
) -> Result<(), String> {
|
||||
let install_root = PathBuf::from(install_root);
|
||||
let exe_path = resolve_hermes_desktop_exe(&install_root).ok_or_else(|| {
|
||||
format!(
|
||||
"Couldn't find a built Hermes desktop at {}. The desktop build step \
|
||||
may have been skipped or failed. Run `hermes desktop` from a \
|
||||
terminal to build and launch it.",
|
||||
install_root.join("apps").join("desktop").join("release").display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(?exe_path, "launching Hermes desktop");
|
||||
|
||||
// Detach from us — the installer is about to exit.
|
||||
let mut cmd = tokio::process::Command::new(&exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(&install_root));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(|e| {
|
||||
format!(
|
||||
"failed to launch {}: {e}",
|
||||
exe_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Give Windows ~150ms to actually start the new process before we exit.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
|
||||
// Exit the installer cleanly. Tauri's process plugin gives us the
|
||||
// right hook regardless of platform.
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walks the well-known electron-builder unpacked-app paths under
|
||||
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
||||
/// <os>-unpacked/<exe>).
|
||||
pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let release_dir = install_root.join("apps").join("desktop").join("release");
|
||||
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
||||
&[
|
||||
("win-unpacked", "Hermes.exe"),
|
||||
("win-arm64-unpacked", "Hermes.exe"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[
|
||||
("mac/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
]
|
||||
} else {
|
||||
&[("linux-unpacked", "hermes")]
|
||||
};
|
||||
for (subdir, exe) in candidates {
|
||||
let p = release_dir.join(subdir).join(exe);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True when a prior install completed (bootstrap-complete marker present) AND a
|
||||
/// launchable desktop app exists on disk. Used by the installer's launcher fast
|
||||
/// path so a bare re-open just opens Hermes instead of re-running setup.
|
||||
pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
||||
install_root.join(".hermes-bootstrap-complete").exists()
|
||||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> {
|
||||
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
|
||||
})?;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.current_dir(exe.parent().unwrap_or(install_root));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the
|
||||
// installer exits, mirroring launch_hermes_desktop. Kept correct here
|
||||
// even though the only caller is macOS-gated today, so future reuse on
|
||||
// Windows doesn't reintroduce the relaunch race.
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_bootstrap(
|
||||
app: AppHandle,
|
||||
args: StartBootstrapArgs,
|
||||
cancel_rx_holder: Arc<Mutex<Option<mpsc::Receiver<()>>>>,
|
||||
) -> Result<String> {
|
||||
let kind = ScriptKind::for_current_os();
|
||||
|
||||
let pin = Pin {
|
||||
commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")),
|
||||
branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
?pin,
|
||||
kind = ?kind,
|
||||
include_desktop = args.include_desktop,
|
||||
"bootstrap starting"
|
||||
);
|
||||
|
||||
let app_for_log = app.clone();
|
||||
let emit_log = move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_log,
|
||||
BootstrapEvent::Log {
|
||||
stage: None,
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
// Bump to info-level so the line shows in bootstrap-installer.log
|
||||
// under the default INFO filter. Previously this was debug! which
|
||||
// got dropped on the floor, leaving us blind whenever install.ps1
|
||||
// failed — the log only had the "bootstrap starting" banner.
|
||||
tracing::info!(target: "bootstrap.log", "{line}");
|
||||
};
|
||||
|
||||
// 1. Resolve install.ps1
|
||||
let script = install_script::resolve(kind, &pin, &emit_log)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("resolve install script failed: {e:#}");
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let source_note = match &script.source {
|
||||
ScriptSource::DevCheckout => "dev checkout",
|
||||
ScriptSource::Bundled => "bundled",
|
||||
ScriptSource::Cached => "cached",
|
||||
ScriptSource::Downloaded => "downloaded",
|
||||
};
|
||||
emit_log(&format!(
|
||||
"[bootstrap] script {} via {}",
|
||||
script.path.display(),
|
||||
source_note
|
||||
));
|
||||
|
||||
// 2. Fetch manifest
|
||||
//
|
||||
// -IncludeDesktop MUST be passed to the manifest call too — install.ps1
|
||||
// gates the desktop stage inclusion on this flag, so without it here
|
||||
// the manifest comes back missing the desktop stage and we never run
|
||||
// it. The per-stage call below also passes -IncludeDesktop to keep
|
||||
// the contracts identical.
|
||||
let manifest_args = build_pin_args(&script);
|
||||
let mut manifest_args_full = vec!["-Manifest".to_string()];
|
||||
manifest_args_full.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
manifest_args_full.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
let manifest_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&manifest_args_full,
|
||||
args.hermes_home.as_deref(),
|
||||
None,
|
||||
Some("__manifest__".to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if manifest_result.exit_code != Some(0) {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest failed: exit {:?}\n{}",
|
||||
manifest_result.exit_code,
|
||||
manifest_result.stderr.trim()
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest produced no parseable JSON payload\n{}",
|
||||
truncate(&manifest_result.stdout, 4000)
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(err)
|
||||
})?;
|
||||
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages: manifest.stages.clone(),
|
||||
protocol_version: manifest.protocol_version,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Iterate stages.
|
||||
for stage in &manifest.stages {
|
||||
// Skip Stage-Desktop unless explicitly requested. install.ps1 may
|
||||
// or may not include it in the manifest depending on the flag we
|
||||
// pass, but if it slipped in, gate client-side too.
|
||||
if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(0),
|
||||
result: None,
|
||||
error: Some("skipped by include_desktop=false".into()),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if cancellation_signalled(&cancel_rx_holder).await {
|
||||
let err = "bootstrap cancelled by user".to_string();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Running,
|
||||
duration_ms: None,
|
||||
result: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut stage_args = vec![
|
||||
"-Stage".to_string(),
|
||||
stage.name.clone(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Json".to_string(),
|
||||
];
|
||||
stage_args.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
stage_args.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
// Each stage gets its own cancel receiver because tokio::select!
|
||||
// in run_script consumes it. Take/return through the Arc<Mutex>.
|
||||
let local_cancel_rx = cancel_rx_holder.lock().await.take();
|
||||
|
||||
let stage_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&stage_args,
|
||||
args.hermes_home.as_deref(),
|
||||
local_cancel_rx,
|
||||
Some(stage.name.clone()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
if stage_result.killed {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some("cancelled by user".into()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: "cancelled by user".into(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!("cancelled by user"));
|
||||
}
|
||||
|
||||
let result_frame = powershell::parse_stage_result(&stage_result.stdout);
|
||||
|
||||
match result_frame {
|
||||
None => {
|
||||
let err = format!(
|
||||
"install.ps1 -Stage {} produced no JSON result frame (exit={:?})",
|
||||
stage.name, stage_result.exit_code
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
Some(frame) if frame.ok && frame.skipped => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) if frame.ok => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Succeeded,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) => {
|
||||
let err = frame
|
||||
.reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code));
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve install_root. install.ps1 doesn't (yet) report this back
|
||||
// explicitly; we infer it from $HermesHome which Stage-Repository clones
|
||||
// the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants.
|
||||
let hermes_home = args
|
||||
.hermes_home
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
|
||||
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
|
||||
|
||||
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
|
||||
// re-invoke us with `--update` and shortcuts have a stable target. This is
|
||||
// a one-shot install concern; an `--update` re-invocation no-ops because
|
||||
// we're already running from that path. Best-effort — a failure here must
|
||||
// not fail an otherwise-successful install.
|
||||
if let Err(err) = crate::paths::copy_self_to_hermes_home() {
|
||||
tracing::warn!(?err, "failed to copy installer into HERMES_HOME (non-fatal)");
|
||||
emit_log(&format!(
|
||||
"[bootstrap] warning: could not stage updater binary: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(install_root.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
async fn cancellation_signalled(holder: &Arc<Mutex<Option<mpsc::Receiver<()>>>>) -> bool {
|
||||
let mut guard = holder.lock().await;
|
||||
if let Some(rx) = guard.as_mut() {
|
||||
rx.try_recv().is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_install_script(
|
||||
app: &AppHandle,
|
||||
script_path: &std::path::Path,
|
||||
args: &[String],
|
||||
hermes_home_override: Option<&str>,
|
||||
cancel_rx: Option<mpsc::Receiver<()>>,
|
||||
stage_name: Option<String>,
|
||||
) -> Result<powershell::ScriptResult> {
|
||||
let app_for_stdout = app.clone();
|
||||
let stage_for_stdout = stage_name.clone();
|
||||
let app_for_stderr = app.clone();
|
||||
let stage_for_stderr = stage_name.clone();
|
||||
let stage_for_stdout_log = stage_name.clone();
|
||||
let stage_for_stderr_log = stage_name.clone();
|
||||
|
||||
let sink = StreamSink {
|
||||
on_stdout_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stdout,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stdout.clone(),
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
// Tee to the rolling installer log so we have a persistent
|
||||
// record of every install.ps1 line. Without this, the only
|
||||
// log evidence of a failure was the Tauri event stream —
|
||||
// which gets discarded the moment the failure route mounts.
|
||||
match &stage_for_stdout_log {
|
||||
Some(name) => {
|
||||
tracing::info!(target: "bootstrap.log", stage = %name, "{line}")
|
||||
}
|
||||
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
||||
}
|
||||
}),
|
||||
on_stderr_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stderr,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stderr.clone(),
|
||||
line: format!("stderr: {line}"),
|
||||
},
|
||||
);
|
||||
// stderr-level lines get warn! so they're visually distinct
|
||||
// when scrolling through the log later.
|
||||
match &stage_for_stderr_log {
|
||||
Some(name) => {
|
||||
tracing::warn!(target: "bootstrap.log", stage = %name, "stderr: {line}")
|
||||
}
|
||||
None => tracing::warn!(target: "bootstrap.log", "stderr: {line}"),
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(?e, "install script invocation failed");
|
||||
anyhow!("install script invocation failed: {e:#}")
|
||||
})
|
||||
}
|
||||
|
||||
fn build_pin_args(script: &install_script::ResolvedScript) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = &script.commit {
|
||||
out.push("-Commit".to_string());
|
||||
out.push(c.clone());
|
||||
}
|
||||
if let Some(b) = &script.branch {
|
||||
out.push("-Branch".to_string());
|
||||
out.push(b.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_event(app: &AppHandle, event: BootstrapEvent) {
|
||||
// Tee important state transitions to the rolling installer log so
|
||||
// bootstrap-installer.log isn't just "starting" + final summary.
|
||||
// Log lines (the noisy stuff) handle their own tracing in
|
||||
// run_install_script's sink; here we cover the lifecycle frames.
|
||||
match &event {
|
||||
BootstrapEvent::Manifest { stages, .. } => {
|
||||
tracing::info!(
|
||||
stage_count = stages.len(),
|
||||
names = ?stages.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
|
||||
"manifest received"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Stage {
|
||||
name,
|
||||
state,
|
||||
duration_ms,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
tracing::info!(
|
||||
stage = %name,
|
||||
?state,
|
||||
duration_ms = ?duration_ms,
|
||||
error = ?error,
|
||||
"stage transition"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Complete { install_root, .. } => {
|
||||
tracing::info!(install_root = %install_root, "bootstrap complete");
|
||||
}
|
||||
BootstrapEvent::Failed { stage, error } => {
|
||||
tracing::error!(stage = ?stage, error = %error, "bootstrap FAILED");
|
||||
}
|
||||
BootstrapEvent::Log { .. } => {
|
||||
// Log lines are teed via the sink callbacks in
|
||||
// run_install_script — don't double-emit here.
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
||||
tracing::warn!(?e, "failed to emit bootstrap event");
|
||||
}
|
||||
}
|
||||
|
||||
fn option_env_string(key: &str) -> Option<String> {
|
||||
// option_env! only accepts literals, so we hardcode the known keys.
|
||||
let val = match key {
|
||||
"BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
|
||||
"BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max])
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//! Event types streamed from Rust → React.
|
||||
//!
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
|
||||
//! 1:1 so the React installer code can be roughly identical to the Electron
|
||||
//! install-overlay we'll replace.
|
||||
//!
|
||||
//! The Tauri event channel name is `"bootstrap"` for all of these — the
|
||||
//! `type` discriminator on each payload is how the frontend routes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stage definition as reported by `install.ps1 -Manifest`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageInfo {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
/// `needs_user_input=true` stages run with -NonInteractive and emit
|
||||
/// skipped=true; the post-install wizard takes over for those.
|
||||
#[serde(rename = "needs_user_input", alias = "needsUserInput")]
|
||||
pub needs_user_input: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocol_version", alias = "protocolVersion", default)]
|
||||
pub protocol_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageResultPayload {
|
||||
pub stage: String,
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub skipped: bool,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// install.ps1 may attach stage-specific structured data here.
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Run-state for a single stage as we transition through it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StageState {
|
||||
Running,
|
||||
Succeeded,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The single event channel `bootstrap` emits these. `type` discriminates.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum BootstrapEvent {
|
||||
/// Sent once at the start with the full stage list.
|
||||
Manifest {
|
||||
stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocolVersion")]
|
||||
protocol_version: Option<u32>,
|
||||
},
|
||||
/// Stage state transition. `result` populated only on terminal states.
|
||||
Stage {
|
||||
name: String,
|
||||
state: StageState,
|
||||
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<StageResultPayload>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Raw stdout/stderr line from install.ps1 (or our wrapper).
|
||||
Log {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
line: String,
|
||||
},
|
||||
/// Sent once when all stages complete successfully.
|
||||
Complete {
|
||||
#[serde(rename = "installRoot")]
|
||||
install_root: String,
|
||||
marker: Option<serde_json::Value>,
|
||||
},
|
||||
/// Sent once if the run aborts.
|
||||
Failed {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BootstrapEvent {
|
||||
/// Tauri event name. Single channel for all bootstrap events; the
|
||||
/// `type` tag tells the renderer how to interpret the payload.
|
||||
pub const CHANNEL: &'static str = "bootstrap";
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
//! Resolves and downloads `scripts/install.ps1` (and `install.sh`).
|
||||
//!
|
||||
//! Resolution order:
|
||||
//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT
|
||||
//! env var. Lets devs iterate without re-publishing the script.
|
||||
//! 2. Bundled fallback: if the installer was bundled with a script (e.g.
|
||||
//! tauri's `resource` mechanism), serve from there. Not used today.
|
||||
//! 3. Network: download from GitHub raw at a pinned commit or branch.
|
||||
//! Commit pins are immutable; branch pins are HEAD-tracking.
|
||||
//!
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
|
||||
//! but the dev-checkout resolution is driven by an env var rather than the
|
||||
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
|
||||
//! to live OUTSIDE any repo checkout.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// Identity of the install.ps1 we'll execute. Used by both the manifest
|
||||
/// fetch and the per-stage runs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedScript {
|
||||
pub path: PathBuf,
|
||||
pub source: ScriptSource,
|
||||
/// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is
|
||||
/// what makes the repo stage clone the exact tested SHA.
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScriptSource {
|
||||
DevCheckout,
|
||||
Bundled,
|
||||
Cached,
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
/// What flavor of script (Windows .ps1 vs Unix .sh).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScriptKind {
|
||||
Ps1,
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl ScriptKind {
|
||||
pub fn for_current_os() -> Self {
|
||||
if cfg!(target_os = "windows") {
|
||||
Self::Ps1
|
||||
} else {
|
||||
Self::Sh
|
||||
}
|
||||
}
|
||||
|
||||
fn filename(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ps1 => "install.ps1",
|
||||
Self::Sh => "install.sh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
|
||||
fn is_valid_commit(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Resolves the install script to use for this run.
|
||||
///
|
||||
/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
|
||||
/// constant (compiled into the installer) or a runtime override.
|
||||
pub async fn resolve(
|
||||
kind: ScriptKind,
|
||||
pin: &Pin,
|
||||
emit_log: &impl Fn(&str),
|
||||
) -> Result<ResolvedScript> {
|
||||
// 1. Dev shortcut.
|
||||
if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") {
|
||||
let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename());
|
||||
if candidate.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] dev mode — using local {} at {}",
|
||||
kind.filename(),
|
||||
candidate.display()
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: candidate,
|
||||
source: ScriptSource::DevCheckout,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. (Not implemented) bundled fallback.
|
||||
|
||||
// 3. Network. Pin must be a real commit or a branch ref.
|
||||
let commit_or_ref = match (&pin.commit, &pin.branch) {
|
||||
(Some(c), _) if is_valid_commit(c) => c.clone(),
|
||||
(_, Some(b)) if !b.trim().is_empty() => b.clone(),
|
||||
(Some(other), _) => {
|
||||
return Err(anyhow!(
|
||||
"install script pin commit `{other}` is not a valid git SHA"
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"no install-script pin supplied — installer cannot resolve a script source"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let cached = cached_path(kind, &commit_or_ref);
|
||||
if cached.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] using cached {} for {}",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
emit_log(&format!(
|
||||
"[bootstrap] downloading {} for {} from GitHub",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
|
||||
download(kind, &commit_or_ref, &cached).await?;
|
||||
|
||||
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
|
||||
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Downloaded,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Pin {
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf {
|
||||
let safe = sanitize_ref(commit_or_ref);
|
||||
let filename = match kind {
|
||||
ScriptKind::Ps1 => format!("install-{safe}.ps1"),
|
||||
ScriptKind::Sh => format!("install-{safe}.sh"),
|
||||
};
|
||||
paths::bootstrap_cache_dir().join(filename)
|
||||
}
|
||||
|
||||
/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can
|
||||
/// contain `/`, dots, etc.; we want a flat filename.
|
||||
fn sanitize_ref(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_ref(s: &str) -> &str {
|
||||
if is_valid_commit(s) && s.len() >= 12 {
|
||||
&s[..12]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
|
||||
/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
|
||||
async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}",
|
||||
commit_or_ref,
|
||||
kind.filename()
|
||||
);
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("creating bootstrap-cache parent dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let tmp_path = dest_path.with_extension({
|
||||
let ext = dest_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("tmp");
|
||||
format!("{ext}.tmp")
|
||||
});
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("User-Agent", "hermes-setup/0.0.1")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to download {}: HTTP {} from {}",
|
||||
kind.filename(),
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("reading body of {url}"))?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.with_context(|| format!("creating temp file {}", tmp_path.display()))?;
|
||||
file.write_all(&bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing temp file {}", tmp_path.display()))?;
|
||||
file.flush().await.context("flushing temp file")?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, dest_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} → {}",
|
||||
tmp_path.display(),
|
||||
dest_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_valid_commit_accepts_short_and_full_shas() {
|
||||
assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
|
||||
assert!(is_valid_commit("02d2698"));
|
||||
assert!(!is_valid_commit("02d269"));
|
||||
assert!(!is_valid_commit("not-a-sha"));
|
||||
assert!(!is_valid_commit(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ref_replaces_slashes() {
|
||||
assert_eq!(sanitize_ref("bb/gui"), "bb_gui");
|
||||
assert_eq!(sanitize_ref("main"), "main");
|
||||
assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
//! Hermes Setup — Tauri entrypoint.
|
||||
//!
|
||||
//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/).
|
||||
//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri
|
||||
//! commands registered at the bottom of `run()`.
|
||||
//!
|
||||
//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not
|
||||
//! here — a crate-level attribute on a lib doesn't propagate to the linker
|
||||
//! flags of the executable that consumes it.
|
||||
|
||||
mod bootstrap;
|
||||
mod events;
|
||||
mod install_script;
|
||||
mod powershell;
|
||||
mod paths;
|
||||
mod update;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// How the installer was invoked. Resolved once from the process args in
|
||||
/// `run()` and exposed to the frontend via `get_mode` so it can route to the
|
||||
/// install flow (first-run onboarding) or the update flow (driven by the
|
||||
/// desktop app handing off via `Hermes-Setup.exe --update`).
|
||||
///
|
||||
/// Bare launch (double-click, first-run) => Install.
|
||||
/// `--update` (spawned by the desktop's "Update" button) => Update.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppMode {
|
||||
Install,
|
||||
Update,
|
||||
}
|
||||
|
||||
impl AppMode {
|
||||
/// Resolve the mode from an argument iterator. Anything containing the
|
||||
/// `--update` flag selects Update; otherwise Install. Kept arg-iterator
|
||||
/// generic (not reading `std::env` directly) so it's unit-testable.
|
||||
pub fn from_args<I, S>(args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
for a in args {
|
||||
if a.as_ref() == "--update" {
|
||||
return AppMode::Update;
|
||||
}
|
||||
}
|
||||
AppMode::Install
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the args request a forced installer UI (repair/reinstall)
|
||||
/// via `--reinstall` or `--repair`, which overrides the macOS launcher
|
||||
/// fast-path so a broken install can be repaired. Arg-iterator generic so it's
|
||||
/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection:
|
||||
/// these flags never flip Install<->Update.
|
||||
pub fn force_setup_from_args<I, S>(args: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
args.into_iter()
|
||||
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
/// of these per window. `Arc<Mutex<...>>` lets command handlers grab it
|
||||
/// without lifetime gymnastics.
|
||||
pub struct AppState {
|
||||
pub bootstrap: Mutex<Option<bootstrap::BootstrapHandle>>,
|
||||
/// How this process was launched (install vs update). Immutable for the
|
||||
/// lifetime of the process; read by the `get_mode` command.
|
||||
pub mode: AppMode,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(mode: AppMode) -> Self {
|
||||
Self {
|
||||
bootstrap: Mutex::new(None),
|
||||
mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend → Rust: which flow should the UI render?
|
||||
#[tauri::command]
|
||||
fn get_mode(state: tauri::State<'_, Arc<AppState>>) -> AppMode {
|
||||
state.mode
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Tracing → bootstrap-installer.log under HERMES_HOME/logs/ so install
|
||||
// failures leave a trail for support. Console output also goes here in
|
||||
// debug builds.
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
let mode = AppMode::from_args(std::env::args().skip(1));
|
||||
// Escape hatch: `--reinstall`/`--repair` forces the installer UI even when
|
||||
// Hermes is already installed, so users can re-run setup to repair a broken
|
||||
// install instead of the launcher fast path silently relaunching the app.
|
||||
let force_setup = force_setup_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, "Hermes installer starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::new(mode)))
|
||||
.setup(move |app| {
|
||||
use tauri::Manager;
|
||||
// Launcher fast path (macOS only): a bare ("Install") launch when
|
||||
// Hermes is already installed should NOT show the installer or
|
||||
// rebuild — it should just open the app, so the /Applications
|
||||
// "Hermes" doubles as a normal launcher (first run installs, every
|
||||
// later run launches instantly). The window is kept hidden until
|
||||
// here via `"visible": false` so this path never flashes a window.
|
||||
//
|
||||
// Gated to macOS deliberately: on Windows/Linux the installer keeps
|
||||
// its existing behavior (Windows users relaunch via the Start
|
||||
// Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a
|
||||
// reliable detached relaunch there needs the DETACHED_PROCESS +
|
||||
// startup-grace handling used by launch_hermes_desktop — out of
|
||||
// scope here). So this is a pure no-op on non-macOS.
|
||||
//
|
||||
// `--reinstall`/`--repair` opts out so a broken install can be
|
||||
// repaired by re-running setup instead of launching the bad app.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
|
||||
let install_root = paths::hermes_home().join("hermes-agent");
|
||||
if bootstrap::hermes_is_installed(&install_root) {
|
||||
match bootstrap::spawn_installed_desktop(&install_root) {
|
||||
Ok(()) => {
|
||||
// Brief grace so the spawned app is registered
|
||||
// before we exit (mirrors launch_hermes_desktop).
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tracing::info!(
|
||||
"hermes already installed — relaunched desktop; exiting installer"
|
||||
);
|
||||
app.handle().exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"relaunch of installed desktop failed; showing installer UI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First run / repair install, or Update mode: reveal the UI.
|
||||
match app.get_webview_window("main") {
|
||||
Some(win) => {
|
||||
if let Err(err) = win.show() {
|
||||
tracing::error!(?err, "failed to show main installer window");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("main installer window not found; installer UI will not appear");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Mode (install vs update)
|
||||
get_mode,
|
||||
// Bootstrap lifecycle
|
||||
bootstrap::start_bootstrap,
|
||||
bootstrap::cancel_bootstrap,
|
||||
bootstrap::get_bootstrap_status,
|
||||
// Update lifecycle
|
||||
update::start_update,
|
||||
// Hand-off
|
||||
bootstrap::launch_hermes_desktop,
|
||||
// Diagnostics
|
||||
paths::get_log_path,
|
||||
paths::get_hermes_home,
|
||||
paths::open_log_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Hermes Setup");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{force_setup_from_args, AppMode};
|
||||
|
||||
#[test]
|
||||
fn bare_args_are_install() {
|
||||
assert_eq!(AppMode::from_args(Vec::<String>::new()), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--foo", "bar"]), AppMode::Install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_flag_selects_update() {
|
||||
assert_eq!(AppMode::from_args(["--update"]), AppMode::Update);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--something", "--update", "--else"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_and_repair_flags_force_setup() {
|
||||
assert!(force_setup_from_args(["--reinstall"]));
|
||||
assert!(force_setup_from_args(["--repair"]));
|
||||
assert!(force_setup_from_args(["--foo", "--repair", "--bar"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_or_unrelated_args_do_not_force_setup() {
|
||||
assert!(!force_setup_from_args(Vec::<String>::new()));
|
||||
assert!(!force_setup_from_args(["--foo", "bar"]));
|
||||
// --update must not be mistaken for a force-setup flag.
|
||||
assert!(!force_setup_from_args(["--update"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_setup_flags_do_not_affect_mode_selection() {
|
||||
// The repair flags must never flip Install<->Update.
|
||||
assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--update", "--reinstall"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Hermes Setup — process entrypoint. All logic lives in lib.rs so it can
|
||||
// be unit-tested as a library; this file just calls into it.
|
||||
//
|
||||
// The windows_subsystem attribute MUST live here on the binary crate
|
||||
// (not lib.rs) — placing it on the lib was the bug that left a stray
|
||||
// cmd window behind Hermes-Setup.exe on release builds.
|
||||
//
|
||||
// `windows_subsystem = "windows"` strips the console allocation that
|
||||
// the default `windows_subsystem = "console"` would do, so double-clicking
|
||||
// the .exe gives you ONLY the Tauri window.
|
||||
//
|
||||
// debug_assertions guard: dev builds keep the console so tracing output
|
||||
// is visible during `cargo tauri dev`.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermes_bootstrap_lib::run()
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
//! Filesystem paths + logging setup.
|
||||
//!
|
||||
//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI:
|
||||
//! Windows: %LOCALAPPDATA%\hermes
|
||||
//! macOS: ~/.hermes
|
||||
//! Linux: ~/.hermes (override via $HERMES_HOME)
|
||||
//!
|
||||
//! NOTE (macOS): Python's get_hermes_home(), scripts/install.sh, and the
|
||||
//! Electron desktop's resolveHermesHome() ALL use ~/.hermes on macOS — there
|
||||
//! is no ~/Library/Application Support branch anywhere else. An earlier
|
||||
//! version of this file used Application Support, which drifted from every
|
||||
//! other component: the installer wrote the install to one dir and the
|
||||
//! desktop looked for it in another, so first launch never found the backend.
|
||||
//!
|
||||
//! IMPORTANT: this must match exactly. Drift here means install.ps1
|
||||
//! writes to one place and the installer reads from another, breaking
|
||||
//! the bootstrap-complete check.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
|
||||
pub fn hermes_home() -> PathBuf {
|
||||
if let Ok(override_path) = std::env::var("HERMES_HOME") {
|
||||
if !override_path.trim().is_empty() {
|
||||
return PathBuf::from(override_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// %LOCALAPPDATA%\hermes — matches scripts/install.ps1's $HermesHome.
|
||||
if let Some(local_app_data) = dirs::data_local_dir() {
|
||||
return local_app_data.join("hermes");
|
||||
}
|
||||
}
|
||||
|
||||
// macOS + Linux + fallback: ~/.hermes (matches Python get_hermes_home(),
|
||||
// install.sh, and the Electron desktop's resolveHermesHome()).
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".hermes");
|
||||
}
|
||||
|
||||
// Last resort — current dir, almost certainly wrong but at least
|
||||
// doesn't panic.
|
||||
PathBuf::from(".hermes")
|
||||
}
|
||||
|
||||
pub fn log_dir() -> PathBuf {
|
||||
hermes_home().join("logs")
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
log_dir().join("bootstrap-installer.log")
|
||||
}
|
||||
|
||||
pub fn bootstrap_cache_dir() -> PathBuf {
|
||||
hermes_home().join("bootstrap-cache")
|
||||
}
|
||||
|
||||
/// Stable location the installer copies itself to after a successful install.
|
||||
/// The desktop app re-invokes this with `--update`, and the start-menu /
|
||||
/// desktop shortcuts can point users back to it. Lives directly under
|
||||
/// HERMES_HOME so it survives repo checkout deletion (unlike anything under
|
||||
/// hermes-agent/).
|
||||
///
|
||||
/// On Windows this is `%LOCALAPPDATA%\hermes\hermes-setup.exe`; on other
|
||||
/// platforms the extension differs but the directory is the same.
|
||||
pub fn installer_dest() -> PathBuf {
|
||||
let name = if cfg!(target_os = "windows") {
|
||||
"hermes-setup.exe"
|
||||
} else {
|
||||
"hermes-setup"
|
||||
};
|
||||
hermes_home().join(name)
|
||||
}
|
||||
|
||||
/// Copy the currently-running installer binary to `installer_dest()` so it's
|
||||
/// available for future `--update` runs and shortcut launches.
|
||||
///
|
||||
/// No-ops (returns Ok) when the running exe is ALREADY the destination — which
|
||||
/// is exactly the case during an `--update` run (the desktop launched us FROM
|
||||
/// that path), where copying onto ourselves would be a Windows sharing
|
||||
/// violation. Best-effort: a failure here must not fail the install, so the
|
||||
/// caller logs and continues.
|
||||
pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
|
||||
let src = std::env::current_exe()?;
|
||||
let dest = installer_dest();
|
||||
|
||||
// Skip if we're already running from the destination (update re-invocation
|
||||
// or a prior copy). canonicalize both so symlinks / 8.3 short paths / case
|
||||
// differences don't trick us into a self-copy.
|
||||
let same = match (src.canonicalize(), dest.canonicalize()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => src == dest,
|
||||
};
|
||||
if same {
|
||||
tracing::info!(?dest, "installer already at destination; skipping self-copy");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &dest)?;
|
||||
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.cjs:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
pub fn likely_bootstrap_marker(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".hermes-bootstrap-complete")
|
||||
}
|
||||
|
||||
/// Initializes tracing to bootstrap-installer.log under HERMES_HOME/logs/.
|
||||
/// Returns a guard that flushes the appender on drop — keep it alive for
|
||||
/// the lifetime of the process.
|
||||
pub fn init_logging() -> Option<WorkerGuard> {
|
||||
let dir = log_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
// No log dir → log to stderr only. Don't panic; the installer
|
||||
// should still be usable on an exotic filesystem.
|
||||
eprintln!("[hermes-setup] could not create log dir {dir:?}: {err}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_appender = tracing_appender::rolling::never(&dir, "bootstrap-installer.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_env("HERMES_BOOTSTRAP_LOG")
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.init();
|
||||
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_path() -> String {
|
||||
log_path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_home() -> String {
|
||||
hermes_home().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_log_dir(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
let path = log_dir();
|
||||
app.opener()
|
||||
.open_path(path.to_string_lossy(), None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
|
||||
//!
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
|
||||
//! line-buffered stdout/stderr streaming + cancellation semantics.
|
||||
//!
|
||||
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
|
||||
//! On Unix we shell out to `bash <script>` since install.sh expects bash.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Hooks the caller installs to receive output.
|
||||
pub struct StreamSink {
|
||||
pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
|
||||
/// `{stdout, stderr, code, signal, killed}` shape.
|
||||
#[derive(Debug)]
|
||||
pub struct ScriptResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub killed: bool,
|
||||
}
|
||||
|
||||
/// Cancellation signal — `cancel_tx.send(()).await` aborts the running script.
|
||||
pub type CancelRx = mpsc::Receiver<()>;
|
||||
|
||||
/// Spawns install.ps1 / install.sh with the given args and streams output.
|
||||
///
|
||||
/// `hermes_home_override` propagates to the child as $HERMES_HOME so the
|
||||
/// install script writes to the same directory the installer is reading from.
|
||||
pub async fn run_script(
|
||||
script_path: &Path,
|
||||
args: &[String],
|
||||
sink: StreamSink,
|
||||
hermes_home_override: Option<&str>,
|
||||
mut cancel_rx: Option<CancelRx>,
|
||||
) -> Result<ScriptResult> {
|
||||
let mut cmd = build_command(script_path, args);
|
||||
|
||||
if let Some(home) = hermes_home_override {
|
||||
cmd.env("HERMES_HOME", home);
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Windows, avoid spawning a flashing cmd window when we're hosted
|
||||
// inside a GUI process. Tauri's main window is already created, so
|
||||
// the side-effect console for the child is unwanted.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let mut child: Child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {}", script_path.display()))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let stderr = child.stderr.take().expect("stderr was piped");
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
|
||||
let mut combined_stdout = String::new();
|
||||
let mut combined_stderr = String::new();
|
||||
let mut killed = false;
|
||||
|
||||
// Loop: poll stdout, stderr, cancel, and child exit concurrently.
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = stdout_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// EOF on stdout — wait for stderr + exit.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stdout read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
line = stderr_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// stderr EOF — keep draining stdout.
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stderr read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = recv_cancel(&mut cancel_rx) => {
|
||||
tracing::warn!("cancellation received — killing child");
|
||||
killed = true;
|
||||
// best-effort kill; don't propagate errors
|
||||
let _ = child.start_kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining lines after the loop exited.
|
||||
while let Ok(Some(l)) = stdout_reader.next_line().await {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
while let Ok(Some(l)) = stderr_reader.next_line().await {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("waiting for install script to exit")?;
|
||||
|
||||
Ok(ScriptResult {
|
||||
stdout: combined_stdout,
|
||||
stderr: combined_stderr,
|
||||
exit_code: status.code(),
|
||||
killed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn recv_cancel(rx: &mut Option<CancelRx>) {
|
||||
match rx {
|
||||
Some(r) => {
|
||||
let _ = r.recv().await;
|
||||
}
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// We want PowerShell 5.1 / 7. install.ps1 uses 5.1-safe syntax everywhere.
|
||||
// Prefer `powershell.exe` (5.1 baseline, present on every Windows since 7)
|
||||
// over `pwsh.exe` (7+, may not be present).
|
||||
let mut cmd = Command::new("powershell.exe");
|
||||
cmd.arg("-NoProfile");
|
||||
cmd.arg("-ExecutionPolicy").arg("Bypass");
|
||||
cmd.arg("-File").arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// install.sh expects bash. /bin/bash is fine on macOS (Apple still
|
||||
// ships an old 3.2 bash; install.sh is written to that baseline).
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
|
||||
/// print info/banner lines before the result frame; we scan from the end.
|
||||
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("ok").and_then(|v| v.as_bool()).is_some()
|
||||
&& value.get("stage").and_then(|v| v.as_str()).is_some()
|
||||
{
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<crate::events::StageResultPayload>(value)
|
||||
{
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Same logic but for the `-Manifest` payload (the LAST line with a `stages`
|
||||
/// array). Returns the parsed manifest.
|
||||
pub fn parse_manifest(stdout: &str) -> Option<crate::events::Manifest> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("stages").and_then(|v| v.as_array()).is_some() {
|
||||
if let Ok(parsed) = serde_json::from_value::<crate::events::Manifest>(value) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_stage_result_picks_last_json_line() {
|
||||
let stdout = r#"
|
||||
[bootstrap] some info
|
||||
{"ok": false, "stage": "venv", "reason": "bad python"}
|
||||
{"ok": true, "stage": "venv"}
|
||||
final non-json banner
|
||||
"#;
|
||||
let result = parse_stage_result(stdout).unwrap();
|
||||
assert_eq!(result.stage, "venv");
|
||||
assert!(result.ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_finds_stages_array() {
|
||||
let stdout = r#"
|
||||
info line
|
||||
{"stages": [{"name": "uv", "title": "uv", "category": "prereqs", "needs_user_input": false}], "protocol_version": 1}
|
||||
"#;
|
||||
let m = parse_manifest(stdout).unwrap();
|
||||
assert_eq!(m.stages.len(), 1);
|
||||
assert_eq!(m.stages[0].name, "uv");
|
||||
assert_eq!(m.protocol_version, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_no_match() {
|
||||
assert!(parse_stage_result("just banner\n").is_none());
|
||||
assert!(parse_manifest("just banner\n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
//! Update orchestration.
|
||||
//!
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
//! 4. launch the freshly-built desktop (reuses bootstrap::launch logic).
|
||||
//!
|
||||
//! We reuse the `BootstrapEvent` channel + the existing progress UI by
|
||||
//! emitting a synthetic two-stage manifest ("update", "rebuild"). To the
|
||||
//! frontend an update looks like a short bootstrap.
|
||||
//!
|
||||
//! Cross-platform note: `hermes update` already handles macOS/Linux (git/pip).
|
||||
//! The only OS-specific bits here are the venv shim path (resolve_hermes) and
|
||||
//! the no-window creation flag — both already cfg-gated. Keep new logic
|
||||
//! OS-agnostic so the mac/linux port stays "fill in the paths".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::events::{BootstrapEvent, StageInfo, StageState};
|
||||
|
||||
/// `hermes update` exit code meaning "another hermes process is holding the
|
||||
/// venv shim open / dirty precondition" — see _cmd_update_impl in
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Frontend → Rust: kick off the update flow. Mirrors `start_bootstrap`'s
|
||||
/// fire-and-forget shape; progress arrives on the `bootstrap` event channel.
|
||||
#[tauri::command]
|
||||
pub async fn start_update(app: AppHandle) -> Result<(), String> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = run_update(app.clone()).await {
|
||||
// run_update already emits a Failed event on the paths that matter;
|
||||
// this catches anything that escaped. Emit defensively.
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: format!("{err:#}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_update(app: AppHandle) -> Result<()> {
|
||||
let hermes_home = crate::paths::hermes_home();
|
||||
let install_root = hermes_home.join("hermes-agent");
|
||||
|
||||
let hermes = resolve_hermes(&install_root).ok_or_else(|| {
|
||||
let msg = format!(
|
||||
"Could not find the hermes CLI under {}. Is Hermes installed? \
|
||||
Re-run the installer to repair the install.",
|
||||
install_root.display()
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(msg)
|
||||
})?;
|
||||
|
||||
// Synthetic manifest so the existing progress UI renders our two stages.
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages: vec![
|
||||
stage_info("update", "Updating Hermes"),
|
||||
stage_info("rebuild", "Rebuilding the desktop app"),
|
||||
],
|
||||
protocol_version: None,
|
||||
},
|
||||
);
|
||||
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
// built/pinned against (BUILD_PIN_BRANCH), NOT its built-in default of
|
||||
// `main`. The install was a detached-HEAD checkout of a specific commit;
|
||||
// without --branch, `hermes update` switches the checkout to `main` (a
|
||||
// divergent branch that may not even have the desktop CLI command), then
|
||||
// reports "already up to date" against the wrong branch. The desktop
|
||||
// detected the update against this same branch, so we must update against
|
||||
// it too.
|
||||
let pin_branch = option_env_string("BUILD_PIN_BRANCH");
|
||||
let mut update_args: Vec<&str> = vec!["update", "--yes", "--gateway"];
|
||||
if let Some(b) = pin_branch.as_deref() {
|
||||
update_args.push("--branch");
|
||||
update_args.push(b);
|
||||
}
|
||||
|
||||
emit_stage(&app, "update", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
&install_root,
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
let update_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
match update.exit_code {
|
||||
Some(0) => {
|
||||
emit_stage(&app, "update", StageState::Succeeded, Some(update_ms), None);
|
||||
}
|
||||
Some(code) if code == UPDATE_EXIT_CONCURRENT => {
|
||||
let msg = "Hermes is still running. Close all Hermes windows and try \
|
||||
the update again."
|
||||
.to_string();
|
||||
emit_stage(
|
||||
&app,
|
||||
"update",
|
||||
StageState::Failed,
|
||||
Some(update_ms),
|
||||
Some(msg.clone()),
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some("update".into()),
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
other => {
|
||||
let msg = format!(
|
||||
"hermes update failed (exit {:?}). See {} for details.",
|
||||
other,
|
||||
crate::paths::hermes_home()
|
||||
.join("logs")
|
||||
.join("update.log")
|
||||
.display()
|
||||
);
|
||||
emit_stage(
|
||||
&app,
|
||||
"update",
|
||||
StageState::Failed,
|
||||
Some(update_ms),
|
||||
Some(msg.clone()),
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some("update".into()),
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stage 2: hermes desktop --build-only ----------------------------
|
||||
// `hermes update` deliberately does NOT build apps/desktop (it installs
|
||||
// repo-root deps with --workspaces=false). This is the rebuild it skips.
|
||||
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let rebuild = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&["desktop", "--build-only"],
|
||||
&install_root,
|
||||
Some("rebuild"),
|
||||
)
|
||||
.await?;
|
||||
let rebuild_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
if rebuild.exit_code != Some(0) {
|
||||
let msg = format!(
|
||||
"Rebuilding the desktop app failed (exit {:?}). The update was \
|
||||
applied but the app could not be rebuilt; run `hermes desktop` \
|
||||
from a terminal to see the error.",
|
||||
rebuild.exit_code
|
||||
);
|
||||
emit_stage(
|
||||
&app,
|
||||
"rebuild",
|
||||
StageState::Failed,
|
||||
Some(rebuild_ms),
|
||||
Some(msg.clone()),
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some("rebuild".into()),
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None);
|
||||
|
||||
// ---- done: signal complete, then launch the fresh desktop ------------
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Reuse the same detached-launch + app.exit(0) used post-install.
|
||||
if let Err(err) =
|
||||
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned())
|
||||
.await
|
||||
{
|
||||
// Launch failed: don't hard-fail the update (it succeeded); surface a
|
||||
// log line so the success screen can still tell the user to launch
|
||||
// manually.
|
||||
emit_log(
|
||||
&app,
|
||||
None,
|
||||
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
if !is_locked(&shim) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
"[update] timed out waiting for Hermes to exit; proceeding anyway",
|
||||
);
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort lock probe: try to open the file for read+write. On Windows an
|
||||
/// exclusively-held running .exe refuses the open with a sharing violation.
|
||||
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
|
||||
/// the venv-shim contention is a Windows-only problem.
|
||||
fn is_locked(path: &Path) -> bool {
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
match std::fs::OpenOptions::new().read(true).write(true).open(path) {
|
||||
Ok(_) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
|
||||
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
|
||||
/// but for an arbitrary command (no install.ps1 -File wrapping).
|
||||
async fn run_streamed(
|
||||
app: &AppHandle,
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
cwd: &Path,
|
||||
stage: Option<&str>,
|
||||
) -> Result<CmdResult> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW = 0x08000000 — no flashing console behind the GUI.
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("spawning {} {:?}: {e}", program.display(), args))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
let mut out = BufReader::new(stdout).lines();
|
||||
let mut err = BufReader::new(stderr).lines();
|
||||
|
||||
let stage_owned = stage.map(|s| s.to_string());
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = out.next_line() => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l),
|
||||
Ok(None) => break,
|
||||
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
|
||||
},
|
||||
line = err.next_line() => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")),
|
||||
Ok(None) => {}
|
||||
Err(e) => { tracing::warn!("stderr read error: {e}"); }
|
||||
},
|
||||
}
|
||||
}
|
||||
while let Ok(Some(l)) = out.next_line().await {
|
||||
emit_log(app, stage_owned.as_deref(), &l);
|
||||
}
|
||||
while let Ok(Some(l)) = err.next_line().await {
|
||||
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
|
||||
Ok(CmdResult {
|
||||
exit_code: status.code(),
|
||||
})
|
||||
}
|
||||
|
||||
struct CmdResult {
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Path to the venv hermes shim under an install root, regardless of existence.
|
||||
fn venv_hermes(install_root: &Path) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
install_root.join("venv").join("Scripts").join("hermes.exe")
|
||||
} else {
|
||||
install_root.join("venv").join("bin").join("hermes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the hermes CLI to drive. Prefer the venv shim in the install we
|
||||
/// just updated; fall back to `hermes` on PATH.
|
||||
fn resolve_hermes(install_root: &Path) -> Option<PathBuf> {
|
||||
let shim = venv_hermes(install_root);
|
||||
if shim.exists() {
|
||||
return Some(shim);
|
||||
}
|
||||
// PATH fallback. which-style probe via env, kept dependency-free.
|
||||
let exe = if cfg!(target_os = "windows") { "hermes.exe" } else { "hermes" };
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
let sep = if cfg!(target_os = "windows") { ';' } else { ':' };
|
||||
for dir in path.split(sep) {
|
||||
let cand = Path::new(dir).join(exe);
|
||||
if cand.exists() {
|
||||
return Some(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event helpers — keep emit shape identical to bootstrap.rs so the UI is reused
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn stage_info(name: &str, title: &str) -> StageInfo {
|
||||
StageInfo {
|
||||
name: name.to_string(),
|
||||
title: title.to_string(),
|
||||
category: "update".to_string(),
|
||||
needs_user_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
// option_env! only accepts string literals, so the build-time pins are read
|
||||
// by their literal names here. Mirrors bootstrap.rs's helper of the same name
|
||||
// (kept local rather than shared because option_env! can't be parameterized).
|
||||
fn option_env_string(key: &str) -> Option<String> {
|
||||
let val = match key {
|
||||
"BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
|
||||
"BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, event: BootstrapEvent) {
|
||||
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
||||
tracing::warn!(?e, "failed to emit update event");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_stage(
|
||||
app: &AppHandle,
|
||||
name: &str,
|
||||
state: StageState,
|
||||
duration_ms: Option<u64>,
|
||||
error: Option<String>,
|
||||
) {
|
||||
tracing::info!(stage = %name, ?state, ?duration_ms, ?error, "update stage");
|
||||
emit(
|
||||
app,
|
||||
BootstrapEvent::Stage {
|
||||
name: name.to_string(),
|
||||
state,
|
||||
duration_ms,
|
||||
result: None,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
|
||||
match stage {
|
||||
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
|
||||
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
||||
}
|
||||
emit(
|
||||
app,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage.map(|s| s.to_string()),
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn venv_hermes_is_under_install_root() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let shim = venv_hermes(root);
|
||||
assert!(shim.starts_with(root));
|
||||
assert!(shim.to_string_lossy().contains("venv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_not_locked() {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes",
|
||||
"version": "0.0.1",
|
||||
"identifier": "com.nousresearch.hermes.setup",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:5175",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes",
|
||||
"width": 880,
|
||||
"height": 620,
|
||||
"minWidth": 720,
|
||||
"minHeight": 520,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
},
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Hermes",
|
||||
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
|
||||
"publisher": "Nous Research",
|
||||
"copyright": "Copyright © 2026 Nous Research",
|
||||
"targets": [
|
||||
"app",
|
||||
"dmg",
|
||||
"appimage"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { $route, $bootstrap, initialize } from './store'
|
||||
import Welcome from './routes/welcome'
|
||||
import Progress from './routes/progress'
|
||||
import Success from './routes/success'
|
||||
import Failure from './routes/failure'
|
||||
|
||||
/*
|
||||
* App shell — Hermes Setup.
|
||||
*
|
||||
* No header chrome (the OS title bar already says "Hermes Setup"; an
|
||||
* in-window repeat of the H mark + words was redundant slop).
|
||||
*
|
||||
* Route state lives in a single $route atom — 4 screens, no react-router.
|
||||
*/
|
||||
export default function App() {
|
||||
const route = useStore($route)
|
||||
const bootstrap = useStore($bootstrap)
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{route === 'welcome' && <Welcome />}
|
||||
{route === 'progress' && <Progress bootstrap={bootstrap} />}
|
||||
{route === 'success' && <Success />}
|
||||
{route === 'failure' && <Failure bootstrap={bootstrap} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* Button — copied verbatim from apps/desktop/src/components/ui/button.tsx.
|
||||
*
|
||||
* We import the desktop's local shadcn-style Button rather than
|
||||
* @nous-research/ui's <Button>, because the DS Button uses bg-midground /
|
||||
* text-background-base utilities that resolve to the DS's hardcoded
|
||||
* gold/brown brand defaults (#ffac02 / #170d02) unless overridden in
|
||||
* runtime. The desktop never sets those vars; it routes through its
|
||||
* own --dt-* token chain via shadcn classes like bg-primary. We do
|
||||
* the same so visuals match exactly.
|
||||
*/
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs':
|
||||
"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
data-size={size}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { buttonVariants }
|
||||
@@ -1,12 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/*
|
||||
* cn — Tailwind-aware class merger. Same util the desktop and dashboard
|
||||
* use. clsx handles conditional classes; twMerge resolves utility
|
||||
* conflicts so `cn('px-2', condition && 'px-4')` ends up with px-4 only,
|
||||
* not both.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './app.tsx'
|
||||
import './styles.css'
|
||||
|
||||
// Default to LIGHT mode — matches the Hermes desktop's default. The
|
||||
// desktop's runtime theme system can switch to .dark later, but our
|
||||
// installer ships in light mode only since we don't carry the theme
|
||||
// provider machinery.
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
type BootstrapStateModel
|
||||
} from '../store'
|
||||
import { RefreshCw, FileText } from 'lucide-react'
|
||||
|
||||
interface FailureProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Failure screen. Same hero treatment as Welcome/Success — the wordmark
|
||||
* carries the brand, so we keep it across every terminal state.
|
||||
*
|
||||
* The actual error message lives below in muted text. Two clear
|
||||
* affordances: Retry (primary) and Open log folder (secondary).
|
||||
*/
|
||||
export default function Failure({ bootstrap }: FailureProps) {
|
||||
const logPath = useStore($logPath)
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-destructive mix-blend-plus-lighter dark:text-destructive/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Install didn’t finish</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Install didn’t finish</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
|
||||
{bootstrap.error ?? 'Something went wrong during installation.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry install
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => void openLogDir()}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<FileText size={16} />
|
||||
Open log folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logPath && (
|
||||
<p className="max-w-lg text-center text-xs text-muted-foreground/70">
|
||||
Log: <code className="font-mono">{logPath}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
cancelInstall,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
type StageState
|
||||
} from '../store'
|
||||
import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Progress screen — drives a stage list + collapsible log panel. Uses
|
||||
* the DS <Progress> for the top bar so its motion + ring match the rest
|
||||
* of the product.
|
||||
*/
|
||||
export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
const progress = useStore($progress)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (showLogs && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [bootstrap.logs.length, showLogs])
|
||||
|
||||
const currentStage =
|
||||
bootstrap.currentStage != null
|
||||
? bootstrap.stages[bootstrap.currentStage]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
{bootstrap.status === 'running' && (
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
)}
|
||||
<span>
|
||||
{bootstrap.status === 'running'
|
||||
? currentStage
|
||||
? currentStage.info.title
|
||||
: 'Preparing\u2026'
|
||||
: bootstrap.status === 'completed'
|
||||
? 'Done'
|
||||
: 'Installing'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{progress.done} of {progress.total} steps
|
||||
</div>
|
||||
</div>
|
||||
{/* Top progress bar — plain HTML, derived from --primary so it
|
||||
tracks the theme accent. */}
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ol className="space-y-1">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
if (!rec) return null
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
rec.state === 'running' && 'bg-card text-foreground',
|
||||
rec.state === 'succeeded' && 'text-foreground/80',
|
||||
rec.state === 'skipped' && 'text-muted-foreground',
|
||||
rec.state === 'failed' &&
|
||||
'bg-destructive/10 text-destructive',
|
||||
!rec.state && 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
{rec.durationMs != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(rec.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="flex w-1/2 flex-col border-l border-border bg-card/40">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground/80">
|
||||
Live output
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bootstrap.logs.length} lines
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.line.startsWith('stderr:')
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{entry.line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showLogs && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancelInstall()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'running') {
|
||||
return <Loader2 size={14} className="animate-spin text-primary" />
|
||||
}
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={14} className="text-emerald-400" />
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <ChevronRight size={14} className="text-muted-foreground/70" />
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <X size={14} className="text-destructive" />
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.round((ms % 60000) / 1000)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
import { Rocket, AlertCircle } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
* (same Collapse Bold treatment as Welcome + the desktop chat intro),
|
||||
* with a status line below.
|
||||
*
|
||||
* Launching the desktop can fail (e.g. Stage-Desktop was skipped and
|
||||
* Hermes.exe doesn't exist). We catch the Tauri error and surface it
|
||||
* inline rather than silently doing nothing — the previous version
|
||||
* had `onClick={() => void launchHermesDesktop()}` which swallowed
|
||||
* the rejection and left the user staring at an unresponsive button.
|
||||
*/
|
||||
export default function Success() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [launching, setLaunching] = useState(false)
|
||||
|
||||
async function handleLaunch() {
|
||||
setError(null)
|
||||
setLaunching(true)
|
||||
try {
|
||||
await launchHermesDesktop()
|
||||
// On success the installer exits — control never returns here.
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
setLaunching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-8 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Hermes is ready</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Hermes is ready</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
You can launch from here, or any time from your terminal with{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-sm">
|
||||
hermes desktop
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void handleLaunch()}
|
||||
size="lg"
|
||||
disabled={launching}
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<Rocket size={18} />
|
||||
{launching ? 'Launching…' : 'Launch Hermes'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex max-w-2xl items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-1 text-destructive/80">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { startInstall } from '../store'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Welcome screen.
|
||||
*
|
||||
* Mirrors the desktop's chat intro (apps/desktop/src/components/chat/intro.tsx):
|
||||
* - HERMES AGENT wordmark rendered in Collapse Bold, uppercase, tracked
|
||||
* - mix-blend-plus-lighter so the type "glows" on the canvas
|
||||
* - fit-text utility so the wordmark sizes itself to the column
|
||||
*
|
||||
* No install-path footer. The default install location is correct for
|
||||
* 99% of users; the rest will use the CLI installer with a -HermesHome
|
||||
* flag. Showing %LOCALAPPDATA% to grandma is developer-brain.
|
||||
*/
|
||||
export default function Welcome() {
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-10 px-12 py-10">
|
||||
{/* Hero — same recipe the desktop's chat/intro.tsx uses */}
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '6rem',
|
||||
'--fit-text-min': '2.5rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
The agent that grows with you. We’ll set things up in the
|
||||
background — takes a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="group inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
Install Hermes
|
||||
<ArrowRight
|
||||
size={18}
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/*
|
||||
* Bootstrap state store — single source of truth for installer screens.
|
||||
*
|
||||
* Lives in nanostores per the project's TypeScript guidelines (apps/desktop
|
||||
* AGENTS.md): "Prefer small nanostores over component state when state is
|
||||
* shared, reused, or read by distant UI."
|
||||
*
|
||||
* One channel from Rust ('bootstrap' event), discriminated by payload.type.
|
||||
* We translate those events into typed atom updates here so the rest of
|
||||
* the app only deals with React-friendly state.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror src-tauri/src/events.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StageInfo {
|
||||
name: string
|
||||
title: string
|
||||
category: string
|
||||
needs_user_input: boolean
|
||||
}
|
||||
|
||||
export type StageState = 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface StageRecord {
|
||||
info: StageInfo
|
||||
state: StageState | null
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BootstrapStateModel {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed'
|
||||
protocolVersion: number | null
|
||||
stages: Record<string, StageRecord>
|
||||
stageOrder: string[]
|
||||
currentStage: string | null
|
||||
installRoot: string | null
|
||||
error: string | null
|
||||
logs: Array<{ stage?: string; line: string }>
|
||||
}
|
||||
|
||||
const INITIAL: BootstrapStateModel = {
|
||||
status: 'idle',
|
||||
protocolVersion: null,
|
||||
stages: {},
|
||||
stageOrder: [],
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
|
||||
|
||||
/// How the installer was launched, mirrored from src-tauri AppMode.
|
||||
/// 'install' = first-run onboarding (bare launch). 'update' = driven by the
|
||||
/// desktop app handing off via `Hermes-Setup.exe --update`.
|
||||
export type AppMode = 'install' | 'update'
|
||||
|
||||
export const $route = atom<Route>('welcome')
|
||||
export const $mode = atom<AppMode>('install')
|
||||
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
|
||||
export const $logPath = atom<string | null>(null)
|
||||
export const $hermesHome = atom<string | null>(null)
|
||||
|
||||
export const $progress = computed($bootstrap, (b) => {
|
||||
const total = b.stageOrder.length
|
||||
if (total === 0) return { done: 0, total: 0, fraction: 0 }
|
||||
let done = 0
|
||||
for (const name of b.stageOrder) {
|
||||
const s = b.stages[name]?.state
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
|
||||
}
|
||||
return { done, total, fraction: done / total }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri event subscription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BootstrapManifestEvent {
|
||||
type: 'manifest'
|
||||
stages: StageInfo[]
|
||||
protocolVersion: number | null
|
||||
}
|
||||
|
||||
interface BootstrapStageEvent {
|
||||
type: 'stage'
|
||||
name: string
|
||||
state: StageState
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BootstrapLogEvent {
|
||||
type: 'log'
|
||||
stage?: string
|
||||
line: string
|
||||
}
|
||||
|
||||
interface BootstrapCompleteEvent {
|
||||
type: 'complete'
|
||||
installRoot: string
|
||||
marker: unknown
|
||||
}
|
||||
|
||||
interface BootstrapFailedEvent {
|
||||
type: 'failed'
|
||||
stage?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
type BootstrapEvent =
|
||||
| BootstrapManifestEvent
|
||||
| BootstrapStageEvent
|
||||
| BootstrapLogEvent
|
||||
| BootstrapCompleteEvent
|
||||
| BootstrapFailedEvent
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
if (unlisten) return
|
||||
|
||||
// Pull static info on mount for the diagnostics footer.
|
||||
try {
|
||||
const [logPath, hermesHome, mode] = await Promise.all([
|
||||
invoke<string>('get_log_path'),
|
||||
invoke<string>('get_hermes_home'),
|
||||
invoke<AppMode>('get_mode')
|
||||
])
|
||||
$logPath.set(logPath)
|
||||
$hermesHome.set(hermesHome)
|
||||
$mode.set(mode)
|
||||
} catch (err) {
|
||||
console.warn('failed to fetch installer paths', err)
|
||||
}
|
||||
|
||||
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
||||
const payload = event.payload
|
||||
const cur = $bootstrap.get()
|
||||
switch (payload.type) {
|
||||
case 'manifest': {
|
||||
const stages: Record<string, StageRecord> = {}
|
||||
const order: string[] = []
|
||||
for (const s of payload.stages) {
|
||||
stages[s.name] = { info: s, state: null }
|
||||
order.push(s.name)
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'running',
|
||||
protocolVersion: payload.protocolVersion,
|
||||
stages,
|
||||
stageOrder: order,
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
})
|
||||
$route.set('progress')
|
||||
break
|
||||
}
|
||||
case 'stage': {
|
||||
const existing = cur.stages[payload.name]
|
||||
if (!existing) {
|
||||
console.warn('stage event for unknown stage', payload.name)
|
||||
break
|
||||
}
|
||||
const next: StageRecord = {
|
||||
...existing,
|
||||
state: payload.state,
|
||||
durationMs: payload.durationMs,
|
||||
error: payload.error
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
stages: { ...cur.stages, [payload.name]: next },
|
||||
currentStage:
|
||||
payload.state === 'running' ? payload.name : cur.currentStage
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
$bootstrap.set({ ...cur, logs: trimmed })
|
||||
break
|
||||
}
|
||||
case 'complete':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'completed',
|
||||
installRoot: payload.installRoot,
|
||||
currentStage: null
|
||||
})
|
||||
// Install: show the "launch Hermes" success screen. Update: this is a
|
||||
// hand-off — the installer relaunches the desktop and exits within a
|
||||
// few hundred ms, so routing to success just flashes that screen
|
||||
// before the window closes. Stay on progress until we exit.
|
||||
if ($mode.get() !== 'update') {
|
||||
$route.set('success')
|
||||
}
|
||||
break
|
||||
case 'failed':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'failed',
|
||||
error: payload.error,
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('failure')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Update mode is a hand-off, not a user-initiated flow: the desktop already
|
||||
// exited and re-launched us as `--update`. Kick the update immediately so
|
||||
// the user lands on progress, not a redundant "click to update" screen.
|
||||
if ($mode.get() === 'update') {
|
||||
void startUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
// Reset before kicking off so a retry from the failure screen clears
|
||||
// the previous run's state.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_bootstrap', {
|
||||
args: {
|
||||
commit: null,
|
||||
branch: opts?.branch ?? null,
|
||||
include_desktop: true,
|
||||
hermes_home: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function startUpdate(): Promise<void> {
|
||||
// Update is driven by the desktop handing off (Hermes-Setup.exe --update);
|
||||
// there's no welcome click. Reset + jump straight to progress, then let the
|
||||
// Rust side stream the synthetic update manifest.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_update')
|
||||
}
|
||||
|
||||
export async function cancelInstall(): Promise<void> {
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
const installRoot = $bootstrap.get().installRoot
|
||||
if (!installRoot) throw new Error('no install root')
|
||||
await invoke('launch_hermes_desktop', { installRoot })
|
||||
}
|
||||
|
||||
export async function openLogDir(): Promise<void> {
|
||||
await invoke('open_log_dir')
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* Hermes Setup — defer entirely to the desktop's styles.css.
|
||||
*
|
||||
* Rather than re-implement the Hermes design system (and inevitably drift
|
||||
* from it), we import apps/desktop/src/styles.css wholesale. The desktop
|
||||
* is the canonical source of truth for fonts, color tokens, button chrome,
|
||||
* scrollbars, layout utilities, and animations. Any change to the
|
||||
* Hermes look propagates here automatically with no copy-paste maintenance.
|
||||
*
|
||||
* Path resolution caveats:
|
||||
* - Tailwind v4's `@import` resolves relative to this file. The desktop's
|
||||
* `@source '../../../node_modules/...'` declarations therefore re-resolve
|
||||
* against apps/bootstrap-installer/src/. Since both apps live two levels
|
||||
* deep under the same repo root, `../../../node_modules` lands in the
|
||||
* same place. (Verify if either app ever moves.)
|
||||
* - The desktop's `@font-face url('../../../node_modules/...')` references
|
||||
* are baked into the *imported* stylesheet; CSS resolves url()s relative
|
||||
* to the file that contains them, so they continue to point at the
|
||||
* correct node_modules path even from here.
|
||||
*
|
||||
* Forced light mode: the desktop ships with a runtime theme switcher
|
||||
* (ThemeProvider + applyTheme) that can flip to dark via document.documentElement.
|
||||
* The installer has no UI for theme switching, so we stay on the desktop's
|
||||
* default light surface (Nous-blue accent on near-white chrome).
|
||||
*/
|
||||
@import '../../desktop/src/styles.css';
|
||||
|
||||
/* Installer-only additions: a fade-in animation and a warm radial glow
|
||||
for the welcome screen. Everything else inherits from the desktop. */
|
||||
@keyframes hermes-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hermes-fade-in {
|
||||
animation: hermes-fade-in 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.hermes-glow {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
color-mix(in srgb, var(--ui-warm) 18%, transparent) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user