Compare commits
262 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73bf3ab1b2 | |||
| 76edc40ab0 | |||
| b9b9ee3e6c | |||
| 8fbc9d7d78 | |||
| d60a9917d3 | |||
| 7c07422202 | |||
| f4b76fa272 | |||
| 564a649e6a | |||
| 7913d6a90f | |||
| 8b290a5908 | |||
| cdf9793d6d | |||
| 29bcd2f6e9 | |||
| b9d9fa7df8 | |||
| d499d17271 | |||
| 2d3c041338 | |||
| 4e296dcdda | |||
| d954d6fbcf | |||
| e30de51ee9 | |||
| 285e9efb3f | |||
| cad7944b92 | |||
| 407dfbb021 | |||
| 9a14540603 | |||
| ae8930afa5 | |||
| 4178ab3c07 | |||
| 4c792865b4 | |||
| a845177ebe | |||
| eda1d516dc | |||
| e8e5985ce6 | |||
| d1d0ef6dbd | |||
| 87f5e1a25a | |||
| b50bc13ef9 | |||
| 3fc4c63d38 | |||
| 61fec7689d | |||
| 201f7caed8 | |||
| e0fa2cf972 | |||
| 70ae678af1 | |||
| 0dd373ec43 | |||
| fbb3775770 | |||
| cc5b9fb581 | |||
| 3de8e21683 | |||
| 04ea895ffb | |||
| 411f586c67 | |||
| ca87c822ed | |||
| 5af8fa5c8c | |||
| 19f9be1dff | |||
| 3858f9419e | |||
| 01d7c87ecc | |||
| 362996e269 | |||
| f54935738c | |||
| f44f1f9615 | |||
| 663ba9a58f | |||
| cb130bf776 | |||
| 8d302e37a8 | |||
| 2662bfb756 | |||
| 0da968e521 | |||
| 658947480a | |||
| d2536a72bf | |||
| 5d253e65b7 | |||
| 10e43edc09 | |||
| bff8ab0311 | |||
| 97a851bf97 | |||
| 25caaa4a70 | |||
| 0ad4f55aa8 | |||
| 2facea7f71 | |||
| 5b85a7d351 | |||
| fd0796947f | |||
| aa7bf329bc | |||
| 26787ce638 | |||
| e23bb18dac | |||
| 45780edbbf | |||
| 39b0bc377c | |||
| ca5bebef00 | |||
| a696bceafa | |||
| b3137d758c | |||
| 21e695fcb6 | |||
| 3c27efbb91 | |||
| 718e4e2e7e | |||
| 62a5d7207d | |||
| ce0c3ae493 | |||
| f73364b1c4 | |||
| e7beaaf184 | |||
| b06a06e608 | |||
| 828d3a320b | |||
| 4d363499db | |||
| 71c8ca17dc | |||
| 868bc1c242 | |||
| 6e42daf7dd | |||
| 1f1608067c | |||
| 52d9e57825 | |||
| e464cde58f | |||
| 457128d4e8 | |||
| 2e20f6ae2d | |||
| 8f144fe36b | |||
| 4d7fc0f37c | |||
| 7fae87bc00 | |||
| a7fb79efb2 | |||
| 502debed91 | |||
| ffa65291d1 | |||
| 16233711d9 | |||
| d69a0b2c29 | |||
| 763aadd6bf | |||
| 1f712173b2 | |||
| dd2d1ba5e6 | |||
| 7966560fb5 | |||
| 113239f6e3 | |||
| e6b05eaf63 | |||
| 289cc47631 | |||
| 51b44b6e3f | |||
| a7780fe05f | |||
| 7d48a16f14 | |||
| 3c673468b4 | |||
| 95f2802f84 | |||
| 22ff6ca32b | |||
| 8dcab19d02 | |||
| 49fcad8cf8 | |||
| 24b5279f43 | |||
| 0ba451d004 | |||
| 4cc6da84a1 | |||
| 87e259a678 | |||
| 31f70d1f2a | |||
| d05497f812 | |||
| 98a428fd61 | |||
| 8cce85b819 | |||
| fc0f358f37 | |||
| 7a4da315a2 | |||
| b978fd8b26 | |||
| 9fc9c15b4a | |||
| fc7f55f490 | |||
| 98f5be13fa | |||
| 5e6e8b6af3 | |||
| d9bf093728 | |||
| faa467ccaf | |||
| f45434d3c6 | |||
| 2a9a5fffa5 | |||
| c2cb6d1071 | |||
| b52b63396c | |||
| 528e7dc176 | |||
| 4899bd99c0 | |||
| 8652d47eaa | |||
| 7d96a5ab6e | |||
| d3ab2b2e13 | |||
| f7abcb4f01 | |||
| 10fcd620d2 | |||
| d8afafd22b | |||
| 456955c2e4 | |||
| 9be3ab1a5b | |||
| ffe1d660a0 | |||
| 9d7ece362d | |||
| 528a13b37a | |||
| 9835f57e9c | |||
| d7d1503595 | |||
| b81638d749 | |||
| 165d766891 | |||
| 258449c468 | |||
| 2e991770fc | |||
| c5a5e586d7 | |||
| 5a61c116e1 | |||
| 69d4800db7 | |||
| 9ee540a5e2 | |||
| 0e577fb1be | |||
| c61b2e0af7 | |||
| b01656d116 | |||
| 430302c197 | |||
| 40a98fb0fa | |||
| eafa637287 | |||
| f3aa989b1b | |||
| 0b2f1bb27b | |||
| 9eb16025bd | |||
| b2820cd207 | |||
| e0c0167428 | |||
| 6d8423761b | |||
| ec27f0a3fa | |||
| 8c8fc6c1ec | |||
| e120cd5941 | |||
| fa3338c171 | |||
| fd5479a4fc | |||
| fd7188a7c6 | |||
| 60c6b07128 | |||
| 0a5ee01e48 | |||
| c38dac742b | |||
| 1bedc836b5 | |||
| e0a03f3f40 | |||
| 13c238327e | |||
| 5a1d4f6804 | |||
| 810d98e892 | |||
| 83c288da01 | |||
| 398945e7b1 | |||
| 7141cda967 | |||
| 0565497dcc | |||
| 5662ac2afc | |||
| cf83982da0 | |||
| 835f9adec0 | |||
| b85fff9495 | |||
| f317325279 | |||
| 9e63062b6c | |||
| 33967b4e52 | |||
| 40d25e125b | |||
| ff687c019e | |||
| aea72c0936 | |||
| fe6c86623f | |||
| 258755a24f | |||
| e9b96fd050 | |||
| b0435cc164 | |||
| 46437966cc | |||
| 3606414ec7 | |||
| 20b759cd02 | |||
| 13683c0842 | |||
| d244596dba | |||
| 37d107e03d | |||
| df0e97a168 | |||
| 860ff445f6 | |||
| ecaf8008bb | |||
| 4a62ba9ccd | |||
| 23f5fc6765 | |||
| ed170f4333 | |||
| be57af7188 | |||
| 059980727a | |||
| 21676e80cc | |||
| 58a6171bfb | |||
| bc0d8a941e | |||
| 2d137074a3 | |||
| 5531c0df82 | |||
| 5e68503d2f | |||
| 22cc7492ff | |||
| c2fd0fa684 | |||
| fa9383d27b | |||
| 019d4c1c3f | |||
| a12f7aa8bb | |||
| 0d31864e3b | |||
| c8b7e7268a | |||
| bc79e227e6 | |||
| 88602376d4 | |||
| ded12f0968 | |||
| 80e474f11f | |||
| d341af22c0 | |||
| 88e07c42b4 | |||
| cc5efb6fc1 | |||
| 97a2474b39 | |||
| 6b4ef00a2c | |||
| 4858e26eaa | |||
| dcd7b717f8 | |||
| ac855bba0e | |||
| f95c34f415 | |||
| 679a27498d | |||
| d1ee4915f3 | |||
| 26816d1f77 | |||
| e750829015 | |||
| 7d39a45749 | |||
| 69ff114ee2 | |||
| f10a3df632 | |||
| 88a9efdb1a | |||
| 72a3af63d4 | |||
| a2819e1820 | |||
| 0a6ecea676 | |||
| b66cbb7b4c | |||
| c4db1ce08c | |||
| 9e398e1809 | |||
| e4120d1e6d | |||
| 3379f88ea4 | |||
| cb039ac000 | |||
| 0399d4b976 | |||
| c3d39feb3a |
@@ -398,3 +398,19 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
# MICROSOFT TEAMS INTEGRATION
|
||||
# =============================================================================
|
||||
# Register a Bot in Azure: https://dev.botframework.com/ → "Register a bot"
|
||||
# Or use Azure Portal: Azure Active Directory → App registrations → New registration
|
||||
# Then add the bot to Teams via the Bot Framework or App Studio.
|
||||
#
|
||||
# TEAMS_CLIENT_ID= # Azure AD App (client) ID
|
||||
# TEAMS_CLIENT_SECRET= # Azure AD client secret value
|
||||
# TEAMS_TENANT_ID= # Azure AD tenant ID (or "common" for multi-tenant)
|
||||
# TEAMS_ALLOWED_USERS= # Comma-separated AAD object IDs or UPNs
|
||||
# TEAMS_ALLOW_ALL_USERS=false # Set true to skip the allowlist
|
||||
# TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery
|
||||
# TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel
|
||||
# TEAMS_PORT=3978 # Webhook listen port (Bot Framework default)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
name: 'Setup Nix'
|
||||
description: 'Install Nix with DeterminateSystems and enable magic-nix-cache'
|
||||
description: 'Install Nix and configure Cachix binary cache'
|
||||
|
||||
inputs:
|
||||
cachix-auth-token:
|
||||
description: 'Cachix auth token (enables push). Omit for read-only.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
with:
|
||||
name: hermes-agent
|
||||
authToken: ${{ inputs.cachix-auth-token }}
|
||||
continue-on-error: true
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Nix Lockfile Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-lockfile-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix-lockfile-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
|
||||
- name: Resolve head SHA
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check lockfile hashes
|
||||
id: check
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
- name: Fail if check crashed without reporting
|
||||
if: steps.check.outputs.stale != 'true' && steps.check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale)
|
||||
if: steps.check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- 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 -- --apply` and commit the diff
|
||||
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: steps.check.outputs.stale == 'false' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Fail if stale
|
||||
if: steps.check.outputs.stale == 'true'
|
||||
run: exit 1
|
||||
@@ -28,7 +28,7 @@ concurrency:
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles --apply and pushes the hash
|
||||
# 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:
|
||||
@@ -62,6 +62,8 @@ jobs:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
@@ -200,10 +202,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles -- --apply
|
||||
run: nix run .#fix-lockfiles
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
@@ -22,12 +23,95 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Resolve head SHA
|
||||
if: github.event_name == 'pull_request'
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
- 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' || steps.build.outcome == 'failure') && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
|
||||
# etc.) it won't set stale=true/false. Treat that as a distinct failure
|
||||
# mode rather than silently ignoring it.
|
||||
- name: Fail if hash check crashed without reporting
|
||||
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale hashes)
|
||||
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.hash_check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- 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 build passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (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.build.outcome == 'success'))
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- 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 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
|
||||
|
||||
@@ -70,4 +70,3 @@ mini-swe-agent/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
models-dev-upstream/
|
||||
.venv
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# that would otherwise accumulate when hermes runs as PID 1. See #15012.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
|
||||
build-essential curl nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
# Hermes Agent v0.12.0 (v2026.4.30)
|
||||
|
||||
**Release Date:** April 30, 2026
|
||||
**Since v0.11.0:** 1,096 commits · 550 merged PRs · 1,270 files changed · 217,776 insertions · 213 community contributors (including co-authors)
|
||||
|
||||
> The Curator release — Hermes Agent now maintains itself. An autonomous background Curator grades, prunes, and consolidates your skill library on its own schedule. The self-improvement loop that reviews what to save got a substantial upgrade. Four new inference providers, a 18th messaging platform, a 19th via Teams plugin, native Spotify + Google Meet integrations, ComfyUI and TouchDesigner-MCP moved from optional to bundled-by-default, and a ~57% cut to visible TUI cold start.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Autonomous Curator** — `hermes curator` runs as a background agent on the gateway's cron ticker (7-day cycle default). It grades your skill library, consolidates related skills, prunes dead ones, and writes per-run reports to `logs/curator/run.json` + `REPORT.md`. Archived skills are classified consolidated-vs-pruned via model + heuristic. Defense-in-depth gates protect bundled/hub skills from mutation. Unified under `auxiliary.curator` — pick the curator's model in `hermes model`, manage it from the dashboard. `hermes curator status` ranks skills by usage (most-used / least-used). ([#17277](https://github.com/NousResearch/hermes-agent/pull/17277), [#17307](https://github.com/NousResearch/hermes-agent/pull/17307), [#17941](https://github.com/NousResearch/hermes-agent/pull/17941), [#17868](https://github.com/NousResearch/hermes-agent/pull/17868), [#18033](https://github.com/NousResearch/hermes-agent/pull/18033))
|
||||
|
||||
- **Self-improvement loop — substantially upgraded** — The background review fork (the core of Hermes' self-improvement: after each turn it decides what memories/skills to save or update) is now class-first (rubric-based rather than free-form), active-update biased (prefers the skill the agent just loaded), handles `references/`/`templates/` sub-files, and properly inherits the parent's live runtime (provider, model, credentials actually propagate). Restricted to memory + skills toolsets so it can't sprawl. Memory providers shut down cleanly. Prior-turn tool messages excluded from the summary so the fork sees a clean context. ([#16026](https://github.com/NousResearch/hermes-agent/pull/16026), [#17213](https://github.com/NousResearch/hermes-agent/pull/17213), [#16099](https://github.com/NousResearch/hermes-agent/pull/16099), [#16569](https://github.com/NousResearch/hermes-agent/pull/16569), [#16204](https://github.com/NousResearch/hermes-agent/pull/16204), [#15057](https://github.com/NousResearch/hermes-agent/pull/15057))
|
||||
|
||||
- **Skill integrations — major expansion** — **ComfyUI v5** with official CLI + REST + hardware-gated local install, moved from optional to **built-in by default** ([#17610](https://github.com/NousResearch/hermes-agent/pull/17610), [#17631](https://github.com/NousResearch/hermes-agent/pull/17631), [#17734](https://github.com/NousResearch/hermes-agent/pull/17734)). **TouchDesigner-MCP** bundled by default, expanded with GLSL, post-FX, audio, geometry, and 9 new reference docs ([#16753](https://github.com/NousResearch/hermes-agent/pull/16753), [#16624](https://github.com/NousResearch/hermes-agent/pull/16624), [#16768](https://github.com/NousResearch/hermes-agent/pull/16768) — @kshitijk4poor + @SHL0MS). **Humanizer** skill ports a text-cleaner that strips AI-isms ([#16787](https://github.com/NousResearch/hermes-agent/pull/16787)). **claude-design** HTML artifact skill + design-md (Google DESIGN.md spec) + airtable salvage + `skill_manage` edits in `external_dirs` + direct-URL skill install + `/reload-skills` slash command. ([#16358](https://github.com/NousResearch/hermes-agent/pull/16358), [#14876](https://github.com/NousResearch/hermes-agent/pull/14876), [#16291](https://github.com/NousResearch/hermes-agent/pull/16291), [#17512](https://github.com/NousResearch/hermes-agent/pull/17512), [#16323](https://github.com/NousResearch/hermes-agent/pull/16323), [#17744](https://github.com/NousResearch/hermes-agent/pull/17744))
|
||||
|
||||
- **LM Studio — first-class provider** — upgraded from a custom-endpoint alias to a full-blown native provider: dedicated auth, `hermes doctor` checks, reasoning transport, live `/models` listing. (Salvage of @kshitijk4poor's #17061.) ([#17102](https://github.com/NousResearch/hermes-agent/pull/17102))
|
||||
|
||||
- **Four more new inference providers** — **GMI Cloud** (first-class, salvage of #11955 — @isaachuangGMICLOUD), **Azure AI Foundry** with auto-detection, **MiniMax OAuth** with PKCE browser flow (salvage #15203), **Tencent Tokenhub** (salvage of #16860). ([#16663](https://github.com/NousResearch/hermes-agent/pull/16663), [#15845](https://github.com/NousResearch/hermes-agent/pull/15845), [#17524](https://github.com/NousResearch/hermes-agent/pull/17524), [#16960](https://github.com/NousResearch/hermes-agent/pull/16960))
|
||||
|
||||
- **Pluggable gateway platforms + Microsoft Teams** — the gateway is now a plugin host. Drop-in messaging adapters live outside the core, and Microsoft Teams is the first plugin-shipped platform. (Salvage of #17664.) ([#17751](https://github.com/NousResearch/hermes-agent/pull/17751), [#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
|
||||
|
||||
- **Tencent 元宝 (Yuanbao) — 18th messaging platform** — native gateway adapter with text + media delivery. ([#16298](https://github.com/NousResearch/hermes-agent/pull/16298), [#17424](https://github.com/NousResearch/hermes-agent/pull/17424))
|
||||
|
||||
- **Spotify — native tools + bundled skill + wizard** — 7 tools (play, search, queue, playlists, devices) behind PKCE OAuth, interactive setup wizard, bundled skill, surfacing in `hermes tools`, cron usage documented. ([#15121](https://github.com/NousResearch/hermes-agent/pull/15121), [#15130](https://github.com/NousResearch/hermes-agent/pull/15130), [#15154](https://github.com/NousResearch/hermes-agent/pull/15154), [#15180](https://github.com/NousResearch/hermes-agent/pull/15180))
|
||||
|
||||
- **Google Meet plugin** — join calls, transcribe, speak, follow up. Realtime OpenAI transport + Node bot server, full pipeline bundled as a plugin. ([#16364](https://github.com/NousResearch/hermes-agent/pull/16364))
|
||||
|
||||
- **`hermes -z` one-shot mode + `hermes update --check`** — non-interactive `hermes -z <prompt>` with `--model`/`--provider`/`HERMES_INFERENCE_MODEL`. `hermes update --check` preflight. Opt-in pre-update HERMES_HOME backup. ([#15702](https://github.com/NousResearch/hermes-agent/pull/15702), [#15704](https://github.com/NousResearch/hermes-agent/pull/15704), [#15841](https://github.com/NousResearch/hermes-agent/pull/15841), [#16539](https://github.com/NousResearch/hermes-agent/pull/16539), [#16566](https://github.com/NousResearch/hermes-agent/pull/16566))
|
||||
|
||||
- **Models dashboard tab + in-browser model config** — rich per-model analytics, switch main + auxiliary models from the dashboard. ([#17745](https://github.com/NousResearch/hermes-agent/pull/17745), [#17802](https://github.com/NousResearch/hermes-agent/pull/17802))
|
||||
|
||||
- **Remote model catalog manifest** — OpenRouter + Nous Portal model catalogs are now pulled from a remote manifest so new models show up without a release. ([#16033](https://github.com/NousResearch/hermes-agent/pull/16033))
|
||||
|
||||
- **Native multimodal image routing** — images now route based on the model's actual vision capability rather than provider defaults. ([#16506](https://github.com/NousResearch/hermes-agent/pull/16506))
|
||||
|
||||
- **Gateway media parity** — native multi-image sending across Telegram, Discord, Slack, Mattermost, Email, and Signal; centralized audio routing with FLAC support + Telegram document fallback. ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909), [#17833](https://github.com/NousResearch/hermes-agent/pull/17833))
|
||||
|
||||
- **TUI catches up to (and past) the classic CLI** — LaTeX rendering (@austinpickett), `/reload` .env hot-reload, pluggable busy-indicator styles (@OutThisLife, #13610), opt-in auto-resume of last session, expanded light-terminal auto-detection, session delete from `/resume` picker with `d`, modified mouse-wheel line scroll, and a `/mouse` toggle that kills ConPTY's phantom mouse injection (@kevin-ho). ([#17175](https://github.com/NousResearch/hermes-agent/pull/17175), [#17286](https://github.com/NousResearch/hermes-agent/pull/17286), [#17150](https://github.com/NousResearch/hermes-agent/pull/17150), [#17130](https://github.com/NousResearch/hermes-agent/pull/17130), [#17113](https://github.com/NousResearch/hermes-agent/pull/17113), [#17668](https://github.com/NousResearch/hermes-agent/pull/17668), [#17669](https://github.com/NousResearch/hermes-agent/pull/17669), [#15488](https://github.com/NousResearch/hermes-agent/pull/15488))
|
||||
|
||||
- **Observability + achievements plugins** — bundled Langfuse observability plugin (salvage #16845) + bundled hermes-achievements plugin that scans full session history. ([#16917](https://github.com/NousResearch/hermes-agent/pull/16917), [#17754](https://github.com/NousResearch/hermes-agent/pull/17754))
|
||||
|
||||
- **TTS provider registry + Piper local TTS** — pluggable `tts.providers.<name>` registry; Piper ships as a native local TTS provider. (Closes #8508.) ([#17843](https://github.com/NousResearch/hermes-agent/pull/17843), [#17885](https://github.com/NousResearch/hermes-agent/pull/17885))
|
||||
|
||||
- **Vercel Sandbox backend** — Vercel sandboxes as an execute_code/terminal backend (@kshitijk4poor). ([#17445](https://github.com/NousResearch/hermes-agent/pull/17445))
|
||||
|
||||
- **Secret redaction off by default** — default flipped to off. Prevents the long-standing patch-corruption incidents where fake secret-shaped substrings mangled tool outputs. Opt in via `redaction.enabled: true` when you need it. ([#16794](https://github.com/NousResearch/hermes-agent/pull/16794))
|
||||
|
||||
- **Cold-start performance** — visible TUI cold start cut **~57%** via lazy agent init (@OutThisLife), lazy imports of OpenAI / Anthropic / Firecrawl / account_usage, mtime-cached `load_config()`, memoized `get_tool_definitions()` with TTL-cached `check_fn` results, precompiled dangerous-command patterns. ([#17190](https://github.com/NousResearch/hermes-agent/pull/17190), [#17046](https://github.com/NousResearch/hermes-agent/pull/17046), [#17041](https://github.com/NousResearch/hermes-agent/pull/17041), [#17098](https://github.com/NousResearch/hermes-agent/pull/17098), [#17206](https://github.com/NousResearch/hermes-agent/pull/17206))
|
||||
|
||||
- **Configurable prompt cache TTL** — `prompt_caching.cache_ttl` (5m default, 1h opt-in — cost savings for bursty sessions that keep cache warm). Salvage of #12659. ([#15065](https://github.com/NousResearch/hermes-agent/pull/15065))
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Autonomous Curator & Self-Improvement Loop
|
||||
|
||||
### Curator — autonomous skill maintenance
|
||||
- **`hermes curator` as a background agent** — runs on the gateway's cron ticker, 7-day cycle by default, umbrella-first prompt, inherits parent config, unbounded iterations ([#17277](https://github.com/NousResearch/hermes-agent/pull/17277) — issue #7816)
|
||||
- **Per-run reports** — `logs/curator/run.json` + `REPORT.md` per cycle ([#17307](https://github.com/NousResearch/hermes-agent/pull/17307))
|
||||
- **Consolidated vs pruned classification** — archived skills split with model + heuristic ([#17941](https://github.com/NousResearch/hermes-agent/pull/17941))
|
||||
- **`hermes curator status`** — ranks skills by usage, shows most-used and least-used ([#18033](https://github.com/NousResearch/hermes-agent/pull/18033))
|
||||
- **Unified under `auxiliary.curator`** — pick the model in `hermes model`, configure from the dashboard ([#17868](https://github.com/NousResearch/hermes-agent/pull/17868))
|
||||
- **Documentation** — dedicated curator feature page on the docs site ([#17563](https://github.com/NousResearch/hermes-agent/pull/17563))
|
||||
- Fix: seed defaults on update, create `logs/curator/` directory, defer fire import ([#17927](https://github.com/NousResearch/hermes-agent/pull/17927))
|
||||
- Fix: scan nested archive subdirs in `restore_skill` (@0xDevNinja) ([#17951](https://github.com/NousResearch/hermes-agent/pull/17951))
|
||||
- Fix: use actual skill activity in curator status (@y0shua1ee) ([#17953](https://github.com/NousResearch/hermes-agent/pull/17953))
|
||||
- Fix: `skill_manage` refuses writes on pinned skills; pinning now blocks curator writes ([#17562](https://github.com/NousResearch/hermes-agent/pull/17562), [#17578](https://github.com/NousResearch/hermes-agent/pull/17578))
|
||||
- Fix: `bump_use()` wired into skill invocation + preload + skill_view (salvage #17782) ([#17932](https://github.com/NousResearch/hermes-agent/pull/17932))
|
||||
|
||||
### Self-improvement loop (background review fork)
|
||||
- **Class-first skill-review prompt** — rubric-based grading rather than free-form "should this update" ([#16026](https://github.com/NousResearch/hermes-agent/pull/16026))
|
||||
- **Active-update bias** — prefers updating skills the agent just loaded, handles `references/` + `templates/` sub-files ([#17213](https://github.com/NousResearch/hermes-agent/pull/17213))
|
||||
- **Fork inherits parent's live runtime** — provider, model, credentials actually propagate now ([#16099](https://github.com/NousResearch/hermes-agent/pull/16099))
|
||||
- **Scoped toolsets** — review fork restricted to memory + skills (no shell, no web) ([#16569](https://github.com/NousResearch/hermes-agent/pull/16569))
|
||||
- **Clean shutdown** — background review memory providers exit properly (salvage #15289) ([#16204](https://github.com/NousResearch/hermes-agent/pull/16204))
|
||||
- **Clean context** — prior-history tool messages excluded from review summary (salvage #14967) ([#15057](https://github.com/NousResearch/hermes-agent/pull/15057))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills Ecosystem
|
||||
|
||||
### Skill integrations — newly bundled or promoted
|
||||
- **ComfyUI v5** — official CLI + REST + hardware-gated local install; **moved from optional to built-in** ([#17610](https://github.com/NousResearch/hermes-agent/pull/17610), [#17631](https://github.com/NousResearch/hermes-agent/pull/17631), [#17734](https://github.com/NousResearch/hermes-agent/pull/17734), [#17612](https://github.com/NousResearch/hermes-agent/pull/17612))
|
||||
- **TouchDesigner-MCP** — **bundled by default** ([#16753](https://github.com/NousResearch/hermes-agent/pull/16753) — @kshitijk4poor), expanded with GLSL, post-FX, audio, geometry references ([#16624](https://github.com/NousResearch/hermes-agent/pull/16624)), 9 new reference docs ([#16768](https://github.com/NousResearch/hermes-agent/pull/16768) — @SHL0MS)
|
||||
- **Humanizer** — strips AI-isms from text ([#16787](https://github.com/NousResearch/hermes-agent/pull/16787))
|
||||
- **claude-design** — HTML artifact skill with disambiguation from other design skills ([#16358](https://github.com/NousResearch/hermes-agent/pull/16358))
|
||||
- **design-md** — Google's DESIGN.md spec skill ([#14876](https://github.com/NousResearch/hermes-agent/pull/14876))
|
||||
- **airtable** — salvaged skill + skill API keys wired into `.env` (#15838) ([#16291](https://github.com/NousResearch/hermes-agent/pull/16291))
|
||||
- **pretext** — creative browser demos with @chenglou/pretext ([#17259](https://github.com/NousResearch/hermes-agent/pull/17259))
|
||||
- **spike** + **sketch** — throwaway experiments + HTML mockups, adapted from gsd-build ([#17421](https://github.com/NousResearch/hermes-agent/pull/17421))
|
||||
|
||||
### Skills UX
|
||||
- **Install skills from a direct HTTP(S) URL** — `hermes skills install <url>` ([#16323](https://github.com/NousResearch/hermes-agent/pull/16323))
|
||||
- **`/reload-skills`** slash command (salvage #17670) ([#17744](https://github.com/NousResearch/hermes-agent/pull/17744))
|
||||
- **`hermes skills list`** shows enabled/disabled status ([#16129](https://github.com/NousResearch/hermes-agent/pull/16129))
|
||||
- **`skill_manage` refuses writes on pinned skills** ([#17562](https://github.com/NousResearch/hermes-agent/pull/17562))
|
||||
- **`skill_manage` edits external_dirs skills in place** (salvage #9966) ([#17512](https://github.com/NousResearch/hermes-agent/pull/17512), [#17289](https://github.com/NousResearch/hermes-agent/pull/17289))
|
||||
- Fix: inline-shell rendering in `skill_view` ([#15376](https://github.com/NousResearch/hermes-agent/pull/15376))
|
||||
- Fix: exclude `.archive/` from skill index walk (salvage #17639) ([#17931](https://github.com/NousResearch/hermes-agent/pull/17931))
|
||||
- Fix: dedicated docs page per bundled + optional skill ([#14929](https://github.com/NousResearch/hermes-agent/pull/14929))
|
||||
- Fix: `google-workspace` shared HERMES_HOME helper + ship deps as optional extra ([#15405](https://github.com/NousResearch/hermes-agent/pull/15405))
|
||||
- Fix: auto-wrap ASCII-art code blocks in generated skill pages ([#16497](https://github.com/NousResearch/hermes-agent/pull/16497))
|
||||
- Point agent at `hermes-agent` skill + docs site for Hermes questions ([#16535](https://github.com/NousResearch/hermes-agent/pull/16535))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### Provider & Model Support
|
||||
|
||||
#### New providers
|
||||
- **GMI Cloud** — first-class API-key provider on par with Arcee/Kilocode/Xiaomi (salvage of #11955 — @isaachuangGMICLOUD) ([#16663](https://github.com/NousResearch/hermes-agent/pull/16663))
|
||||
- **Azure AI Foundry** — auto-detection, full wiring ([#15845](https://github.com/NousResearch/hermes-agent/pull/15845))
|
||||
- **LM Studio** — upgraded from custom-endpoint alias to first-class provider: dedicated auth, doctor checks, reasoning transport, live `/models` (salvage of #17061 — @kshitijk4poor) ([#17102](https://github.com/NousResearch/hermes-agent/pull/17102))
|
||||
- **MiniMax OAuth** — PKCE browser flow with full OAuth integration (salvage #15203) ([#17524](https://github.com/NousResearch/hermes-agent/pull/17524))
|
||||
- **Tencent Tokenhub** — new provider (salvage of #16860) ([#16960](https://github.com/NousResearch/hermes-agent/pull/16960))
|
||||
|
||||
#### Model catalog
|
||||
- **Remote model catalog manifest** — OpenRouter + Nous Portal catalogs pulled from remote manifest so new models show up without a release ([#16033](https://github.com/NousResearch/hermes-agent/pull/16033))
|
||||
- `openai/gpt-5.5` and `gpt-5.5-pro` added to OpenRouter + Nous Portal ([#15343](https://github.com/NousResearch/hermes-agent/pull/15343))
|
||||
- `deepseek-v4-pro` and `deepseek-v4-flash` added ([#14934](https://github.com/NousResearch/hermes-agent/pull/14934))
|
||||
- `qwen3.6-plus` added to Alibaba-supported models ([#16896](https://github.com/NousResearch/hermes-agent/pull/16896))
|
||||
- Gemini free-tier keys blocked at setup with 429 guidance surfacing ([#15100](https://github.com/NousResearch/hermes-agent/pull/15100))
|
||||
|
||||
#### Model configuration
|
||||
- **Configurable `prompt_caching.cache_ttl`** — 5m default, 1h opt-in (salvage #12659) ([#15065](https://github.com/NousResearch/hermes-agent/pull/15065))
|
||||
- `/fast` whitelist broadened to all OpenAI + Anthropic models ([#16883](https://github.com/NousResearch/hermes-agent/pull/16883))
|
||||
- `auxiliary.extra_body.reasoning` translates into Codex Responses API ([#17004](https://github.com/NousResearch/hermes-agent/pull/17004))
|
||||
- `hermes fallback` command for managing fallback providers ([#16052](https://github.com/NousResearch/hermes-agent/pull/16052))
|
||||
|
||||
### Agent Loop & Conversation
|
||||
- **Native multimodal image routing** — based on model vision capability, not provider defaults ([#16506](https://github.com/NousResearch/hermes-agent/pull/16506))
|
||||
- **Delegate `child_timeout_seconds` default bumped to 600s** ([#14809](https://github.com/NousResearch/hermes-agent/pull/14809))
|
||||
- **Diagnostic dump when subagent times out with 0 API calls** ([#15105](https://github.com/NousResearch/hermes-agent/pull/15105))
|
||||
- **Gateway busts cached agent on compression/context_length config edits** ([#17008](https://github.com/NousResearch/hermes-agent/pull/17008))
|
||||
- **Opt-in runtime-metadata footer on final replies** ([#17026](https://github.com/NousResearch/hermes-agent/pull/17026))
|
||||
- `/reload-mcp` awareness — rebuild cached agents + prompt-cache cost confirmation ([#17729](https://github.com/NousResearch/hermes-agent/pull/17729))
|
||||
- Fix: repair CamelCase + `_tool` suffix tool-call emissions ([#15124](https://github.com/NousResearch/hermes-agent/pull/15124))
|
||||
- Fix: retry on `json.JSONDecodeError` instead of treating as local validation error ([#15107](https://github.com/NousResearch/hermes-agent/pull/15107))
|
||||
- Fix: handle unescaped control chars in `tool_call.arguments` ([#15356](https://github.com/NousResearch/hermes-agent/pull/15356))
|
||||
- Fix: ordering fix in `_copy_reasoning_content_for_api` — cross-provider reasoning isolation (@Zjianru) ([#15749](https://github.com/NousResearch/hermes-agent/pull/15749))
|
||||
- Fix: inject empty `reasoning_content` for DeepSeek/Kimi `tool_calls` unconditionally (@Zjianru) ([#15762](https://github.com/NousResearch/hermes-agent/pull/15762))
|
||||
- Fix: persist streamed `reasoning_content` on assistant turns (#16844) ([#16892](https://github.com/NousResearch/hermes-agent/pull/16892))
|
||||
- Fix: cancel coroutine on timeout so worker thread exits; full traceback on tool failure ([#17428](https://github.com/NousResearch/hermes-agent/pull/17428))
|
||||
- Fix: isolate `get_tool_definitions` quiet_mode cache + dedup LCM injection (#17335) ([#17889](https://github.com/NousResearch/hermes-agent/pull/17889))
|
||||
- Fix: serialize concurrent `hermes_tools` RPC calls from `execute_code` (#17770) ([#17894](https://github.com/NousResearch/hermes-agent/pull/17894), [#17902](https://github.com/NousResearch/hermes-agent/pull/17902))
|
||||
- Fix: rename `[SYSTEM:` → `[IMPORTANT:` in all user-injected markers (dodges Azure content filter) ([#16114](https://github.com/NousResearch/hermes-agent/pull/16114))
|
||||
|
||||
### Compression
|
||||
- **Retry summary on main model for unknown errors before giving up** ([#16774](https://github.com/NousResearch/hermes-agent/pull/16774))
|
||||
- **Notify users when configured aux model fails even if main-model fallback recovers** ([#16775](https://github.com/NousResearch/hermes-agent/pull/16775))
|
||||
- `/compress` wrapped in `_busy_command` to block input during compression ([#15388](https://github.com/NousResearch/hermes-agent/pull/15388))
|
||||
- Fix: reserve system + tools headroom when aux binds threshold ([#15631](https://github.com/NousResearch/hermes-agent/pull/15631))
|
||||
- Fix: use text-char sum for multimodal token estimation in `_find_tail_cut_by_tokens` ([#16369](https://github.com/NousResearch/hermes-agent/pull/16369))
|
||||
|
||||
### Session, Memory & State
|
||||
- **Trigram FTS5 index for CJK search, replace LIKE fallback** (@alt-glitch) ([#16651](https://github.com/NousResearch/hermes-agent/pull/16651))
|
||||
- **Index `tool_name` + `tool_calls` in FTS5, with repair + migration** (salvages #16866) ([#16914](https://github.com/NousResearch/hermes-agent/pull/16914))
|
||||
- **Checkpoints: auto-prune orphan and stale shadow repos at startup** ([#16303](https://github.com/NousResearch/hermes-agent/pull/16303))
|
||||
- **Memory providers notified on mid-process session_id rotation** (#6672) ([#17409](https://github.com/NousResearch/hermes-agent/pull/17409))
|
||||
- Fix: quote underscored terms in FTS5 query sanitization ([#16915](https://github.com/NousResearch/hermes-agent/pull/16915))
|
||||
- Fix: resolve viking_read 500/412 on file URIs + pseudo-summary URIs (salvage #5886) ([#17869](https://github.com/NousResearch/hermes-agent/pull/17869))
|
||||
- Fix: skip external-provider sync on interrupted turns ([#15395](https://github.com/NousResearch/hermes-agent/pull/15395))
|
||||
- Fix: close embedded Hindsight async client cleanly (salvage #14605) ([#16209](https://github.com/NousResearch/hermes-agent/pull/16209))
|
||||
- Fix: pass session transcript to `shutdown_memory_provider` on gateway + CLI (#15165) ([#16571](https://github.com/NousResearch/hermes-agent/pull/16571))
|
||||
- Fix: write-origin metadata seam ([#15346](https://github.com/NousResearch/hermes-agent/pull/15346))
|
||||
- Fix: preserve symlinks during atomic file writes ([#16980](https://github.com/NousResearch/hermes-agent/pull/16980))
|
||||
- Refactor: remove `flush_memories` entirely ([#15696](https://github.com/NousResearch/hermes-agent/pull/15696))
|
||||
|
||||
### Auxiliary models
|
||||
- Fix: surface auxiliary failures in UI (previously silent) ([#15324](https://github.com/NousResearch/hermes-agent/pull/15324))
|
||||
- Fix: surface title-gen auxiliary failures instead of silently dropping ([#16371](https://github.com/NousResearch/hermes-agent/pull/16371))
|
||||
- Fix: generalize unsupported-parameter detector and harden `max_tokens` retry ([#15633](https://github.com/NousResearch/hermes-agent/pull/15633))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### New Platforms
|
||||
- **Microsoft Teams (19th platform)** — as a plugin, + xdist collision guard ([#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
|
||||
- **Yuanbao (Tencent 元宝, 18th platform)** — native adapter with text + media delivery ([#16298](https://github.com/NousResearch/hermes-agent/pull/16298), [#17424](https://github.com/NousResearch/hermes-agent/pull/17424), [#16880](https://github.com/NousResearch/hermes-agent/pull/16880))
|
||||
|
||||
### Pluggable Gateway Platforms
|
||||
- **Drop-in messaging adapters** — the gateway is now a plugin host for platforms (salvage of #17664) ([#17751](https://github.com/NousResearch/hermes-agent/pull/17751))
|
||||
|
||||
### Telegram
|
||||
- **Chat allowlists for groups and forums** (@web3blind) ([#15027](https://github.com/NousResearch/hermes-agent/pull/15027))
|
||||
- **Send fresh finals for stale preview streams** (port openclaw#72038) ([#16261](https://github.com/NousResearch/hermes-agent/pull/16261))
|
||||
- **Render markdown tables as row-group bullets + prompt hint** ([#16997](https://github.com/NousResearch/hermes-agent/pull/16997))
|
||||
- Document fallback in centralized audio routing ([#17833](https://github.com/NousResearch/hermes-agent/pull/17833))
|
||||
- Native multi-image sending ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
|
||||
|
||||
### Discord
|
||||
- **Opt-in toolsets + ID injection + tool split + Feishu wiring** (salvage #15457, #15458) ([#15610](https://github.com/NousResearch/hermes-agent/pull/15610), [#15613](https://github.com/NousResearch/hermes-agent/pull/15613))
|
||||
- Fix: coerce `limit` parameter to int before `min()` call ([#16319](https://github.com/NousResearch/hermes-agent/pull/16319))
|
||||
|
||||
### Slack
|
||||
- **Register every gateway command as a native slash (Discord/Telegram parity)** ([#16164](https://github.com/NousResearch/hermes-agent/pull/16164))
|
||||
- **`strict_mention` config** — prevents thread auto-engagement ([#16193](https://github.com/NousResearch/hermes-agent/pull/16193))
|
||||
- **`channel_skill_bindings`** — bind specific skills to specific Slack channels ([#16283](https://github.com/NousResearch/hermes-agent/pull/16283))
|
||||
|
||||
### Signal
|
||||
- **Native formatting** — markdown → bodyRanges, reply quotes, reactions ([#17417](https://github.com/NousResearch/hermes-agent/pull/17417))
|
||||
- Native multi-image sending ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
|
||||
|
||||
### Feishu / Mattermost / Email / Signal
|
||||
- All participate in **native multi-image sending** ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
|
||||
|
||||
### Gateway Core
|
||||
- **Centralized audio routing + FLAC support + Telegram doc fallback** ([#17833](https://github.com/NousResearch/hermes-agent/pull/17833))
|
||||
- **Native multi-image sending** across Telegram, Discord, Slack, Mattermost, Email, Signal ([#17909](https://github.com/NousResearch/hermes-agent/pull/17909))
|
||||
- **Make hygiene hard message limit configurable** ([#17000](https://github.com/NousResearch/hermes-agent/pull/17000))
|
||||
- **Opt-in runtime-metadata footer on final replies** ([#17026](https://github.com/NousResearch/hermes-agent/pull/17026))
|
||||
- **`pre_gateway_dispatch` hook** — plugins can intercept before dispatch ([#15050](https://github.com/NousResearch/hermes-agent/pull/15050))
|
||||
- **`pre_approval_request` / `post_approval_response` hooks** ([#16776](https://github.com/NousResearch/hermes-agent/pull/16776))
|
||||
- Fix: timeouts — guard `load_config()` call against runtime exceptions ([#16318](https://github.com/NousResearch/hermes-agent/pull/16318))
|
||||
- Fix: support passing handler tools via registry ([#15613](https://github.com/NousResearch/hermes-agent/pull/15613))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### Plugin-first architecture
|
||||
- **Pluggable gateway platforms** — platforms can ship as plugins ([#17751](https://github.com/NousResearch/hermes-agent/pull/17751))
|
||||
- **Microsoft Teams as first plugin-shipped platform** ([#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
|
||||
- **`pre_gateway_dispatch` hook** ([#15050](https://github.com/NousResearch/hermes-agent/pull/15050))
|
||||
- **`pre_approval_request` + `post_approval_response` hooks** ([#16776](https://github.com/NousResearch/hermes-agent/pull/16776))
|
||||
- **`duration_ms` on `post_tool_call`** (inspired by Claude Code 2.1.119) ([#15429](https://github.com/NousResearch/hermes-agent/pull/15429))
|
||||
- **Bundled plugins**: Spotify ([#15174](https://github.com/NousResearch/hermes-agent/pull/15174)), Google Meet ([#16364](https://github.com/NousResearch/hermes-agent/pull/16364)), Langfuse observability ([#16917](https://github.com/NousResearch/hermes-agent/pull/16917)), hermes-achievements ([#17754](https://github.com/NousResearch/hermes-agent/pull/17754))
|
||||
- **Page-scoped plugin slots for built-in dashboard pages** ([#15658](https://github.com/NousResearch/hermes-agent/pull/15658))
|
||||
- **Declarative plugin installation for NixOS module** (@alt-glitch) ([#15953](https://github.com/NousResearch/hermes-agent/pull/15953))
|
||||
|
||||
### Browser
|
||||
- **CDP supervisor** — dialog detection + response + cross-origin iframe eval ([#14540](https://github.com/NousResearch/hermes-agent/pull/14540))
|
||||
- **Auto-spawn local Chromium for LAN/localhost URLs** when cloud provider is configured ([#16136](https://github.com/NousResearch/hermes-agent/pull/16136))
|
||||
|
||||
### Execute code / Terminal
|
||||
- **Vercel Sandbox backend** for `execute_code` / terminal (@kshitijk4poor) ([#17445](https://github.com/NousResearch/hermes-agent/pull/17445))
|
||||
- **Collapse subagent `task_id`s to shared container** ([#16177](https://github.com/NousResearch/hermes-agent/pull/16177))
|
||||
- **Docker: run container as host user** to avoid root-owned bind mounts (@benbarclay) ([#17305](https://github.com/NousResearch/hermes-agent/pull/17305))
|
||||
- Fix: safely quote `~/` subpaths in wrapped `cd` commands ([#15394](https://github.com/NousResearch/hermes-agent/pull/15394))
|
||||
- Fix: close file descriptor in `LocalEnvironment._update_cwd` ([#17300](https://github.com/NousResearch/hermes-agent/pull/17300))
|
||||
- Fix: SSH — prevent tar from overwriting remote home dir permissions ([#17898](https://github.com/NousResearch/hermes-agent/pull/17898), [#17867](https://github.com/NousResearch/hermes-agent/pull/17867))
|
||||
|
||||
### Image generation
|
||||
- See Provider section for updates; no new image providers this window.
|
||||
|
||||
### TTS / Voice
|
||||
- **Pluggable TTS provider registry** under `tts.providers.<name>` ([#17843](https://github.com/NousResearch/hermes-agent/pull/17843))
|
||||
- **Piper** as native local TTS provider (closes #8508) ([#17885](https://github.com/NousResearch/hermes-agent/pull/17885))
|
||||
- **Voice mode CLI parity in the TUI** — VAD loop + TTS + crash forensics ([#14810](https://github.com/NousResearch/hermes-agent/pull/14810))
|
||||
- Fix: vision — use HERMES_HOME-based cache dir instead of cwd ([#17719](https://github.com/NousResearch/hermes-agent/pull/17719))
|
||||
|
||||
### Cron
|
||||
- **Honor `hermes tools` config for the cron platform** ([#14798](https://github.com/NousResearch/hermes-agent/pull/14798))
|
||||
- **Per-job `workdir`** — project-aware cron runs ([#15110](https://github.com/NousResearch/hermes-agent/pull/15110))
|
||||
- **`context_from` field** — chain cron job outputs ([#15606](https://github.com/NousResearch/hermes-agent/pull/15606))
|
||||
- Fix: promote `croniter` to a core dependency ([#17577](https://github.com/NousResearch/hermes-agent/pull/17577))
|
||||
|
||||
### Web search
|
||||
- **Expose `limit` for `web_search`** ([#16934](https://github.com/NousResearch/hermes-agent/pull/16934))
|
||||
|
||||
### Maps
|
||||
- Fix: include seconds in timezone UTC offset output ([#16300](https://github.com/NousResearch/hermes-agent/pull/16300))
|
||||
|
||||
### Approvals
|
||||
- **Hardline blocklist for unrecoverable commands** ([#15878](https://github.com/NousResearch/hermes-agent/pull/15878))
|
||||
- Perf: precompile DANGEROUS_PATTERNS and HARDLINE_PATTERNS ([#17206](https://github.com/NousResearch/hermes-agent/pull/17206))
|
||||
|
||||
### ACP
|
||||
- **Advertise and forward image prompts** ([#18030](https://github.com/NousResearch/hermes-agent/pull/18030))
|
||||
|
||||
### API Server
|
||||
- **POST `/v1/runs/{run_id}/stop`** (salvage of #15656) ([#15842](https://github.com/NousResearch/hermes-agent/pull/15842))
|
||||
- **Expose run status for external UIs** (#17085) ([#17458](https://github.com/NousResearch/hermes-agent/pull/17458))
|
||||
|
||||
### Nix
|
||||
- **Declarative plugin installation for NixOS module** (@alt-glitch) ([#15953](https://github.com/NousResearch/hermes-agent/pull/15953))
|
||||
- Fix: use `--rebuild` in fix-lockfiles to bypass cached FOD store paths ([#15444](https://github.com/NousResearch/hermes-agent/pull/15444))
|
||||
- Fix: `extraPackages` now actually works via per-user profile ([#17047](https://github.com/NousResearch/hermes-agent/pull/17047))
|
||||
- Fix: refresh web/ npm-deps hash to unblock main builds ([#17174](https://github.com/NousResearch/hermes-agent/pull/17174))
|
||||
- Fix: replace magic-nix-cache with Cachix ([#17928](https://github.com/NousResearch/hermes-agent/pull/17928))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ TUI
|
||||
|
||||
### New features
|
||||
- **LaTeX rendering** (@austinpickett) ([#17175](https://github.com/NousResearch/hermes-agent/pull/17175))
|
||||
- **`/reload` .env hot-reload** — ported from the classic CLI ([#17286](https://github.com/NousResearch/hermes-agent/pull/17286))
|
||||
- **Pluggable busy-indicator styles** (@OutThisLife, #13610) ([#17150](https://github.com/NousResearch/hermes-agent/pull/17150))
|
||||
- **Opt-in auto-resume of the most recent session** (@OutThisLife) ([#17130](https://github.com/NousResearch/hermes-agent/pull/17130))
|
||||
- **Expanded light-terminal auto-detection** — `HERMES_TUI_THEME` + background hex (@OutThisLife) ([#17113](https://github.com/NousResearch/hermes-agent/pull/17113))
|
||||
- **Delete sessions from `/resume` picker with `d`** (@OutThisLife) ([#17668](https://github.com/NousResearch/hermes-agent/pull/17668))
|
||||
- **Line-by-line scroll on modified mouse wheel** (@OutThisLife) ([#17669](https://github.com/NousResearch/hermes-agent/pull/17669))
|
||||
- **Delete queued message while editing with ctrl-x / cancel with esc** (@OutThisLife) ([#16707](https://github.com/NousResearch/hermes-agent/pull/16707))
|
||||
- **Per-section visibility for the details accordion** (@OutThisLife) ([#14968](https://github.com/NousResearch/hermes-agent/pull/14968))
|
||||
- **Voice mode CLI parity** — VAD loop + TTS + crash forensics ([#14810](https://github.com/NousResearch/hermes-agent/pull/14810))
|
||||
- **Contextual first-touch hints ported to TUI** — `/busy`, `/verbose` ([#16054](https://github.com/NousResearch/hermes-agent/pull/16054))
|
||||
- **Mini help menu on `?` in the input field** (@ethernet8023) ([#18043](https://github.com/NousResearch/hermes-agent/pull/18043))
|
||||
|
||||
### Fixes
|
||||
- Fix: proactive mouse disable on ConPTY + `/mouse` toggle command (@kevin-ho, WSL2 ghost-mouse fix) ([#15488](https://github.com/NousResearch/hermes-agent/pull/15488))
|
||||
- Fix: restore skills search RPC ([#15870](https://github.com/NousResearch/hermes-agent/pull/15870))
|
||||
- Perf: cache text measurements across yoga flex re-passes ([#14818](https://github.com/NousResearch/hermes-agent/pull/14818))
|
||||
- Perf: stabilize long-session scrolling ([#15926](https://github.com/NousResearch/hermes-agent/pull/15926))
|
||||
- Perf: lazily seed virtual history heights ([#16523](https://github.com/NousResearch/hermes-agent/pull/16523))
|
||||
- Perf: cut visible cold start ~57% with lazy agent init ([#17190](https://github.com/NousResearch/hermes-agent/pull/17190))
|
||||
|
||||
---
|
||||
|
||||
## 🖱️ CLI & User Experience
|
||||
|
||||
### New commands
|
||||
- **`hermes -z <prompt>`** — non-interactive one-shot mode ([#15702](https://github.com/NousResearch/hermes-agent/pull/15702))
|
||||
- **`hermes -z` with `--model` / `--provider` / `HERMES_INFERENCE_MODEL`** ([#15704](https://github.com/NousResearch/hermes-agent/pull/15704))
|
||||
- **`hermes update --check`** preflight flag ([#15841](https://github.com/NousResearch/hermes-agent/pull/15841))
|
||||
- **`hermes fallback`** command for managing fallback providers ([#16052](https://github.com/NousResearch/hermes-agent/pull/16052))
|
||||
- **`/busy`** slash command for busy input mode ([#15382](https://github.com/NousResearch/hermes-agent/pull/15382))
|
||||
- **`/busy` input mode 'steer'** as a third option ([#16279](https://github.com/NousResearch/hermes-agent/pull/16279))
|
||||
- **`/btw` as alias for `/background`** ([#16053](https://github.com/NousResearch/hermes-agent/pull/16053))
|
||||
- **`/reload-skills`** slash command (salvage #17670) ([#17744](https://github.com/NousResearch/hermes-agent/pull/17744))
|
||||
- **Surface `/queue`, `/bg`, `/steer` in agent-running placeholder** ([#16118](https://github.com/NousResearch/hermes-agent/pull/16118))
|
||||
|
||||
### Setup / onboarding
|
||||
- **Auto-reconfigure on existing installs** ([#15879](https://github.com/NousResearch/hermes-agent/pull/15879))
|
||||
- **Contextual first-touch hints for `/busy` and `/verbose`** ([#16046](https://github.com/NousResearch/hermes-agent/pull/16046))
|
||||
- **Cost-saving tips from the April 30 tip-of-the-day** ([#17841](https://github.com/NousResearch/hermes-agent/pull/17841))
|
||||
- **Hyperlink startup banner title to the latest GitHub Release** ([#14945](https://github.com/NousResearch/hermes-agent/pull/14945))
|
||||
|
||||
### Update / backup
|
||||
- **Snapshot pairing data before `git pull`** ([#16383](https://github.com/NousResearch/hermes-agent/pull/16383))
|
||||
- **Auto-backup HERMES_HOME before `hermes update`** (opt-in, off by default) ([#16539](https://github.com/NousResearch/hermes-agent/pull/16539), [#16566](https://github.com/NousResearch/hermes-agent/pull/16566))
|
||||
- **Exclude `checkpoints/` from backups** ([#16572](https://github.com/NousResearch/hermes-agent/pull/16572))
|
||||
- **Exclude SQLite WAL/SHM/journal sidecars from backups** ([#16576](https://github.com/NousResearch/hermes-agent/pull/16576))
|
||||
- **Installer FHS layout for root installs on Linux** ([#15608](https://github.com/NousResearch/hermes-agent/pull/15608))
|
||||
- Fix: kill stale dashboards instead of warning ([#17832](https://github.com/NousResearch/hermes-agent/pull/17832))
|
||||
- Fix: show correct update status on nix-built hermes ([#17550](https://github.com/NousResearch/hermes-agent/pull/17550))
|
||||
|
||||
### Slash-command housekeeping
|
||||
- Refactor: drop `/provider`, `/plan` handler, and clean up slash registry ([#15047](https://github.com/NousResearch/hermes-agent/pull/15047))
|
||||
- Refactor: drop `persist_session` plumbing + fix broken `/btw` mid-turn bypass ([#16075](https://github.com/NousResearch/hermes-agent/pull/16075))
|
||||
|
||||
### OpenClaw migration (for folks coming from OpenClaw)
|
||||
- **Hardened OpenClaw import** — plan-first apply, redaction, pre-migration backup ([#16911](https://github.com/NousResearch/hermes-agent/pull/16911))
|
||||
- Fix: case-preserving brand rewrite + one-time `~/.openclaw` residue banner ([#16327](https://github.com/NousResearch/hermes-agent/pull/16327))
|
||||
- Fix: resolve `openclaw` workspace files from `agents.defaults.workspace` ([#16879](https://github.com/NousResearch/hermes-agent/pull/16879))
|
||||
- Fix: resolve model aliases against real OpenClaw catalog schema (salvage #16778) ([#16977](https://github.com/NousResearch/hermes-agent/pull/16977))
|
||||
|
||||
---
|
||||
|
||||
## 📊 Web Dashboard
|
||||
|
||||
- **Models tab** — rich per-model analytics ([#17745](https://github.com/NousResearch/hermes-agent/pull/17745))
|
||||
- **Configure main + auxiliary models from the Models page** ([#17802](https://github.com/NousResearch/hermes-agent/pull/17802))
|
||||
- **Dashboard Chat tab — xterm.js + JSON-RPC sidecar** (supersedes #12710 + #13379, @OutThisLife) ([#14890](https://github.com/NousResearch/hermes-agent/pull/14890))
|
||||
- **Dashboard layout refresh** (@austinpickett) ([#14899](https://github.com/NousResearch/hermes-agent/pull/14899))
|
||||
- **`--stop` and `--status` flags** on the dashboard CLI ([#17840](https://github.com/NousResearch/hermes-agent/pull/17840))
|
||||
- **Page-scoped plugin slots for built-in pages** ([#15658](https://github.com/NousResearch/hermes-agent/pull/15658))
|
||||
- Fix: replace all buttons for design system buttons ([#17007](https://github.com/NousResearch/hermes-agent/pull/17007))
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- **TUI visible cold start cut ~57%** via lazy agent init ([#17190](https://github.com/NousResearch/hermes-agent/pull/17190))
|
||||
- **Lazy-import OpenAI, Anthropic, Firecrawl, account_usage** ([#17046](https://github.com/NousResearch/hermes-agent/pull/17046))
|
||||
- **mtime-cache `load_config()` and `read_raw_config()`** ([#17041](https://github.com/NousResearch/hermes-agent/pull/17041))
|
||||
- **Memoize `get_tool_definitions()` + TTL-cache `check_fn` results** ([#17098](https://github.com/NousResearch/hermes-agent/pull/17098))
|
||||
- **Precompile DANGEROUS_PATTERNS and HARDLINE_PATTERNS** ([#17206](https://github.com/NousResearch/hermes-agent/pull/17206))
|
||||
- **Cache Ink text measurements across yoga flex re-passes** ([#14818](https://github.com/NousResearch/hermes-agent/pull/14818))
|
||||
- **Stabilize long-session scrolling** ([#15926](https://github.com/NousResearch/hermes-agent/pull/15926))
|
||||
- **Lazily seed virtual history heights** ([#16523](https://github.com/NousResearch/hermes-agent/pull/16523))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
- **Secret redaction off by default** — stops corrupting patches / API payloads with fake-key substitutions. Opt in via `redaction.enabled: true` ([#16794](https://github.com/NousResearch/hermes-agent/pull/16794))
|
||||
- **`[SYSTEM:` → `[IMPORTANT:`** in all user-injected markers (Azure content filter dodge) ([#16114](https://github.com/NousResearch/hermes-agent/pull/16114))
|
||||
- **Hardline blocklist for unrecoverable commands** ([#15878](https://github.com/NousResearch/hermes-agent/pull/15878))
|
||||
- **Canonical `mask_secret` helper; fix status.py DIM drift** ([#17207](https://github.com/NousResearch/hermes-agent/pull/17207))
|
||||
- **Sweep expired paste.rs uploads on a real timer** ([#16431](https://github.com/NousResearch/hermes-agent/pull/16431))
|
||||
- **Preserve symlinks during atomic file writes** ([#16980](https://github.com/NousResearch/hermes-agent/pull/16980))
|
||||
- **Probe `/dev/tty` by opening it, not bare existence** ([#17024](https://github.com/NousResearch/hermes-agent/pull/17024))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
This window includes 360 `fix:` PRs. Selected highlights from across the stack:
|
||||
|
||||
- **Background review fork inherits parent's live runtime** — provider/model/creds now propagate correctly ([#16099](https://github.com/NousResearch/hermes-agent/pull/16099))
|
||||
- **Hindsight configurable `HINDSIGHT_TIMEOUT` env var** ([#15077](https://github.com/NousResearch/hermes-agent/pull/15077))
|
||||
- **Tools: normalize numeric entries + clear stale `no_mcp` in `_save_platform_tools`** ([#15607](https://github.com/NousResearch/hermes-agent/pull/15607))
|
||||
- **MCP: rewrite `definitions` refs to `$defs` in input schemas** — closes provider-side 400s
|
||||
- **Azure content filter compatibility** — renamed `[SYSTEM:` markers so Azure's content filter stops flagging them ([#16114](https://github.com/NousResearch/hermes-agent/pull/16114))
|
||||
- **Vision cache uses HERMES_HOME instead of cwd** ([#17719](https://github.com/NousResearch/hermes-agent/pull/17719))
|
||||
- **FTS5 search** — tool_name + tool_calls indexing with repair + migration ([#16914](https://github.com/NousResearch/hermes-agent/pull/16914))
|
||||
- **Streaming reasoning persists on assistant turns** ([#16892](https://github.com/NousResearch/hermes-agent/pull/16892))
|
||||
- **execute_code concurrent RPC serialization** (#17770) ([#17894](https://github.com/NousResearch/hermes-agent/pull/17894), [#17902](https://github.com/NousResearch/hermes-agent/pull/17902))
|
||||
- **Background reviewer scoped to memory + skills toolsets** — no more accidental web/shell escapes ([#16569](https://github.com/NousResearch/hermes-agent/pull/16569))
|
||||
- **Compression recovery** — retry on main before giving up; notify user when aux fails ([#16774](https://github.com/NousResearch/hermes-agent/pull/16774), [#16775](https://github.com/NousResearch/hermes-agent/pull/16775))
|
||||
- **`croniter` promoted to a core dependency** ([#17577](https://github.com/NousResearch/hermes-agent/pull/17577))
|
||||
- **Discord tool `limit` parameter coerced to int** before `min()` call ([#16319](https://github.com/NousResearch/hermes-agent/pull/16319))
|
||||
- **Yuanbao messaging platform entrance fix** ([#16880](https://github.com/NousResearch/hermes-agent/pull/16880))
|
||||
- **ACP advertise and forward image prompts** ([#18030](https://github.com/NousResearch/hermes-agent/pull/18030))
|
||||
- **DeepSeek / Kimi reasoning content isolation** across cross-provider histories (@Zjianru) ([#15749](https://github.com/NousResearch/hermes-agent/pull/15749), [#15762](https://github.com/NousResearch/hermes-agent/pull/15762))
|
||||
- **Preserve reasoning_content replay on DeepSeek v4 + Kimi/Moonshot thinking** ([#18045](https://github.com/NousResearch/hermes-agent/pull/18045))
|
||||
|
||||
The vast majority of the 360 fixes landed in the streaming/compression/tool-calling paths across all providers — DeepSeek, Kimi, Moonshot, GLM, Qwen, MiniMax, Gemini, Anthropic, OpenAI — alongside TUI polish (resize, scroll, sticky-prompt) and gateway platform-specific edge cases.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing & CI
|
||||
|
||||
- Hermetic test parity (`scripts/run_tests.sh`) held across this window
|
||||
- **Microsoft Teams xdist collision guard** — prevents worker collisions when Teams platform tests run in parallel ([#17828](https://github.com/NousResearch/hermes-agent/pull/17828))
|
||||
- Chore: remove unused imports and dead locals (ruff F401, F841) ([#17010](https://github.com/NousResearch/hermes-agent/pull/17010))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **Curator feature page** added to docs site ([#17563](https://github.com/NousResearch/hermes-agent/pull/17563))
|
||||
- **Document pin also blocking `skill_manage` writes** ([#17578](https://github.com/NousResearch/hermes-agent/pull/17578))
|
||||
- **Direct-URL skill install documented** across features, reference, guide, and `hermes-agent` skill ([#16355](https://github.com/NousResearch/hermes-agent/pull/16355))
|
||||
- **Hooks tutorial — build a BOOT.md startup checklist** (replaces the removed built-in hook) ([#17202](https://github.com/NousResearch/hermes-agent/pull/17202))
|
||||
- **ComfyUI docs: ask local vs cloud FIRST before hardware check** ([#17612](https://github.com/NousResearch/hermes-agent/pull/17612))
|
||||
- **Obliteratus skill: link YouTube video guide in SKILL.md** ([#15808](https://github.com/NousResearch/hermes-agent/pull/15808))
|
||||
- Per-skill docs pages generated for bundled + optional skills; ASCII art code blocks auto-wrapped ([#14929](https://github.com/NousResearch/hermes-agent/pull/14929), [#16497](https://github.com/NousResearch/hermes-agent/pull/16497))
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Removed / Reverted
|
||||
|
||||
- **Kanban multi-profile collaboration board** — landed in #16081, reverted in ([#16098](https://github.com/NousResearch/hermes-agent/pull/16098)) while the design is reworked
|
||||
- **computer-use cua-driver** — 3 preparatory PRs landed then were reverted in ([#16927](https://github.com/NousResearch/hermes-agent/pull/16927))
|
||||
- **BOOT.md built-in hook** removed ([#17093](https://github.com/NousResearch/hermes-agent/pull/17093)); the hooks tutorial ([#17202](https://github.com/NousResearch/hermes-agent/pull/17202)) shows how to build the same workflow yourself with a shell hook
|
||||
- **`/provider` + `/plan` slash commands dropped** ([#15047](https://github.com/NousResearch/hermes-agent/pull/15047))
|
||||
- **`flush_memories` removed entirely** ([#15696](https://github.com/NousResearch/hermes-agent/pull/15696))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- **@teknium1** (Teknium)
|
||||
|
||||
### Top Community Contributors (by merged PR count since v0.11.0)
|
||||
|
||||
- **@OutThisLife** (Brooklyn) — 52 PRs · TUI — light-terminal detection + pluggable busy styles + auto-resume + session-delete from /resume + mouse-wheel scrolling + xterm.js dashboard Chat tab + cold-start cut + accordion polish
|
||||
- **@kshitijk4poor** — 12 PRs · LM Studio first-class provider (salvage), Vercel Sandbox backend, GMI Cloud salvage, bundled-by-default touchdesigner-mcp, many tool-call / reasoning fixes
|
||||
- **@helix4u** — 10 PRs · MCP schema robustness, assorted stability fixes
|
||||
- **@alt-glitch** — 8 PRs · trigram FTS5 CJK search, declarative Nix plugin install, matrix/feishu hints and fixes
|
||||
- **@ethernet8023** — 4 PRs
|
||||
- **@austinpickett** — 4 PRs · LaTeX rendering in TUI, dashboard layout refresh
|
||||
- **@benbarclay** — 3 PRs · Docker run-as-host-user so bind mounts don't get root-owned
|
||||
- **@vominh1919** — 2 PRs
|
||||
- **@stephenschoettler** — 2 PRs
|
||||
- **@kevin-ho** — ConPTY mouse-injection fix (#15488)
|
||||
- **@Zjianru** — cross-provider reasoning_content isolation + DeepSeek/Kimi empty-reasoning injection (#15749, #15762)
|
||||
- **@web3blind** — Telegram chat allowlists for groups and forums (#15027)
|
||||
- **@SHL0MS** — 9 new TouchDesigner-MCP reference docs (#16768)
|
||||
- **@0xDevNinja** — curator `restore_skill` nested-archive fix (#17951)
|
||||
- **@y0shua1ee** — curator `use` activity fix (#17953)
|
||||
|
||||
### Also contributing
|
||||
Salvaged or co-authored work from **@isaachuangGMICLOUD** (GMI Cloud), earlier upstream PRs from the original author of each salvage chain, and a long tail of one-shot fixes, documentation nudges, and skill contributions from the community.
|
||||
|
||||
### All Contributors (alphabetical, excluding @teknium1)
|
||||
|
||||
@0xbyt4, @0xharryriddle, @0xDevNinja, @0z1-ghb, @5park1e, @A-FdL-Prog, @aj-nt, @akhater, @alblez, @alexg0bot,
|
||||
@alexzhu0, @AllardQuek, @alt-glitch, @amanning3390, @amanuel2, @AndreKurait, @andrewhosf, @Andy283, @andyylin,
|
||||
@angel12, @AntAISecurityLab, @ash, @austinpickett, @badgerbees, @BadTechBandit, @Bartok9, @beenherebefore,
|
||||
@beesrsj2500, @BeliefanX, @benbarclay, @benjaminsehl, @BlackishGreen33, @bloodcarter, @BlueBirdBack,
|
||||
@briandevans, @brooklynnicholson, @bsgdigital, @buray, @bwjoke, @camaragon, @cdanis, @cgarwood82,
|
||||
@charles-brooks, @chen1749144759, @chengoak, @ching-kaching, @Contentment003111, @crayfish-ai, @CruxExperts,
|
||||
@cyclingwithelephants, @dandaka, @danklynn, @ddupont808, @dhabibi, @difujia, @dimitrovi, @dlkakbs,
|
||||
@dontcallmejames, @EKKOLearnAI, @emozilla, @ericnicolaides, @Erosika, @ethernet8023, @exiao, @Feranmi10,
|
||||
@flobo3, @foxion37, @georgeglessner, @georgex8001, @ghostmfr, @H-Ali13381, @HangGlidersRule, @harryplusplus,
|
||||
@haru398801, @heathley, @hejuntt1014, @hekaru-agent, @helix4u, @Heltman, @HenkDz, @heyitsaamir, @hharry11,
|
||||
@hhhonzik, @hhuang91, @HiddenPuppy, @htsh, @iamagenius00, @in-liberty420, @innocarpe, @irispillars, @iRonin,
|
||||
@isaachuangGMICLOUD, @Ito-69, @j3ffffff, @jackjin1997, @jakubkrcmar, @Jason2031, @JayGwod, @jerome-benoit,
|
||||
@johnncenae, @Kailigithub, @keiravoss94, @kevin-ho, @knockyai, @konsisumer, @kshitijk4poor, @kunlabs, @l0hde,
|
||||
@Leihb, @leoneparise, @LeonSGP43, @liizfq, @liuhao1024, @loongzhao, @lsdsjy, @luyao618, @ma-pony, @Magaav,
|
||||
@MagicRay1217, @math0r-be, @MattMaximo, @maxims-oss, @MaxyMoos, @maymuneth, @mcndjxlefnd, @memosr,
|
||||
@MestreY0d4-Uninter, @mewwts, @Mirac1eSky, @MorAlekss, @mrhwick, @mrunmayee17, @mssteuer, @Nanako0129,
|
||||
@nazirulhafiy, @Nerijusas, @Nicecsh, @nicoloboschi, @nightq, @ningfangbin, @octo-patch, @Octopus,
|
||||
@OutThisLife, @Paperclip, @pein892, @perlowja, @prasadus92, @qike-ms, @qiyin-code, @Readon, @ReginaldasR,
|
||||
@revaraver, @rfilgueiras, @rmoen, @romanornr, @rugvedS07, @rylena, @samrusani, @Sanjays2402, @sasha-id,
|
||||
@Satoshi-agi, @scheidti, @scotttrinh, @season179, @SeeYangZhi, @sgaofen, @shamork, @shannonsands, @SHL0MS,
|
||||
@simbam99, @Societus, @socrates1024, @Sonoyunchu, @sprmn24, @stephenschoettler, @tangyuanjc, @TechPrototyper,
|
||||
@tekgnosis-net, @ThomassJonax, @tmimmanuel, @tochukwuada, @Tosko4, @Tranquil-Flow, @twozle, @txbxxx,
|
||||
@UgwujaGeorge, @Versun, @vlwkaos, @voidborne-d, @vominh1919, @Wang-tianhao, @Wangshengyang2004, @web3blind,
|
||||
@westers, @Wysie, @xandersbell, @xiahu88988, @XieNBi, @xinbenlv, @xnbi, @y0shua1ee, @yatesjalex, @yes999zc,
|
||||
@yeyitech, @Yoimex, @YueLich, @Yukipukii1, @zhiyanliu, @zicochaos, @Zjianru, @zkl2333, @zons-zhaozhy,
|
||||
@ztexydt-cqh.
|
||||
|
||||
Also: @Siddharth Balyan, @YuShu.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.4.23...v2026.4.30](https://github.com/NousResearch/hermes-agent/compare/v2026.4.23...v2026.4.30)
|
||||
+144
-6
@@ -13,6 +13,7 @@ from typing import Any, Deque, Optional
|
||||
import acp
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AgentMessageChunk,
|
||||
AuthenticateResponse,
|
||||
AvailableCommand,
|
||||
AvailableCommandsUpdate,
|
||||
@@ -30,6 +31,7 @@ from acp.schema import (
|
||||
McpServerStdio,
|
||||
ModelInfo,
|
||||
NewSessionResponse,
|
||||
PromptCapabilities,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionResponse,
|
||||
@@ -45,6 +47,7 @@ from acp.schema import (
|
||||
TextContentBlock,
|
||||
UnstructuredCommandInput,
|
||||
Usage,
|
||||
UserMessageChunk,
|
||||
)
|
||||
|
||||
# AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0
|
||||
@@ -88,17 +91,69 @@ def _extract_text(
|
||||
| EmbeddedResourceContentBlock
|
||||
],
|
||||
) -> str:
|
||||
"""Extract plain text from ACP content blocks."""
|
||||
"""Extract plain text from ACP content blocks for display/commands."""
|
||||
parts: list[str] = []
|
||||
for block in prompt:
|
||||
if isinstance(block, TextContentBlock):
|
||||
parts.append(block.text)
|
||||
elif hasattr(block, "text"):
|
||||
parts.append(str(block.text))
|
||||
# Non-text blocks are ignored for now.
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _image_block_to_openai_part(block: ImageContentBlock) -> dict[str, Any] | None:
|
||||
"""Convert an ACP image content block to OpenAI-style multimodal content."""
|
||||
data = str(getattr(block, "data", "") or "").strip()
|
||||
uri = str(getattr(block, "uri", "") or "").strip()
|
||||
mime_type = str(getattr(block, "mime_type", "") or "image/png").strip() or "image/png"
|
||||
|
||||
if data:
|
||||
url = data if data.startswith("data:") else f"data:{mime_type};base64,{data}"
|
||||
elif uri:
|
||||
url = uri
|
||||
else:
|
||||
return None
|
||||
|
||||
return {"type": "image_url", "image_url": {"url": url}}
|
||||
|
||||
|
||||
def _content_blocks_to_openai_user_content(
|
||||
prompt: list[
|
||||
TextContentBlock
|
||||
| ImageContentBlock
|
||||
| AudioContentBlock
|
||||
| ResourceContentBlock
|
||||
| EmbeddedResourceContentBlock
|
||||
],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Convert ACP prompt blocks into a Hermes/OpenAI-compatible user content payload."""
|
||||
parts: list[dict[str, Any]] = []
|
||||
text_parts: list[str] = []
|
||||
|
||||
for block in prompt:
|
||||
if isinstance(block, TextContentBlock):
|
||||
if block.text:
|
||||
parts.append({"type": "text", "text": block.text})
|
||||
text_parts.append(block.text)
|
||||
continue
|
||||
if isinstance(block, ImageContentBlock):
|
||||
image_part = _image_block_to_openai_part(block)
|
||||
if image_part is not None:
|
||||
parts.append(image_part)
|
||||
continue
|
||||
|
||||
if not parts:
|
||||
return _extract_text(prompt)
|
||||
|
||||
# Keep pure text prompts as strings so slash-command handling and text-only
|
||||
# providers keep the exact legacy path. Switch to structured content only
|
||||
# when an actual non-text block is present.
|
||||
if all(part.get("type") == "text" for part in parts):
|
||||
return "\n".join(text_parts)
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
class HermesACPAgent(acp.Agent):
|
||||
"""ACP Agent implementation wrapping Hermes AIAgent."""
|
||||
|
||||
@@ -352,6 +407,7 @@ class HermesACPAgent(acp.Agent):
|
||||
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
|
||||
agent_capabilities=AgentCapabilities(
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(image=True),
|
||||
session_capabilities=SessionCapabilities(
|
||||
fork=SessionForkCapabilities(),
|
||||
list=SessionListCapabilities(),
|
||||
@@ -377,6 +433,78 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
# ---- Session management -------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _history_message_text(message: dict[str, Any]) -> str:
|
||||
"""Extract displayable text from a persisted OpenAI-style message."""
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
elif item.get("type") == "text" and isinstance(item.get("content"), str):
|
||||
parts.append(item["content"])
|
||||
elif isinstance(item, str):
|
||||
parts.append(item)
|
||||
return "\n".join(part.strip() for part in parts if part and part.strip()).strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _history_message_update(
|
||||
*,
|
||||
role: str,
|
||||
text: str,
|
||||
) -> UserMessageChunk | AgentMessageChunk | None:
|
||||
"""Build an ACP history replay update for a user/assistant message."""
|
||||
block = TextContentBlock(type="text", text=text)
|
||||
if role == "user":
|
||||
return UserMessageChunk(
|
||||
session_update="user_message_chunk",
|
||||
content=block,
|
||||
)
|
||||
if role == "assistant":
|
||||
return AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=block,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _replay_session_history(self, state: SessionState) -> None:
|
||||
"""Send persisted user/assistant history to clients during session/load.
|
||||
|
||||
Zed's ACP history UI calls ``session/load`` after the user picks an item
|
||||
from the Agents sidebar. The agent must then replay the full conversation
|
||||
as ``user_message_chunk`` / ``agent_message_chunk`` notifications; merely
|
||||
restoring server-side state makes Hermes remember context, but leaves the
|
||||
editor looking like a clean thread.
|
||||
"""
|
||||
if not self._conn or not state.history:
|
||||
return
|
||||
|
||||
for message in state.history:
|
||||
role = str(message.get("role") or "")
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
text = self._history_message_text(message)
|
||||
if not text:
|
||||
continue
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
if update is None:
|
||||
continue
|
||||
try:
|
||||
await self._conn.session_update(session_id=state.session_id, update=update)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to replay ACP history for session %s",
|
||||
state.session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
async def new_session(
|
||||
self,
|
||||
cwd: str,
|
||||
@@ -405,6 +533,7 @@ class HermesACPAgent(acp.Agent):
|
||||
return None
|
||||
await self._register_session_mcp_servers(state, mcp_servers)
|
||||
logger.info("Loaded session %s", session_id)
|
||||
await self._replay_session_history(state)
|
||||
self._schedule_available_commands_update(session_id)
|
||||
return LoadSessionResponse(models=self._build_model_state(state))
|
||||
|
||||
@@ -421,6 +550,7 @@ class HermesACPAgent(acp.Agent):
|
||||
state = self.session_manager.create_session(cwd=cwd)
|
||||
await self._register_session_mcp_servers(state, mcp_servers)
|
||||
logger.info("Resumed session %s", state.session_id)
|
||||
await self._replay_session_history(state)
|
||||
self._schedule_available_commands_update(state.session_id)
|
||||
return ResumeSessionResponse(models=self._build_model_state(state))
|
||||
|
||||
@@ -517,11 +647,18 @@ class HermesACPAgent(acp.Agent):
|
||||
return PromptResponse(stop_reason="refusal")
|
||||
|
||||
user_text = _extract_text(prompt).strip()
|
||||
if not user_text:
|
||||
user_content = _content_blocks_to_openai_user_content(prompt)
|
||||
has_content = bool(user_text) or (
|
||||
isinstance(user_content, list) and bool(user_content)
|
||||
)
|
||||
if not has_content:
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
# Intercept slash commands — handle locally without calling the LLM
|
||||
if user_text.startswith("/"):
|
||||
# Intercept slash commands — handle locally without calling the LLM.
|
||||
# Slash commands are text-only; if the client included images/resources,
|
||||
# send the whole multimodal prompt to the agent instead of treating it as
|
||||
# an ACP command.
|
||||
if isinstance(user_content, str) and user_text.startswith("/"):
|
||||
response_text = self._handle_slash_command(user_text, state)
|
||||
if response_text is not None:
|
||||
if self._conn:
|
||||
@@ -604,9 +741,10 @@ class HermesACPAgent(acp.Agent):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
try:
|
||||
result = agent.run_conversation(
|
||||
user_message=user_text,
|
||||
user_message=user_content,
|
||||
conversation_history=state.history,
|
||||
task_id=session_id,
|
||||
persist_user_message=user_text or "[Image attachment]",
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
+222
-93
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from utils import normalize_proxy_env_vars
|
||||
from utils import base_url_host_matches, normalize_proxy_env_vars
|
||||
|
||||
# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls
|
||||
# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.)
|
||||
@@ -257,11 +257,10 @@ _OAUTH_ONLY_BETAS = [
|
||||
"oauth-2025-04-20",
|
||||
]
|
||||
|
||||
# Claude Code version — sent on OAuth token-exchange / refresh requests
|
||||
# (platform.claude.com/v1/oauth/token) as the client's user-agent. Anthropic's
|
||||
# OAuth flow validates the UA and may reject requests with a version that's
|
||||
# too old, so detecting dynamically keeps users on a current Claude Code
|
||||
# install from hitting stale-version errors during login/refresh.
|
||||
# Claude Code identity — required for OAuth requests to be routed correctly.
|
||||
# Without these, Anthropic's infrastructure intermittently 500s OAuth traffic.
|
||||
# The version must stay reasonably current — Anthropic rejects OAuth requests
|
||||
# when the spoofed user-agent version is too far behind the actual release.
|
||||
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
|
||||
_claude_code_version_cache: Optional[str] = None
|
||||
|
||||
@@ -269,9 +268,9 @@ _claude_code_version_cache: Optional[str] = None
|
||||
def _detect_claude_code_version() -> str:
|
||||
"""Detect the installed Claude Code version, fall back to a static constant.
|
||||
|
||||
Used only by the OAuth token-exchange / refresh flow
|
||||
(``platform.claude.com/v1/oauth/token``). The Messages API client no
|
||||
longer sends a claude-cli user-agent.
|
||||
Anthropic's OAuth infrastructure validates the user-agent version and may
|
||||
reject requests with a version that's too old. Detecting dynamically means
|
||||
users who keep Claude Code updated never hit stale-version 400s.
|
||||
"""
|
||||
import subprocess as _sp
|
||||
|
||||
@@ -291,13 +290,12 @@ def _detect_claude_code_version() -> str:
|
||||
return _CLAUDE_CODE_VERSION_FALLBACK
|
||||
|
||||
|
||||
def _get_claude_code_version() -> str:
|
||||
"""Lazily detect the installed Claude Code version for OAuth flow headers.
|
||||
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
_MCP_TOOL_PREFIX = "mcp_"
|
||||
|
||||
Used only on the OAuth token-exchange and refresh endpoints
|
||||
(``platform.claude.com/v1/oauth/token``). The Messages API client does
|
||||
not send a claude-cli user-agent.
|
||||
"""
|
||||
|
||||
def _get_claude_code_version() -> str:
|
||||
"""Lazily detect the installed Claude Code version when OAuth headers need it."""
|
||||
global _claude_code_version_cache
|
||||
if _claude_code_version_cache is None:
|
||||
_claude_code_version_cache = _detect_claude_code_version()
|
||||
@@ -367,6 +365,88 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
|
||||
return normalized.rstrip("/").lower().startswith("https://api.kimi.com/coding")
|
||||
|
||||
|
||||
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
|
||||
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
|
||||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
|
||||
# Matched case-insensitively against the post-``normalize_model_name`` form,
|
||||
# so a caller's ``provider/vendor/model`` slug is handled the same as a
|
||||
# bare name.
|
||||
_KIMI_FAMILY_MODEL_PREFIXES = (
|
||||
"kimi-", "kimi_",
|
||||
"moonshot-", "moonshot_",
|
||||
"k1.", "k1-",
|
||||
"k2.", "k2-",
|
||||
"k25", "k2.5",
|
||||
)
|
||||
|
||||
|
||||
def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
if not isinstance(model, str):
|
||||
return False
|
||||
m = model.strip().lower()
|
||||
if not m:
|
||||
return False
|
||||
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
|
||||
if "/" in m:
|
||||
m = m.rsplit("/", 1)[-1]
|
||||
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
|
||||
|
||||
|
||||
def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> bool:
|
||||
"""Return True for any Kimi / Moonshot Anthropic-Messages-speaking endpoint.
|
||||
|
||||
Broader than ``_is_kimi_coding_endpoint`` — matches:
|
||||
|
||||
- Kimi's official ``/coding`` URL (legacy check, preserved)
|
||||
- Any ``api.kimi.com`` / ``moonshot.ai`` / ``moonshot.cn`` host
|
||||
- Custom or proxied endpoints whose *model* name is in the Kimi / Moonshot
|
||||
family (``kimi-*``, ``moonshot-*``, ``k1.*``, ``k2.*``, …). Users with
|
||||
``api_mode: anthropic_messages`` on a private gateway fronting Kimi
|
||||
fall into this branch — the upstream still enforces Kimi's thinking
|
||||
semantics (reasoning_content required on every replayed tool-call
|
||||
message) regardless of the gateway's hostname.
|
||||
|
||||
Used to decide whether to drop Anthropic's ``thinking`` kwarg and to
|
||||
preserve unsigned reasoning_content-derived thinking blocks on replay.
|
||||
See hermes-agent#13848, #17057.
|
||||
"""
|
||||
if _is_kimi_coding_endpoint(base_url):
|
||||
return True
|
||||
for _domain in ("api.kimi.com", "moonshot.ai", "moonshot.cn"):
|
||||
if base_url_host_matches(base_url or "", _domain):
|
||||
return True
|
||||
if _model_name_is_kimi_family(model):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for DeepSeek's Anthropic-compatible endpoint.
|
||||
|
||||
DeepSeek's ``/anthropic`` route speaks the Anthropic Messages protocol
|
||||
but, when thinking mode is enabled, requires the ``thinking`` blocks
|
||||
from prior assistant turns to round-trip on subsequent requests — the
|
||||
generic third-party path strips them and triggers HTTP 400::
|
||||
|
||||
The content[].thinking in the thinking mode must be passed back
|
||||
to the API.
|
||||
|
||||
Per DeepSeek's published compatibility matrix the blocks are unsigned
|
||||
(no Anthropic-proprietary signature, no ``redacted_thinking`` support),
|
||||
so this endpoint is handled with the same strip-signed / keep-unsigned
|
||||
policy used for Kimi's ``/coding`` endpoint. The match is pinned to
|
||||
the ``/anthropic`` path so the OpenAI-compatible ``api.deepseek.com``
|
||||
base URL (which never reaches this adapter) is not misclassified.
|
||||
See hermes-agent#16748.
|
||||
"""
|
||||
if not base_url_host_matches(base_url or "", "api.deepseek.com"):
|
||||
return False
|
||||
normalized = _normalize_base_url_text(base_url)
|
||||
if not normalized:
|
||||
return False
|
||||
return "/anthropic" in normalized.rstrip("/").lower()
|
||||
|
||||
|
||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
||||
|
||||
@@ -381,7 +461,11 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
|
||||
|
||||
|
||||
def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
def _common_betas_for_base_url(
|
||||
base_url: str | None,
|
||||
*,
|
||||
drop_context_1m_beta: bool = False,
|
||||
) -> list[str]:
|
||||
"""Return the beta headers that are safe for the configured endpoint.
|
||||
|
||||
MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests
|
||||
@@ -392,14 +476,30 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
|
||||
endpoints — MiniMax hosts its own models, not Claude, so the header is
|
||||
irrelevant at best and risks request rejection at worst.
|
||||
|
||||
``drop_context_1m_beta=True`` additionally strips the 1M-context beta on
|
||||
otherwise-unrelated endpoints. The OAuth retry path flips this flag after
|
||||
a subscription rejects the beta with
|
||||
"The long context beta is not yet available for this subscription" so
|
||||
subsequent requests in the same session don't repeat the probe. See the
|
||||
reactive recovery loop in ``run_agent.py`` and issue-comment history on
|
||||
PR #17680 for the full rationale.
|
||||
"""
|
||||
if _requires_bearer_auth(base_url):
|
||||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||||
return [b for b in _COMMON_BETAS if b not in _stripped]
|
||||
if drop_context_1m_beta:
|
||||
return [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = None):
|
||||
def build_anthropic_client(
|
||||
api_key: str,
|
||||
base_url: str = None,
|
||||
timeout: float = None,
|
||||
*,
|
||||
drop_context_1m_beta: bool = False,
|
||||
):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
If *timeout* is provided it overrides the default 900s read timeout. The
|
||||
@@ -408,6 +508,12 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
|
||||
Anthropic-compatible providers respect the same knob as OpenAI-wire
|
||||
providers.
|
||||
|
||||
``drop_context_1m_beta=True`` strips ``context-1m-2025-08-07`` from the
|
||||
client-level ``anthropic-beta`` header. Used by the reactive OAuth retry
|
||||
path in ``run_agent.py`` when a subscription rejects the beta; leave at
|
||||
its default on fresh clients so 1M-capable subscriptions keep the
|
||||
capability.
|
||||
|
||||
Returns an anthropic.Anthropic instance.
|
||||
"""
|
||||
_anthropic_sdk = _get_anthropic_sdk()
|
||||
@@ -437,7 +543,10 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
|
||||
kwargs["default_query"] = {"api-version": "2025-04-15"}
|
||||
else:
|
||||
kwargs["base_url"] = normalized_base_url
|
||||
common_betas = _common_betas_for_base_url(normalized_base_url)
|
||||
common_betas = _common_betas_for_base_url(
|
||||
normalized_base_url,
|
||||
drop_context_1m_beta=drop_context_1m_beta,
|
||||
)
|
||||
|
||||
if _is_kimi_coding_endpoint(base_url):
|
||||
# Kimi's /coding endpoint requires User-Agent: claude-code/0.1.0
|
||||
@@ -467,21 +576,15 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
|
||||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
elif _is_oauth_token(api_key):
|
||||
# OAuth access token / setup-token → Bearer auth + OAuth-only betas.
|
||||
# The OAuth-specific beta headers are still required by Anthropic's
|
||||
# OAuth-gated Messages API path; the Claude Code user-agent / x-app
|
||||
# spoofing is deliberately NOT sent — Hermes identifies as itself.
|
||||
#
|
||||
# ``context-1m-2025-08-07`` is stripped here: Anthropic rejects
|
||||
# OAuth requests that carry it with
|
||||
# "This authentication style is incompatible with the long
|
||||
# context beta header."
|
||||
# Subscription-gated OAuth traffic gets the 200K default window.
|
||||
oauth_safe_common = [b for b in common_betas if b != _CONTEXT_1M_BETA]
|
||||
all_betas = oauth_safe_common + _OAUTH_ONLY_BETAS
|
||||
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
||||
# Anthropic routes OAuth requests based on user-agent and headers;
|
||||
# without Claude Code's fingerprint, requests get intermittent 500s.
|
||||
all_betas = common_betas + _OAUTH_ONLY_BETAS
|
||||
kwargs["auth_token"] = api_key
|
||||
kwargs["default_headers"] = {
|
||||
"anthropic-beta": ",".join(all_betas),
|
||||
"user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
}
|
||||
else:
|
||||
# Regular API key → x-api-key header + common betas
|
||||
@@ -825,45 +928,17 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
"""Resolve an Anthropic token from all available sources.
|
||||
|
||||
Priority:
|
||||
1. Hermes credential pool (``~/.hermes/auth.json`` →
|
||||
``credential_pool.anthropic``) — OAuth tokens minted by Hermes'
|
||||
own PKCE login flow. Entries are auto-refreshed when near
|
||||
expiry. Env-sourced pool entries (``source="env:..."``) are
|
||||
skipped here so the env-var priority logic below still runs.
|
||||
2. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
||||
3. CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
4. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||
1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
||||
2. CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||
— with automatic refresh if expired and a refresh token is available
|
||||
5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
||||
4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
||||
|
||||
Returns the token string or None.
|
||||
"""
|
||||
# 1. Hermes credential pool — the live source of truth for tokens
|
||||
# minted via ``hermes login anthropic`` / the dashboard PKCE flow.
|
||||
# ``select()`` picks the best available entry and refreshes it if
|
||||
# it's near expiry, so callers always get a fresh token.
|
||||
#
|
||||
# Skip env-sourced pool entries (``env:ANTHROPIC_TOKEN``, etc.) —
|
||||
# those are passthroughs of the env var, and the env-var branches
|
||||
# below have richer priority logic (``_prefer_refreshable_claude_code_token``)
|
||||
# that can upgrade a static env OAuth token to a refreshed
|
||||
# Claude Code token. Letting the pool win here would short-circuit
|
||||
# that upgrade.
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("anthropic")
|
||||
entry = pool.select()
|
||||
if entry and entry.access_token and not entry.source.startswith("env:"):
|
||||
return entry.access_token
|
||||
except Exception as exc:
|
||||
# Pool lookup is best-effort — fall through to env/file sources
|
||||
# if anything goes wrong (e.g. auth.json corruption during a
|
||||
# concurrent write).
|
||||
logger.debug("Credential-pool lookup failed for anthropic: %s", exc)
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
|
||||
# 2. Hermes-managed OAuth/setup token env var
|
||||
# 1. Hermes-managed OAuth/setup token env var
|
||||
token = os.getenv("ANTHROPIC_TOKEN", "").strip()
|
||||
if token:
|
||||
preferred = _prefer_refreshable_claude_code_token(token, creds)
|
||||
@@ -871,7 +946,7 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
return preferred
|
||||
return token
|
||||
|
||||
# 3. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
||||
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
||||
cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
||||
if cc_token:
|
||||
preferred = _prefer_refreshable_claude_code_token(cc_token, creds)
|
||||
@@ -879,12 +954,12 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
return preferred
|
||||
return cc_token
|
||||
|
||||
# 4. Claude Code credential file
|
||||
# 3. Claude Code credential file
|
||||
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
|
||||
if resolved_claude_token:
|
||||
return resolved_claude_token
|
||||
|
||||
# 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
if api_key:
|
||||
@@ -1112,9 +1187,12 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
|
||||
# These must not be converted to hyphens. See issue #12295.
|
||||
if _is_bedrock_model_id(model):
|
||||
return model
|
||||
# OpenRouter uses dots for version separators (claude-opus-4.6),
|
||||
# Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens.
|
||||
model = model.replace(".", "-")
|
||||
# Only convert dots to hyphens for Anthropic/Claude models.
|
||||
# Non-Anthropic models (gpt-5.4, gemini-2.5, etc.) use dots
|
||||
# as part of their canonical names. See issue #17171.
|
||||
_lower = model.lower()
|
||||
if _lower.startswith("claude-") or _lower.startswith("anthropic/"):
|
||||
model = model.replace(".", "-")
|
||||
return model
|
||||
|
||||
|
||||
@@ -1301,6 +1379,7 @@ def _convert_content_to_anthropic(content: Any) -> Any:
|
||||
def convert_messages_to_anthropic(
|
||||
messages: List[Dict],
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> Tuple[Optional[Any], List[Dict]]:
|
||||
"""Convert OpenAI-format messages to Anthropic format.
|
||||
|
||||
@@ -1312,6 +1391,12 @@ def convert_messages_to_anthropic(
|
||||
endpoint, all thinking block signatures are stripped. Signatures are
|
||||
Anthropic-proprietary — third-party endpoints cannot validate them and will
|
||||
reject them with HTTP 400 "Invalid signature in thinking block".
|
||||
|
||||
When *model* is provided and matches the Kimi / Moonshot family (or
|
||||
*base_url* is a Kimi / Moonshot host), unsigned thinking blocks
|
||||
synthesised from ``reasoning_content`` are preserved on replayed
|
||||
assistant tool-call messages — Kimi requires the field to exist, even
|
||||
if empty.
|
||||
"""
|
||||
system = None
|
||||
result = []
|
||||
@@ -1540,7 +1625,16 @@ def convert_messages_to_anthropic(
|
||||
# cache markers can interfere with signature validation.
|
||||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||||
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
|
||||
_is_kimi = _is_kimi_coding_endpoint(base_url)
|
||||
# Kimi /coding and DeepSeek /anthropic share a contract: both speak the
|
||||
# Anthropic Messages protocol upstream but require that thinking blocks
|
||||
# synthesised from reasoning_content round-trip on subsequent turns when
|
||||
# thinking is enabled. Signed Anthropic blocks still have to be stripped
|
||||
# (neither endpoint can validate Anthropic's signatures); unsigned blocks
|
||||
# are preserved. See hermes-agent#13848 (Kimi) and #16748 (DeepSeek).
|
||||
_preserve_unsigned_thinking = (
|
||||
_is_kimi_family_endpoint(base_url, model)
|
||||
or _is_deepseek_anthropic_endpoint(base_url)
|
||||
)
|
||||
|
||||
last_assistant_idx = None
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
@@ -1552,22 +1646,22 @@ def convert_messages_to_anthropic(
|
||||
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
|
||||
if _is_kimi:
|
||||
# Kimi's /coding endpoint enables thinking server-side and
|
||||
# requires unsigned thinking blocks on replayed assistant
|
||||
# tool-call messages. Strip signed Anthropic blocks (Kimi
|
||||
# can't validate signatures) but preserve the unsigned ones
|
||||
# we synthesised from reasoning_content above.
|
||||
if _preserve_unsigned_thinking:
|
||||
# Kimi's /coding and DeepSeek's /anthropic endpoints both enable
|
||||
# thinking server-side and require unsigned thinking blocks on
|
||||
# replayed assistant tool-call messages. Strip signed Anthropic
|
||||
# blocks (neither upstream can validate Anthropic signatures) but
|
||||
# preserve the unsigned ones we synthesised from reasoning_content.
|
||||
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 b.get("signature") or b.get("data"):
|
||||
# Anthropic-signed block — Kimi can't validate, strip
|
||||
# Anthropic-signed block — upstream can't validate, strip
|
||||
continue
|
||||
# Unsigned thinking (synthesised from reasoning_content) —
|
||||
# keep it: Kimi needs it for message-history validation.
|
||||
# keep it: the upstream needs it for message-history validation.
|
||||
new_content.append(b)
|
||||
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
|
||||
elif _is_third_party or idx != last_assistant_idx:
|
||||
@@ -1624,6 +1718,7 @@ def build_anthropic_kwargs(
|
||||
context_length: Optional[int] = None,
|
||||
base_url: str | None = None,
|
||||
fast_mode: bool = False,
|
||||
drop_context_1m_beta: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build kwargs for anthropic.messages.create().
|
||||
|
||||
@@ -1649,10 +1744,8 @@ def build_anthropic_kwargs(
|
||||
"max_tokens too large given prompt" errors and retry with a smaller cap
|
||||
(see parse_available_output_tokens_from_error + _ephemeral_max_output_tokens).
|
||||
|
||||
When *is_oauth* is True, enables the OAuth-only beta headers required by
|
||||
Anthropic's subscription-gated Messages endpoint (fast-mode branch only;
|
||||
the default headers are set by build_anthropic_client). No system-prompt
|
||||
or tool-name rewriting is performed — Hermes identifies as itself.
|
||||
When *is_oauth* is True, applies Claude Code compatibility transforms:
|
||||
system prompt prefix, tool name prefixing, and prompt sanitization.
|
||||
|
||||
When *preserve_dots* is True, model name dots are not converted to hyphens
|
||||
(for Alibaba/DashScope anthropic-compatible endpoints: qwen3.5-plus).
|
||||
@@ -1665,7 +1758,9 @@ def build_anthropic_kwargs(
|
||||
Currently only supported on native Anthropic endpoints (not third-party
|
||||
compatible ones).
|
||||
"""
|
||||
system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url)
|
||||
system, anthropic_messages = convert_messages_to_anthropic(
|
||||
messages, base_url=base_url, model=model
|
||||
)
|
||||
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
|
||||
|
||||
model = normalize_model_name(model, preserve_dots=preserve_dots)
|
||||
@@ -1685,11 +1780,45 @@ def build_anthropic_kwargs(
|
||||
if context_length and effective_max_tokens > context_length:
|
||||
effective_max_tokens = max(context_length - 1, 1)
|
||||
|
||||
# OAuth requests go through Anthropic's subscription-gated Messages
|
||||
# endpoint but otherwise send the real Hermes system prompt and real
|
||||
# Hermes tool names — the only OAuth-specific wire differences are
|
||||
# Bearer auth and the _OAUTH_ONLY_BETAS header (applied in
|
||||
# build_anthropic_client and the fast-mode branch below).
|
||||
# ── OAuth: Claude Code identity ──────────────────────────────────
|
||||
if is_oauth:
|
||||
# 1. Prepend Claude Code system prompt identity
|
||||
cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX}
|
||||
if isinstance(system, list):
|
||||
system = [cc_block] + system
|
||||
elif isinstance(system, str) and system:
|
||||
system = [cc_block, {"type": "text", "text": system}]
|
||||
else:
|
||||
system = [cc_block]
|
||||
|
||||
# 2. Sanitize system prompt — replace product name references
|
||||
# to avoid Anthropic's server-side content filters.
|
||||
for block in system:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "")
|
||||
text = text.replace("Hermes Agent", "Claude Code")
|
||||
text = text.replace("Hermes agent", "Claude Code")
|
||||
text = text.replace("hermes-agent", "claude-code")
|
||||
text = text.replace("Nous Research", "Anthropic")
|
||||
block["text"] = text
|
||||
|
||||
# 3. Prefix tool names with mcp_ (Claude Code convention)
|
||||
if anthropic_tools:
|
||||
for tool in anthropic_tools:
|
||||
if "name" in tool:
|
||||
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
|
||||
|
||||
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
|
||||
for msg in anthropic_messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "tool_use" and "name" in block:
|
||||
if not block["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
block["name"] = _MCP_TOOL_PREFIX + block["name"]
|
||||
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
||||
pass # tool_result uses ID, not name
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
@@ -1737,7 +1866,7 @@ def build_anthropic_kwargs(
|
||||
# silently hides reasoning text that Hermes surfaces in its CLI. We
|
||||
# request "summarized" so the reasoning blocks stay populated — matching
|
||||
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
|
||||
_is_kimi_coding = _is_kimi_coding_endpoint(base_url)
|
||||
_is_kimi_coding = _is_kimi_family_endpoint(base_url, model)
|
||||
if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding:
|
||||
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
|
||||
effort = str(reasoning_config.get("effort", "medium")).lower()
|
||||
@@ -1778,11 +1907,11 @@ def build_anthropic_kwargs(
|
||||
kwargs.setdefault("extra_body", {})["speed"] = "fast"
|
||||
# Build extra_headers with ALL applicable betas (the per-request
|
||||
# extra_headers override the client-level anthropic-beta header).
|
||||
betas = list(_common_betas_for_base_url(base_url))
|
||||
betas = list(_common_betas_for_base_url(
|
||||
base_url,
|
||||
drop_context_1m_beta=drop_context_1m_beta,
|
||||
))
|
||||
if is_oauth:
|
||||
# Strip context-1m — incompatible with OAuth auth. See matching
|
||||
# comment in build_anthropic_client().
|
||||
betas = [b for b in betas if b != _CONTEXT_1M_BETA]
|
||||
betas.extend(_OAUTH_ONLY_BETAS)
|
||||
betas.append(_FAST_MODE_BETA)
|
||||
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
|
||||
|
||||
+129
-45
@@ -5,11 +5,11 @@ session search, web extraction, vision analysis, browser vision) picks up
|
||||
the best available backend without duplicating fallback logic.
|
||||
|
||||
Resolution order for text tasks (auto mode):
|
||||
1. OpenRouter (OPENROUTER_API_KEY)
|
||||
2. Nous Portal (~/.hermes/auth.json active provider)
|
||||
3. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
|
||||
4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex,
|
||||
wrapped to look like a chat.completions client)
|
||||
1. User's main provider + main model (used regardless of provider type —
|
||||
aggregators, direct API-key providers, native Anthropic, Codex, etc.)
|
||||
2. OpenRouter (OPENROUTER_API_KEY)
|
||||
3. Nous Portal (~/.hermes/auth.json active provider)
|
||||
4. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
|
||||
5. Native Anthropic
|
||||
6. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN)
|
||||
7. None
|
||||
@@ -18,10 +18,16 @@ Resolution order for vision/multimodal tasks (auto mode):
|
||||
1. Selected main provider, if it is one of the supported vision backends below
|
||||
2. OpenRouter
|
||||
3. Nous Portal
|
||||
4. Codex OAuth (gpt-5.3-codex supports vision via Responses API)
|
||||
5. Native Anthropic
|
||||
6. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
|
||||
7. None
|
||||
4. Native Anthropic
|
||||
5. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
|
||||
6. None
|
||||
|
||||
Codex OAuth (ChatGPT-account auth) is intentionally NOT in either
|
||||
fallback chain: OpenAI gates this endpoint behind an undocumented,
|
||||
shifting model allow-list, so "just try Codex with a hardcoded model"
|
||||
rots on its own. Codex is used only when the user's main provider *is*
|
||||
openai-codex (Step 1 above) or when a caller explicitly requests it with
|
||||
a model (auxiliary.<task>.provider + auxiliary.<task>.model).
|
||||
|
||||
Per-task overrides are configured in config.yaml under the ``auxiliary:`` section
|
||||
(e.g. ``auxiliary.vision.provider``, ``auxiliary.compression.model``).
|
||||
@@ -101,6 +107,14 @@ from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
|
||||
"""Return False instead of raising when a patched symbol is not a type."""
|
||||
try:
|
||||
return isinstance(obj, maybe_type)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def _extract_url_query_params(url: str):
|
||||
"""Extract query params from URL, return (clean_url, default_query dict or None)."""
|
||||
parsed = urlparse(url)
|
||||
@@ -210,6 +224,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"kimi-coding-cn": "kimi-k2-turbo-preview",
|
||||
"gmi": "google/gemini-3.1-flash-lite-preview",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"minimax-oauth": "MiniMax-M2.7-highspeed",
|
||||
"minimax-cn": "MiniMax-M2.7",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"ai-gateway": "google/gemini-3-flash",
|
||||
@@ -229,6 +244,21 @@ _PROVIDER_VISION_MODELS: Dict[str, str] = {
|
||||
"zai": "glm-5v-turbo",
|
||||
}
|
||||
|
||||
# Providers whose endpoint does not accept image input, even though the
|
||||
# provider's broader ecosystem has vision models available elsewhere. When
|
||||
# `auxiliary.vision.provider: auto` sees one of these as the main provider,
|
||||
# it must skip straight to the aggregator chain instead of returning a client
|
||||
# that will 404 on every vision request.
|
||||
#
|
||||
# kimi-coding / kimi-coding-cn: the Kimi Coding Plan routes through
|
||||
# api.kimi.com/coding (Anthropic Messages wire) which Kimi's own docs
|
||||
# describe as having no image_in capability. Vision lives on the separate
|
||||
# Kimi Platform (api.moonshot.ai, OpenAI-wire, pay-as-you-go). See #17076.
|
||||
_PROVIDERS_WITHOUT_VISION: frozenset = frozenset({
|
||||
"kimi-coding",
|
||||
"kimi-coding-cn",
|
||||
})
|
||||
|
||||
# OpenRouter app attribution headers
|
||||
_OR_HEADERS = {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
@@ -261,12 +291,14 @@ _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
|
||||
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
|
||||
|
||||
# Codex fallback: uses the Responses API (the only endpoint the Codex
|
||||
# OAuth token can access) with a fast model for auxiliary tasks.
|
||||
# ChatGPT-backed Codex accounts currently reject gpt-5.3-codex for these
|
||||
# auxiliary flows, while gpt-5.2-codex remains broadly available and supports
|
||||
# vision via Responses.
|
||||
_CODEX_AUX_MODEL = "gpt-5.2-codex"
|
||||
# Codex OAuth endpoint used when a caller explicitly requests
|
||||
# provider="openai-codex". There is deliberately no hardcoded default
|
||||
# model: the set of models OpenAI accepts on this endpoint for
|
||||
# ChatGPT-account auth is an undocumented, shifting allow-list, and
|
||||
# pinning one here has drifted silently twice (gpt-5.3-codex → gpt-5.2-codex
|
||||
# → gpt-5.4 over 6 weeks in early 2026). Callers must pass the model
|
||||
# they want explicitly (from config.yaml model.model, auxiliary.<task>.model,
|
||||
# or the user's active Codex model selection).
|
||||
_CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
@@ -323,6 +355,13 @@ def _to_openai_base_url(base_url: str) -> str:
|
||||
rewritten = url[: -len("/anthropic")] + "/v1"
|
||||
logger.debug("Auxiliary client: rewrote base URL %s → %s", url, rewritten)
|
||||
return rewritten
|
||||
if "api.kimi.com" in url and url.endswith("/coding"):
|
||||
# Kimi Code uses /coding/v1/messages for Anthropic SDK (appends /v1/messages)
|
||||
# but /coding/v1/chat/completions for OpenAI SDK (appends /chat/completions)
|
||||
# Without /v1 here, OpenAI SDK hits /coding/chat/completions — a 404.
|
||||
rewritten = url + "/v1"
|
||||
logger.debug("Auxiliary client: rewrote Kimi base URL %s → %s", url, rewritten)
|
||||
return rewritten
|
||||
return url
|
||||
|
||||
|
||||
@@ -713,7 +752,9 @@ class _AnthropicCompletionsAdapter:
|
||||
|
||||
response = self._client.messages.create(**anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(response)
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_oauth
|
||||
)
|
||||
|
||||
# ToolCall already duck-types as OpenAI shape (.type, .function.name,
|
||||
# .function.arguments) via properties, so no wrapping needed.
|
||||
@@ -843,20 +884,20 @@ def _maybe_wrap_anthropic(
|
||||
- The ``anthropic`` SDK is not installed (falls back to OpenAI wire).
|
||||
"""
|
||||
# Already wrapped — don't double-wrap.
|
||||
if isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
# Other specialized adapters we should never re-dispatch.
|
||||
if isinstance(client_obj, CodexAuxiliaryClient):
|
||||
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
if isinstance(client_obj, GeminiNativeClient):
|
||||
if _safe_isinstance(client_obj, GeminiNativeClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
if isinstance(client_obj, CopilotACPClient):
|
||||
if _safe_isinstance(client_obj, CopilotACPClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -1052,9 +1093,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if not api_key:
|
||||
continue
|
||||
|
||||
base_url = _to_openai_base_url(
|
||||
_pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
|
||||
)
|
||||
raw_base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
|
||||
base_url = _to_openai_base_url(raw_base_url)
|
||||
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
|
||||
if model is None:
|
||||
continue # skip provider if we don't know a valid aux model
|
||||
@@ -1072,7 +1112,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider_id)
|
||||
@@ -1080,9 +1120,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if not api_key:
|
||||
continue
|
||||
|
||||
base_url = _to_openai_base_url(
|
||||
str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
)
|
||||
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
base_url = _to_openai_base_url(raw_base_url)
|
||||
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
|
||||
if model is None:
|
||||
continue # skip provider if we don't know a valid aux model
|
||||
@@ -1100,7 +1139,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
return None, None
|
||||
@@ -1394,7 +1433,23 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
return _fallback_client, model
|
||||
|
||||
|
||||
def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
|
||||
def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Build a CodexAuxiliaryClient for an explicitly-requested model.
|
||||
|
||||
There is no auto-selection of the Codex model: the ChatGPT-account
|
||||
Codex endpoint's accepted model list is an undocumented, drifting
|
||||
allow-list, so any hardcoded default we pick goes stale. The caller
|
||||
is responsible for passing the model (e.g. from the user's own
|
||||
``model.model`` or ``auxiliary.<task>.model`` config).
|
||||
|
||||
Returns (None, None) when no Codex OAuth token is available.
|
||||
"""
|
||||
if not model:
|
||||
logger.warning(
|
||||
"Auxiliary client: openai-codex requested without a model; "
|
||||
"pass model explicitly (auxiliary.<task>.model in config.yaml)."
|
||||
)
|
||||
return None, None
|
||||
pool_present, entry = _select_pool_entry("openai-codex")
|
||||
if pool_present:
|
||||
codex_token = _pool_runtime_api_key(entry)
|
||||
@@ -1410,13 +1465,13 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
|
||||
if not codex_token:
|
||||
return None, None
|
||||
base_url = _CODEX_AUX_BASE_URL
|
||||
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", _CODEX_AUX_MODEL)
|
||||
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", model)
|
||||
real_client = OpenAI(
|
||||
api_key=codex_token,
|
||||
base_url=base_url,
|
||||
default_headers=_codex_cloudflare_headers(codex_token),
|
||||
)
|
||||
return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
|
||||
|
||||
def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
|
||||
@@ -1471,7 +1526,6 @@ _AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
"_try_custom_endpoint": "local/custom",
|
||||
"_try_codex": "openai-codex",
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
}
|
||||
|
||||
@@ -1498,12 +1552,18 @@ def _get_provider_chain() -> List[tuple]:
|
||||
|
||||
Built at call time (not module level) so that test patches
|
||||
on the ``_try_*`` functions are picked up correctly.
|
||||
|
||||
NOTE: ``openai-codex`` is deliberately NOT in this chain. The
|
||||
ChatGPT-account Codex endpoint only accepts a shifting, undocumented
|
||||
allow-list of model IDs, so falling back to it with a guessed model
|
||||
fails more often than not. Codex is used only when the user's main
|
||||
provider *is* openai-codex (see Step 1 of ``_resolve_auto``) or when
|
||||
a caller explicitly requests it with a model.
|
||||
"""
|
||||
return [
|
||||
("openrouter", _try_openrouter),
|
||||
("nous", _try_nous),
|
||||
("local/custom", _try_custom_endpoint),
|
||||
("openai-codex", _try_codex),
|
||||
("api-key", _resolve_api_key_provider),
|
||||
]
|
||||
|
||||
@@ -2019,6 +2079,13 @@ def resolve_provider_client(
|
||||
|
||||
# ── OpenAI Codex (OAuth → Responses API) ─────────────────────────
|
||||
if provider == "openai-codex":
|
||||
if not model:
|
||||
logger.warning(
|
||||
"resolve_provider_client: openai-codex requested without a "
|
||||
"model; pass model explicitly (e.g. model.model in config.yaml "
|
||||
"or auxiliary.<task>.model for per-task aux routing)."
|
||||
)
|
||||
return None, None
|
||||
if raw_codex:
|
||||
# Return the raw OpenAI client for callers that need direct
|
||||
# access to responses.stream() (e.g., the main agent loop).
|
||||
@@ -2027,7 +2094,7 @@ def resolve_provider_client(
|
||||
logger.warning("resolve_provider_client: openai-codex requested "
|
||||
"but no Codex OAuth token found (run: hermes model)")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or _CODEX_AUX_MODEL, provider)
|
||||
final_model = _normalize_resolved_model(model, provider)
|
||||
raw_client = OpenAI(
|
||||
api_key=codex_token,
|
||||
base_url=_CODEX_AUX_BASE_URL,
|
||||
@@ -2035,7 +2102,7 @@ def resolve_provider_client(
|
||||
)
|
||||
return (raw_client, final_model)
|
||||
# Standard path: wrap in CodexAuxiliaryClient adapter
|
||||
client, default = _try_codex()
|
||||
client, default = _build_codex_client(model)
|
||||
if client is None:
|
||||
logger.warning("resolve_provider_client: openai-codex requested "
|
||||
"but no Codex OAuth token found (run: hermes model)")
|
||||
@@ -2078,9 +2145,9 @@ def resolve_provider_client(
|
||||
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
# Try custom first, then codex, then API-key providers
|
||||
for try_fn in (_try_custom_endpoint, _try_codex,
|
||||
_resolve_api_key_provider):
|
||||
# Try custom first, then API-key providers (Codex excluded here:
|
||||
# falling through to Codex with no model is a stale-constant trap).
|
||||
for try_fn in (_try_custom_endpoint, _resolve_api_key_provider):
|
||||
client, default = try_fn()
|
||||
if client is not None:
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
@@ -2122,8 +2189,10 @@ def resolve_provider_client(
|
||||
# Anthropic fallback SDK still sees the original URL.
|
||||
if entry_api_mode == "anthropic_messages":
|
||||
openai_base = custom_base
|
||||
raw_base_for_wrap = custom_base
|
||||
else:
|
||||
openai_base = _to_openai_base_url(custom_base)
|
||||
raw_base_for_wrap = custom_base
|
||||
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
|
||||
_extra2 = {"default_query": _dq2} if _dq2 else {}
|
||||
logger.debug(
|
||||
@@ -2167,7 +2236,7 @@ def resolve_provider_client(
|
||||
):
|
||||
client = CodexAuxiliaryClient(client, final_model)
|
||||
else:
|
||||
client = _wrap_if_needed(client, final_model, openai_base, custom_key)
|
||||
client = _wrap_if_needed(client, final_model, raw_base_for_wrap, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
logger.warning(
|
||||
@@ -2213,9 +2282,8 @@ def resolve_provider_client(
|
||||
provider, ", ".join(tried_sources))
|
||||
return None, None
|
||||
|
||||
base_url = _to_openai_base_url(
|
||||
str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
)
|
||||
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
base_url = _to_openai_base_url(raw_base_url)
|
||||
|
||||
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
@@ -2264,7 +2332,7 @@ def resolve_provider_client(
|
||||
# Anthropic-wire endpoints (Kimi Coding Plan api.kimi.com/coding,
|
||||
# /anthropic-suffixed gateways) so named providers like kimi-coding
|
||||
# land on the right transport without needing per-provider branches.
|
||||
client = _wrap_if_needed(client, final_model, base_url, api_key)
|
||||
client = _wrap_if_needed(client, final_model, raw_base_url, api_key)
|
||||
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
@@ -2427,7 +2495,10 @@ def _resolve_strict_vision_backend(
|
||||
if provider == "nous":
|
||||
return _try_nous(vision=True)
|
||||
if provider == "openai-codex":
|
||||
return _try_codex()
|
||||
# Route through resolve_provider_client so the caller's explicit
|
||||
# model is used. There is no safe default Codex model (shifting
|
||||
# allow-list); callers must specify via auxiliary.<task>.model.
|
||||
return resolve_provider_client("openai-codex", model, is_vision=True)
|
||||
if provider == "anthropic":
|
||||
return _try_anthropic()
|
||||
if provider == "custom":
|
||||
@@ -2532,6 +2603,19 @@ def resolve_vision_provider_client(
|
||||
main_provider, default_model or resolved_model or main_model,
|
||||
)
|
||||
return _finalize(main_provider, sync_client, default_model)
|
||||
elif main_provider in _PROVIDERS_WITHOUT_VISION:
|
||||
# Kimi Coding Plan's /coding endpoint (Anthropic Messages wire)
|
||||
# does not accept image input — Kimi's own docs say "Current
|
||||
# model does not support image input, switch to a model with
|
||||
# image_in capability" and vision lives on the separate Kimi
|
||||
# Platform (api.moonshot.ai). Skip the main provider and fall
|
||||
# through to the aggregator chain instead of returning a
|
||||
# client that will 404 on every vision request (#17076).
|
||||
logger.debug(
|
||||
"Vision auto-detect: skipping main provider %s (no "
|
||||
"vision support) — falling through to aggregator chain",
|
||||
main_provider,
|
||||
)
|
||||
else:
|
||||
rpc_client, rpc_model = resolve_provider_client(
|
||||
main_provider, vision_model,
|
||||
@@ -3013,7 +3097,7 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
|
||||
# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper).
|
||||
# Their image content blocks must use Anthropic format, not OpenAI format.
|
||||
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-cn"})
|
||||
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
|
||||
|
||||
|
||||
def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
|
||||
@@ -608,7 +608,7 @@ class CopilotACPClient:
|
||||
end = start + limit if isinstance(limit, int) and limit > 0 else None
|
||||
content = "".join(lines[start:end])
|
||||
if content:
|
||||
content = redact_sensitive_text(content)
|
||||
content = redact_sensitive_text(content, force=True)
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message_id,
|
||||
|
||||
@@ -1299,6 +1299,48 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
except Exception as exc:
|
||||
logger.debug("Qwen OAuth token seed failed: %s", exc)
|
||||
|
||||
elif provider == "minimax-oauth":
|
||||
# MiniMax OAuth tokens live in ~/.hermes/auth.json providers.minimax-oauth.
|
||||
# Seed the pool so `/auth list` reflects the logged-in state and the
|
||||
# standard `hermes auth remove minimax-oauth <N>` flow works.
|
||||
# Use refresh_if_expiring=False equivalent: resolve_minimax_oauth_runtime_credentials
|
||||
# always refreshes on expiry, so instead read raw state here to avoid
|
||||
# surprise network calls during provider discovery.
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if state and state.get("access_token"):
|
||||
source_name = "oauth"
|
||||
if not _is_suppressed(provider, source_name):
|
||||
active_sources.add(source_name)
|
||||
expires_at_ms = None
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
raw = state.get("expires_at", "")
|
||||
if raw:
|
||||
expires_at_ms = int(_dt.fromisoformat(raw).timestamp() * 1000)
|
||||
except Exception:
|
||||
expires_at_ms = None
|
||||
base_url = str(state.get("inference_base_url", "") or "").rstrip("/")
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
source_name,
|
||||
{
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"access_token": state["access_token"],
|
||||
"refresh_token": state.get("refresh_token"),
|
||||
"expires_at_ms": expires_at_ms,
|
||||
"base_url": base_url,
|
||||
"label": state.get("label", "") or label_from_token(
|
||||
state.get("access_token", ""), source_name
|
||||
),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("MiniMax OAuth token seed failed: %s", exc)
|
||||
|
||||
elif provider == "openai-codex":
|
||||
# Respect user suppression — `hermes auth remove openai-codex` marks
|
||||
# the device_code source as suppressed so it won't be re-seeded from
|
||||
|
||||
@@ -252,6 +252,19 @@ def _remove_nous_device_code(provider: str, removed) -> RemovalResult:
|
||||
return result
|
||||
|
||||
|
||||
def _remove_minimax_oauth(provider: str, removed) -> RemovalResult:
|
||||
"""MiniMax OAuth lives in auth.json providers.minimax-oauth — clear it.
|
||||
|
||||
Same pattern as Nous: single-source OAuth state with refresh tokens.
|
||||
Suppression of the `oauth` source ensures the pool reseed path
|
||||
(_seed_from_singletons) doesn't instantly undo the removal.
|
||||
"""
|
||||
result = RemovalResult()
|
||||
if _clear_auth_store_provider(provider):
|
||||
result.cleaned.append(f"Cleared {provider} OAuth tokens from auth store")
|
||||
return result
|
||||
|
||||
|
||||
def _remove_codex_device_code(provider: str, removed) -> RemovalResult:
|
||||
"""Codex tokens live in TWO places: our auth store AND ~/.codex/auth.json.
|
||||
|
||||
@@ -389,6 +402,11 @@ def _register_all_sources() -> None:
|
||||
remove_fn=_remove_qwen_cli,
|
||||
description="~/.qwen/oauth_creds.json",
|
||||
))
|
||||
register(RemovalStep(
|
||||
provider="minimax-oauth", source_id="oauth",
|
||||
remove_fn=_remove_minimax_oauth,
|
||||
description="auth.json providers.minimax-oauth",
|
||||
))
|
||||
register(RemovalStep(
|
||||
provider="*", source_id="config:",
|
||||
match_fn=lambda src: src.startswith("config:") or src == "model_config",
|
||||
|
||||
+1315
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,7 @@ class FailoverReason(enum.Enum):
|
||||
# Provider-specific
|
||||
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
|
||||
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
|
||||
oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry
|
||||
|
||||
# Catch-all
|
||||
unknown = "unknown" # Unclassifiable — retry with backoff
|
||||
@@ -450,6 +451,25 @@ def classify_api_error(
|
||||
should_compress=True,
|
||||
)
|
||||
|
||||
# Anthropic OAuth subscription rejects the 1M-context beta header.
|
||||
# Observed error body: "The long context beta is not yet available for
|
||||
# this subscription." Returned as HTTP 400 from native Anthropic when
|
||||
# the subscription doesn't include 1M context, even though the request
|
||||
# carries ``anthropic-beta: context-1m-2025-08-07``. The recovery path
|
||||
# in run_agent.py rebuilds the Anthropic client with the beta stripped
|
||||
# and retries once. Pattern is narrow enough that it won't collide with
|
||||
# the 429 tier-gate pattern above (different status, different phrase).
|
||||
if (
|
||||
status_code == 400
|
||||
and "long context beta" in error_msg
|
||||
and "not yet available" in error_msg
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.oauth_long_context_beta_forbidden,
|
||||
retryable=True,
|
||||
should_compress=False,
|
||||
)
|
||||
|
||||
# ── 2. HTTP status code classification ──────────────────────────
|
||||
|
||||
if status_code is not None:
|
||||
|
||||
@@ -402,6 +402,41 @@ class MemoryManager:
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
def on_session_switch(
|
||||
self,
|
||||
new_session_id: str,
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Notify all providers that the agent's session_id has rotated.
|
||||
|
||||
Fires on ``/resume``, ``/branch``, ``/reset``, ``/new``, and
|
||||
context compression — any path that reassigns
|
||||
``AIAgent.session_id`` without tearing the provider down.
|
||||
|
||||
Providers keep running; they only need to refresh cached
|
||||
per-session state so subsequent writes land in the correct
|
||||
session's record. See ``MemoryProvider.on_session_switch`` for
|
||||
the full contract.
|
||||
"""
|
||||
if not new_session_id:
|
||||
return
|
||||
for provider in self._providers:
|
||||
try:
|
||||
provider.on_session_switch(
|
||||
new_session_id,
|
||||
parent_session_id=parent_session_id,
|
||||
reset=reset,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Memory provider '%s' on_session_switch failed: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Notify all providers before context compression.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ Lifecycle (called by MemoryManager, wired in run_agent.py):
|
||||
Optional hooks (override to opt in):
|
||||
on_turn_start(turn, message, **kwargs) — per-turn tick with runtime context
|
||||
on_session_end(messages) — end-of-session extraction
|
||||
on_session_switch(new_session_id, **kwargs) — mid-process session_id rotation
|
||||
on_pre_compress(messages) -> str — extract before context compression
|
||||
on_memory_write(action, target, content, metadata=None) — mirror built-in memory writes
|
||||
on_delegation(task, result, **kwargs) — parent-side observation of subagent work
|
||||
@@ -160,6 +161,45 @@ class MemoryProvider(ABC):
|
||||
(CLI exit, /reset, gateway session expiry).
|
||||
"""
|
||||
|
||||
def on_session_switch(
|
||||
self,
|
||||
new_session_id: str,
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Called when the agent switches session_id mid-process.
|
||||
|
||||
Fires on ``/resume``, ``/branch``, ``/reset``, ``/new`` (CLI), the
|
||||
gateway equivalents, and context compression — any path that
|
||||
reassigns ``AIAgent.session_id`` without tearing the provider down.
|
||||
|
||||
Providers that cache per-session state in ``initialize()``
|
||||
(``_session_id``, ``_document_id``, accumulated turn buffers,
|
||||
counters) should update or reset that state here so subsequent
|
||||
writes land in the correct session's record.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_session_id:
|
||||
The session_id the agent just switched to.
|
||||
parent_session_id:
|
||||
The previous session_id, if meaningful — set for ``/branch``
|
||||
(fork lineage), context compression (continuation lineage),
|
||||
and ``/resume`` (the session we're leaving). Empty string
|
||||
when no lineage applies.
|
||||
reset:
|
||||
``True`` when this is a genuinely new conversation, not a
|
||||
resumption of an existing one. Fired by ``/reset`` / ``/new``.
|
||||
Providers should flush accumulated per-session buffers
|
||||
(``_session_turns``, ``_turn_counter``, etc.) when this is
|
||||
set. ``False`` for ``/resume`` / ``/branch`` / compression
|
||||
where the logical conversation continues under the new id.
|
||||
|
||||
Default is no-op for backward compatibility.
|
||||
"""
|
||||
|
||||
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Called before context compression discards old messages.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def _resolve_requests_verify() -> bool | str:
|
||||
# are preserved so the full model name reaches cache lookups and server queries.
|
||||
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
|
||||
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
@@ -1247,7 +1247,7 @@ def get_model_context_length(
|
||||
6. Nous suffix-match via OpenRouter cache
|
||||
7. models.dev registry lookup (provider-aware)
|
||||
8. Thin hardcoded defaults (broad family patterns)
|
||||
9. Default fallback (128K)
|
||||
9. Default fallback (256K)
|
||||
"""
|
||||
# 0. Explicit config override — user knows best
|
||||
if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
|
||||
@@ -1427,7 +1427,7 @@ def get_model_context_length(
|
||||
save_context_length(model, base_url, local_ctx)
|
||||
return local_ctx
|
||||
|
||||
# 10. Default fallback — 128K
|
||||
# 10. Default fallback — 256K
|
||||
return DEFAULT_FALLBACK_CONTEXT
|
||||
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"stepfun": "stepfun",
|
||||
"kimi-coding-cn": "kimi-for-coding",
|
||||
"minimax": "minimax",
|
||||
"minimax-oauth": "minimax",
|
||||
"minimax-cn": "minimax-cn",
|
||||
"deepseek": "deepseek",
|
||||
"alibaba": "alibaba",
|
||||
|
||||
+11
-9
@@ -98,17 +98,19 @@ def tool_progress_hint_cli() -> str:
|
||||
def openclaw_residue_hint_cli() -> str:
|
||||
"""Banner shown the first time Hermes starts and finds ``~/.openclaw/``.
|
||||
|
||||
OpenClaw-era config, memory, and skill paths in ``~/.openclaw/`` will
|
||||
otherwise attract the agent (memory entries like ``~/.openclaw/config.yaml``
|
||||
get carried forward and the agent dutifully reads them). ``hermes claw
|
||||
cleanup`` renames the directory so the agent stops finding it.
|
||||
Points users at ``hermes claw migrate`` (non-destructive port of config,
|
||||
memory, and skills) first. ``hermes claw cleanup`` is mentioned as the
|
||||
follow-up step for users who have already migrated and want to archive
|
||||
the old directory — with a warning that archiving breaks OpenClaw.
|
||||
"""
|
||||
return (
|
||||
"Heads up — an OpenClaw workspace was detected at ~/.openclaw/.\n"
|
||||
"After migrating, the agent can still get confused and read that "
|
||||
"directory's config/memory instead of Hermes's.\n"
|
||||
"Run `hermes claw cleanup` to archive it (rename → .openclaw.pre-migration). "
|
||||
"This tip only shows once; rerun it any time with `hermes claw cleanup`."
|
||||
"A legacy OpenClaw directory was detected at ~/.openclaw/.\n"
|
||||
"To port your config, memory, and skills over to Hermes, run "
|
||||
"`hermes claw migrate`.\n"
|
||||
"If you've already migrated and want to archive the old directory, "
|
||||
"run `hermes claw cleanup` (renames it to ~/.openclaw.pre-migration — "
|
||||
"OpenClaw will stop working after this).\n"
|
||||
"This tip only shows once."
|
||||
)
|
||||
|
||||
|
||||
|
||||
+4
-2
@@ -305,11 +305,13 @@ def _redact_form_body(text: str) -> str:
|
||||
return _redact_query_string(text.strip())
|
||||
|
||||
|
||||
def redact_sensitive_text(text: str) -> str:
|
||||
def redact_sensitive_text(text: str, *, force: bool = False) -> str:
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
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.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
@@ -317,7 +319,7 @@ def redact_sensitive_text(text: str) -> str:
|
||||
text = str(text)
|
||||
if not text:
|
||||
return text
|
||||
if not _REDACT_ENABLED:
|
||||
if not (force or _REDACT_ENABLED):
|
||||
return text
|
||||
|
||||
# Known prefixes (sk-, ghp_, etc.)
|
||||
|
||||
+82
-1
@@ -234,7 +234,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
for scan_dir in dirs_to_scan:
|
||||
for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
if any(part in ('.git', '.github', '.hub', '.archive') for part in skill_md.parts):
|
||||
continue
|
||||
try:
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
@@ -284,6 +284,71 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
return _skill_commands
|
||||
|
||||
|
||||
def reload_skills() -> Dict[str, Any]:
|
||||
"""Re-scan the skills directory and return a diff of what changed.
|
||||
|
||||
Rescans ``~/.hermes/skills/`` and any ``skills.external_dirs`` so the
|
||||
slash-command map (``agent.skill_commands._skill_commands``) reflects
|
||||
skills added or removed on disk.
|
||||
|
||||
This does NOT invalidate the skills system-prompt cache. Skills are
|
||||
called by name via ``/skill-name``, ``skills_list``, or ``skill_view``
|
||||
— they don't need to be in the system prompt for the model to use them.
|
||||
Keeping the prompt cache intact preserves prefix caching across the
|
||||
reload, so a user invoking ``/reload-skills`` pays no cache-reset cost.
|
||||
|
||||
Returns:
|
||||
Dict with keys::
|
||||
|
||||
{
|
||||
"added": [{"name": str, "description": str}, ...],
|
||||
"removed": [{"name": str, "description": str}, ...],
|
||||
"unchanged": [skill names present before and after],
|
||||
"total": total skill count after rescan,
|
||||
"commands": total /slash-skill count after rescan,
|
||||
}
|
||||
|
||||
``description`` is the skill's full SKILL.md frontmatter
|
||||
``description:`` field — the same string the system prompt renders
|
||||
as `` - name: description`` for pre-existing skills.
|
||||
"""
|
||||
# Snapshot pre-reload state (name -> description) from the current
|
||||
# slash-command cache. Using dicts lets the post-rescan diff carry
|
||||
# descriptions for newly-visible or just-removed skills without a
|
||||
# second disk walk.
|
||||
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for slash_key, info in cmds.items():
|
||||
bare = slash_key.lstrip("/")
|
||||
out[bare] = (info or {}).get("description") or ""
|
||||
return out
|
||||
|
||||
before = _snapshot(_skill_commands)
|
||||
|
||||
# Rescan the skills dir. ``scan_skill_commands`` resets
|
||||
# ``_skill_commands = {}`` internally and repopulates it.
|
||||
new_commands = scan_skill_commands()
|
||||
|
||||
after = _snapshot(new_commands)
|
||||
|
||||
added_names = sorted(set(after) - set(before))
|
||||
removed_names = sorted(set(before) - set(after))
|
||||
unchanged = sorted(set(after) & set(before))
|
||||
|
||||
added = [{"name": n, "description": after[n]} for n in added_names]
|
||||
# For removed skills, use the description we had cached pre-rescan
|
||||
# (the skill file is gone so we can't re-read it).
|
||||
removed = [{"name": n, "description": before[n]} for n in removed_names]
|
||||
|
||||
return {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"unchanged": unchanged,
|
||||
"total": len(after),
|
||||
"commands": len(new_commands),
|
||||
}
|
||||
|
||||
|
||||
def resolve_skill_command_key(command: str) -> Optional[str]:
|
||||
"""Resolve a user-typed /command to its canonical skill_cmds key.
|
||||
|
||||
@@ -328,6 +393,14 @@ def build_skill_invocation_message(
|
||||
return f"[Failed to load skill: {skill_info['name']}]"
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
|
||||
# Track active usage for Curator lifecycle management (#17782)
|
||||
try:
|
||||
from tools.skill_usage import bump_use
|
||||
bump_use(skill_name)
|
||||
except Exception:
|
||||
pass # Non-critical — skill invocation proceeds regardless
|
||||
|
||||
activation_note = (
|
||||
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want '
|
||||
"you to follow its instructions. The full skill content is loaded below.]"
|
||||
@@ -367,6 +440,14 @@ def build_preloaded_skills_prompt(
|
||||
continue
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
|
||||
# Track active usage for Curator lifecycle management (#17782)
|
||||
try:
|
||||
from tools.skill_usage import bump_use
|
||||
bump_use(skill_name)
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
activation_note = (
|
||||
f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill '
|
||||
"preloaded. Treat its instructions as active guidance for the duration of this "
|
||||
|
||||
+11
-3
@@ -24,7 +24,7 @@ PLATFORM_MAP = {
|
||||
"windows": "win32",
|
||||
}
|
||||
|
||||
EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub"))
|
||||
EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub", ".archive"))
|
||||
|
||||
# ── Lazy YAML loader ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -200,6 +200,9 @@ def get_external_skills_dirs() -> List[Path]:
|
||||
if not isinstance(raw_dirs, list):
|
||||
return []
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
hermes_home = get_hermes_home()
|
||||
local_skills = get_skills_dir().resolve()
|
||||
seen: Set[Path] = set()
|
||||
result: List[Path] = []
|
||||
@@ -210,7 +213,12 @@ def get_external_skills_dirs() -> List[Path]:
|
||||
continue
|
||||
# Expand ~ and environment variables
|
||||
expanded = os.path.expanduser(os.path.expandvars(entry))
|
||||
p = Path(expanded).resolve()
|
||||
p = Path(expanded)
|
||||
# Resolve relative paths against HERMES_HOME, not cwd
|
||||
if not p.is_absolute():
|
||||
p = (hermes_home / p).resolve()
|
||||
else:
|
||||
p = p.resolve()
|
||||
if p == local_skills:
|
||||
continue
|
||||
if p in seen:
|
||||
@@ -432,7 +440,7 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
|
||||
def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
"""Walk skills_dir yielding sorted paths matching *filename*.
|
||||
|
||||
Excludes ``.git``, ``.github``, ``.hub`` directories.
|
||||
Excludes ``.git``, ``.github``, ``.hub``, ``.archive`` directories.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
|
||||
@@ -58,6 +58,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
context_length: int | None
|
||||
base_url: str | None
|
||||
fast_mode: bool
|
||||
drop_context_1m_beta: bool
|
||||
"""
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
@@ -73,6 +74,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
context_length=params.get("context_length"),
|
||||
base_url=params.get("base_url"),
|
||||
fast_mode=params.get("fast_mode", False),
|
||||
drop_context_1m_beta=params.get("drop_context_1m_beta", False),
|
||||
)
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
@@ -85,6 +87,9 @@ class AnthropicTransport(ProviderTransport):
|
||||
from agent.anthropic_adapter import _to_plain_data
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
_MCP_PREFIX = "mcp_"
|
||||
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
reasoning_details = []
|
||||
@@ -99,10 +104,13 @@ class AnthropicTransport(ProviderTransport):
|
||||
if isinstance(block_dict, dict):
|
||||
reasoning_details.append(block_dict)
|
||||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||
name = name[len(_MCP_PREFIX):]
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=block.id,
|
||||
name=block.name,
|
||||
name=name,
|
||||
arguments=json.dumps(block.input),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -20,15 +20,22 @@ from agent.transports.types import NormalizedResponse, ToolCall, Usage
|
||||
|
||||
|
||||
def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None:
|
||||
"""Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig.
|
||||
|
||||
Gemini native/cloud-code adapters do not read ``extra_body.reasoning``.
|
||||
They only inspect ``extra_body.thinking_config`` / ``thinkingConfig`` and
|
||||
then request thought parts with ``includeThoughts`` enabled.
|
||||
"""
|
||||
"""Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig."""
|
||||
if reasoning_config is None or not isinstance(reasoning_config, dict):
|
||||
return None
|
||||
|
||||
normalized_model = (model or "").strip().lower()
|
||||
if normalized_model.startswith("google/"):
|
||||
normalized_model = normalized_model.split("/", 1)[1]
|
||||
|
||||
# ``thinking_config`` is a Gemini-only request parameter. The same
|
||||
# ``gemini`` provider also serves Gemma (and historically PaLM/Bard);
|
||||
# those reject the field with HTTP 400 "Unknown name 'thinking_config':
|
||||
# Cannot find field" — including the polite ``{"includeThoughts": False}``
|
||||
# form. Omit the field entirely on non-Gemini models. (#17426)
|
||||
if not normalized_model.startswith("gemini"):
|
||||
return None
|
||||
|
||||
if reasoning_config.get("enabled") is False:
|
||||
# Gemini can hide thought parts even when internal thinking still
|
||||
# happens; omit thinkingLevel to avoid model-specific validation quirks.
|
||||
@@ -39,9 +46,6 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
|
||||
return {"includeThoughts": False}
|
||||
|
||||
thinking_config: Dict[str, Any] = {"includeThoughts": True}
|
||||
normalized_model = (model or "").strip().lower()
|
||||
if normalized_model.startswith("google/"):
|
||||
normalized_model = normalized_model.split("/", 1)[1]
|
||||
|
||||
# Gemini 2.5 accepts thinkingBudget; don't guess a budget from Hermes'
|
||||
# coarse effort levels. ``includeThoughts`` alone is enough to surface
|
||||
@@ -71,6 +75,30 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
|
||||
return thinking_config
|
||||
|
||||
|
||||
def _snake_case_gemini_thinking_config(config: dict | None) -> dict | None:
|
||||
"""Convert Gemini thinking config keys to the OpenAI-compat field names."""
|
||||
if not isinstance(config, dict) or not config:
|
||||
return None
|
||||
|
||||
translated: Dict[str, Any] = {}
|
||||
if isinstance(config.get("includeThoughts"), bool):
|
||||
translated["include_thoughts"] = config["includeThoughts"]
|
||||
if isinstance(config.get("thinkingLevel"), str) and config["thinkingLevel"].strip():
|
||||
translated["thinking_level"] = config["thinkingLevel"].strip().lower()
|
||||
if isinstance(config.get("thinkingBudget"), (int, float)):
|
||||
translated["thinking_budget"] = int(config["thinkingBudget"])
|
||||
return translated or None
|
||||
|
||||
|
||||
def _is_gemini_openai_compat_base_url(base_url: Any) -> bool:
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
if not normalized:
|
||||
return False
|
||||
if "generativelanguage.googleapis.com" not in normalized:
|
||||
return False
|
||||
return normalized.endswith("/openai")
|
||||
|
||||
|
||||
class ChatCompletionsTransport(ProviderTransport):
|
||||
"""Transport for api_mode='chat_completions'.
|
||||
|
||||
@@ -309,6 +337,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
is_nous = params.get("is_nous", False)
|
||||
is_github_models = params.get("is_github_models", False)
|
||||
provider_name = str(params.get("provider_name") or "").strip().lower()
|
||||
base_url = params.get("base_url")
|
||||
|
||||
provider_prefs = params.get("provider_preferences")
|
||||
if provider_prefs and is_openrouter:
|
||||
@@ -362,7 +391,19 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if is_qwen:
|
||||
extra_body["vl_high_resolution_images"] = True
|
||||
|
||||
if provider_name in {"gemini", "google-gemini-cli"}:
|
||||
if provider_name == "gemini":
|
||||
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
|
||||
if _is_gemini_openai_compat_base_url(base_url):
|
||||
thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config)
|
||||
if thinking_config:
|
||||
openai_compat_extra = extra_body.get("extra_body", {})
|
||||
google_extra = openai_compat_extra.get("google", {})
|
||||
google_extra["thinking_config"] = thinking_config
|
||||
openai_compat_extra["google"] = google_extra
|
||||
extra_body["extra_body"] = openai_compat_extra
|
||||
elif raw_thinking_config:
|
||||
extra_body["thinking_config"] = raw_thinking_config
|
||||
elif provider_name == "google-gemini-cli":
|
||||
thinking_config = _build_gemini_thinking_config(model, reasoning_config)
|
||||
if thinking_config:
|
||||
extra_body["thinking_config"] = thinking_config
|
||||
@@ -436,9 +477,13 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
# so keep them apart in provider_data rather than merging.
|
||||
reasoning = getattr(msg, "reasoning", None)
|
||||
reasoning_content = getattr(msg, "reasoning_content", None)
|
||||
if reasoning_content is None and hasattr(msg, "model_extra"):
|
||||
model_extra = getattr(msg, "model_extra", None) or {}
|
||||
if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
|
||||
reasoning_content = model_extra["reasoning_content"]
|
||||
|
||||
provider_data: Dict[str, Any] = {}
|
||||
if reasoning_content:
|
||||
if reasoning_content is not None:
|
||||
provider_data["reasoning_content"] = reasoning_content
|
||||
rd = getattr(msg, "reasoning_details", None)
|
||||
if rd:
|
||||
|
||||
@@ -359,6 +359,25 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
# MiniMax
|
||||
(
|
||||
"minimax",
|
||||
"minimax-m2.7",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.30"),
|
||||
output_cost_per_million=Decimal("1.20"),
|
||||
source="official_docs_snapshot",
|
||||
pricing_version="minimax-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"minimax-cn",
|
||||
"minimax-m2.7",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.30"),
|
||||
output_cost_per_million=Decimal("1.20"),
|
||||
source="official_docs_snapshot",
|
||||
pricing_version="minimax-pricing-2026-04",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -400,6 +419,8 @@ def resolve_billing_route(
|
||||
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name == "openai":
|
||||
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name in {"minimax", "minimax-cn"}:
|
||||
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name in {"custom", "local"} or (base and "localhost" in base):
|
||||
return BillingRoute(provider=provider_name or "custom", model=model, base_url=base_url or "", billing_mode="unknown")
|
||||
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
|
||||
|
||||
@@ -180,6 +180,11 @@ terminal:
|
||||
# lifetime_seconds: 300
|
||||
# docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace
|
||||
# # Optional: run the container as your host user's uid:gid so files written
|
||||
# # into bind-mounted dirs are owned by you, not root. Drops SETUID/SETGID
|
||||
# # caps too since no gosu privilege drop is needed. Leave off if your
|
||||
# # chosen docker_image expects to start as root.
|
||||
# docker_run_as_host_user: true
|
||||
# # Optional: explicitly forward selected env vars into Docker.
|
||||
# # These values come from your current shell first, then ~/.hermes/.env.
|
||||
# # Warning: anything forwarded here is visible to commands run in the container.
|
||||
@@ -565,7 +570,7 @@ agent:
|
||||
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
|
||||
# - A list of individual toolsets to compose your own (see list below)
|
||||
#
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
@@ -595,6 +600,7 @@ agent:
|
||||
# signal: hermes-signal (same as telegram)
|
||||
# homeassistant: hermes-homeassistant (same as telegram)
|
||||
# qqbot: hermes-qqbot (same as telegram)
|
||||
# teams: hermes-teams (same as telegram)
|
||||
#
|
||||
platform_toolsets:
|
||||
cli: [hermes-cli]
|
||||
@@ -606,6 +612,7 @@ platform_toolsets:
|
||||
homeassistant: [hermes-homeassistant]
|
||||
qqbot: [hermes-qqbot]
|
||||
yuanbao: [hermes-yuanbao]
|
||||
teams: [hermes-teams]
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Platform Settings
|
||||
|
||||
@@ -80,6 +80,11 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
from hermes_cli.browser_connect import (
|
||||
DEFAULT_BROWSER_CDP_URL,
|
||||
manual_chrome_debug_command,
|
||||
try_launch_chrome_debug,
|
||||
)
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from utils import base_url_host_matches
|
||||
|
||||
@@ -240,65 +245,6 @@ def _parse_service_tier_config(raw: str) -> str | None:
|
||||
logger.warning("Unknown service_tier '%s', ignoring", raw)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _get_chrome_debug_candidates(system: str) -> list[str]:
|
||||
"""Return likely browser executables for local CDP auto-launch."""
|
||||
candidates: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add_candidate(path: str | None) -> None:
|
||||
if not path:
|
||||
return
|
||||
normalized = os.path.normcase(os.path.normpath(path))
|
||||
if normalized in seen:
|
||||
return
|
||||
if os.path.isfile(path):
|
||||
candidates.append(path)
|
||||
seen.add(normalized)
|
||||
|
||||
def _add_from_path(*names: str) -> None:
|
||||
for name in names:
|
||||
_add_candidate(shutil.which(name))
|
||||
|
||||
if system == "Darwin":
|
||||
for app in (
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
):
|
||||
_add_candidate(app)
|
||||
elif system == "Windows":
|
||||
_add_from_path(
|
||||
"chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
|
||||
"chrome", "msedge", "brave", "chromium",
|
||||
)
|
||||
|
||||
for base in (
|
||||
os.environ.get("ProgramFiles"),
|
||||
os.environ.get("ProgramFiles(x86)"),
|
||||
os.environ.get("LOCALAPPDATA"),
|
||||
):
|
||||
if not base:
|
||||
continue
|
||||
for parts in (
|
||||
("Google", "Chrome", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chromium.exe"),
|
||||
("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
("Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
):
|
||||
_add_candidate(os.path.join(base, *parts))
|
||||
else:
|
||||
_add_from_path(
|
||||
"google-chrome", "google-chrome-stable", "chromium-browser",
|
||||
"chromium", "brave-browser", "microsoft-edge",
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def load_cli_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Load CLI configuration from config files.
|
||||
@@ -551,18 +497,20 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
|
||||
# SSH config
|
||||
"ssh_host": "TERMINAL_SSH_HOST",
|
||||
"ssh_user": "TERMINAL_SSH_USER",
|
||||
"ssh_port": "TERMINAL_SSH_PORT",
|
||||
"ssh_key": "TERMINAL_SSH_KEY",
|
||||
# Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh)
|
||||
# Container resource config (docker, singularity, modal, daytona, vercel_sandbox -- ignored for local/ssh)
|
||||
"container_cpu": "TERMINAL_CONTAINER_CPU",
|
||||
"container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||||
"container_disk": "TERMINAL_CONTAINER_DISK",
|
||||
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||||
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
|
||||
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
# Persistent shell (non-local backends)
|
||||
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
@@ -1592,9 +1540,29 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
|
||||
# that appears when the ESC byte was stripped by a prior filter.
|
||||
_DSR_CPR_ESC_RE = re.compile(r"\x1b\[\d+;\d+R")
|
||||
_DSR_CPR_VISIBLE_RE = re.compile(r"\^\[\[\d+;\d+R")
|
||||
_SGR_MOUSE_ESC_RE = re.compile(r"\x1b\[<\d+;\d+;\d+[Mm]")
|
||||
_SGR_MOUSE_VISIBLE_RE = re.compile(r"\^\[\[<\d+;\d+;\d+[Mm]")
|
||||
# Some terminals/filters can drop ESC and literal "^[[", leaving only
|
||||
# "<btn;col;rowM" fragments in the buffer. Keep this broad on purpose:
|
||||
# these fragments are extremely unlikely to be intentional user input, and
|
||||
# stripping them is better than sending corrupted prompts.
|
||||
_SGR_MOUSE_BARE_RE = re.compile(r"<\d+;\d+;\d+[Mm]")
|
||||
_TERMINAL_INPUT_MODE_RESET_SEQ = (
|
||||
"\x1b[?1006l" # disable SGR mouse
|
||||
"\x1b[?1003l" # disable any-motion tracking
|
||||
"\x1b[?1002l" # disable button-motion tracking
|
||||
"\x1b[?1000l" # disable click tracking
|
||||
"\x1b[?1004l" # disable focus events
|
||||
"\x1b[?2004l" # disable bracketed paste
|
||||
"\x1b[?1049l" # leave alt screen (if stuck there)
|
||||
"\x1b[<u" # pop kitty keyboard mode
|
||||
"\x1b[>4m" # reset modifyOtherKeys
|
||||
"\x1b[0m" # reset text attributes
|
||||
"\x1b[?25h" # ensure cursor visible
|
||||
)
|
||||
|
||||
|
||||
def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
|
||||
"""Strip leaked terminal control-response sequences from user input.
|
||||
|
||||
Covers Cursor Position Report (CPR / DSR) responses — ``ESC[<row>;<col>R``
|
||||
@@ -1604,12 +1572,43 @@ def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
(resize storms, multiplexer focus changes, slow PTYs) the response
|
||||
lands in the input buffer as literal text and corrupts what the user
|
||||
typed.
|
||||
|
||||
Also strips leaked SGR mouse-report fragments (``ESC[<...M/m`` and
|
||||
degraded visible forms). Returns ``(cleaned_text, had_mouse_reports)``
|
||||
so callers can trigger an in-place terminal mode recovery when needed.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
text = _DSR_CPR_ESC_RE.sub("", text)
|
||||
text = _DSR_CPR_VISIBLE_RE.sub("", text)
|
||||
return text
|
||||
return text, False
|
||||
|
||||
has_esc = "\x1b[" in text
|
||||
has_visible = "^[" in text
|
||||
has_bare_mouse = "<" in text and ";" in text and ("M" in text or "m" in text)
|
||||
if not (has_esc or has_visible or has_bare_mouse):
|
||||
return text, False
|
||||
|
||||
had_mouse_reports = False
|
||||
|
||||
if has_esc:
|
||||
text = _DSR_CPR_ESC_RE.sub("", text)
|
||||
text, count = _SGR_MOUSE_ESC_RE.subn("", text)
|
||||
had_mouse_reports = had_mouse_reports or count > 0
|
||||
|
||||
if has_visible:
|
||||
text = _DSR_CPR_VISIBLE_RE.sub("", text)
|
||||
text, count = _SGR_MOUSE_VISIBLE_RE.subn("", text)
|
||||
had_mouse_reports = had_mouse_reports or count > 0
|
||||
|
||||
if has_bare_mouse:
|
||||
text, count = _SGR_MOUSE_BARE_RE.subn("", text)
|
||||
had_mouse_reports = had_mouse_reports or count > 0
|
||||
|
||||
return text, had_mouse_reports
|
||||
|
||||
|
||||
def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
"""Compatibility wrapper returning only cleaned text."""
|
||||
cleaned, _ = _strip_leaked_terminal_responses_with_meta(text)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
|
||||
@@ -1983,6 +1982,8 @@ class HermesCLI:
|
||||
self._stream_box_opened = False # True once the response box header is printed
|
||||
self._reasoning_preview_buf = "" # Coalesce tiny reasoning chunks for [thinking] output
|
||||
self._pending_edit_snapshots = {}
|
||||
self._last_input_mode_recovery = 0.0
|
||||
self._input_mode_recovery_notice_shown = False
|
||||
|
||||
# Configuration - priority: CLI args > env vars > config file
|
||||
# Model comes from: CLI arg or config.yaml (single source of truth).
|
||||
@@ -3159,6 +3160,8 @@ class HermesCLI:
|
||||
return "Processing skills command..."
|
||||
if cmd_lower == "/reload-mcp":
|
||||
return "Reloading MCP servers..."
|
||||
if cmd_lower == "/reload-skills" or cmd_lower == "/reload_skills":
|
||||
return "Reloading skills..."
|
||||
if cmd_lower.startswith("/browser"):
|
||||
return "Configuring browser..."
|
||||
return "Processing command..."
|
||||
@@ -4172,6 +4175,37 @@ class HermesCLI:
|
||||
sys.stdout.write(seq)
|
||||
sys.stdout.flush()
|
||||
|
||||
def _recover_terminal_input_modes(self, *, reason: str) -> None:
|
||||
"""Best-effort reset when leaked mouse reports indicate mode drift."""
|
||||
now = time.monotonic()
|
||||
# Rate-limit to avoid thrashing if a terminal floods reports.
|
||||
if now - self._last_input_mode_recovery < 0.5:
|
||||
return
|
||||
self._last_input_mode_recovery = now
|
||||
|
||||
out = getattr(self, "_app", None)
|
||||
output = getattr(out, "output", None) if out else None
|
||||
try:
|
||||
if output and hasattr(output, "write_raw"):
|
||||
output.write_raw(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
output.flush()
|
||||
elif output and hasattr(output, "write"):
|
||||
output.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
output.flush()
|
||||
else:
|
||||
sys.stdout.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
logger.warning("Recovered terminal input modes after leak: %s", reason)
|
||||
if not self._input_mode_recovery_notice_shown:
|
||||
self._input_mode_recovery_notice_shown = True
|
||||
_cprint(
|
||||
f" {_DIM}Recovered terminal input modes after leaked mouse reports. "
|
||||
f"If this repeats, run /new or restart this tab.{_RST}"
|
||||
)
|
||||
|
||||
def _handle_copy_command(self, cmd_original: str) -> None:
|
||||
"""Handle /copy [number] — copy assistant output to clipboard."""
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
@@ -4862,6 +4896,22 @@ class HermesCLI:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Notify memory providers that session_id rotated to a fresh
|
||||
# conversation. reset=True signals providers to flush accumulated
|
||||
# per-session state (_session_turns, _turn_counter, _document_id).
|
||||
# Fires BEFORE the plugin on_session_reset hook (shell hooks only
|
||||
# see the new id; Python providers see the transition). See #6672.
|
||||
try:
|
||||
_mm = getattr(self.agent, "_memory_manager", None)
|
||||
if _mm is not None:
|
||||
_mm.on_session_switch(
|
||||
self.session_id,
|
||||
parent_session_id=old_session_id or "",
|
||||
reset=True,
|
||||
reason="new_session",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._notify_session_boundary("on_session_reset")
|
||||
|
||||
if not silent:
|
||||
@@ -4914,6 +4964,7 @@ class HermesCLI:
|
||||
_cprint(" Already on that session.")
|
||||
return
|
||||
|
||||
old_session_id = self.session_id
|
||||
# End current session
|
||||
try:
|
||||
self._session_db.end_session(self.session_id, "resumed_other")
|
||||
@@ -4951,6 +5002,22 @@ class HermesCLI:
|
||||
if hasattr(self.agent, "_invalidate_system_prompt"):
|
||||
self.agent._invalidate_system_prompt()
|
||||
|
||||
# Notify memory providers that session_id rotated to a resumed
|
||||
# session. reset=False — the provider's accumulated state is
|
||||
# still valid; it just needs to target the new session_id for
|
||||
# subsequent writes. See #6672.
|
||||
try:
|
||||
_mm = getattr(self.agent, "_memory_manager", None)
|
||||
if _mm is not None:
|
||||
_mm.on_session_switch(
|
||||
target_id,
|
||||
parent_session_id=old_session_id or "",
|
||||
reset=False,
|
||||
reason="resume",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else ""
|
||||
msg_count = len([m for m in self.conversation_history if m.get("role") == "user"])
|
||||
if self.conversation_history:
|
||||
@@ -5071,6 +5138,22 @@ class HermesCLI:
|
||||
if hasattr(self.agent, "_invalidate_system_prompt"):
|
||||
self.agent._invalidate_system_prompt()
|
||||
|
||||
# Notify memory providers that session_id forked to a new branch.
|
||||
# reset=False — the branched session carries the transcript
|
||||
# forward, so provider state tracks the lineage. parent_session_id
|
||||
# links the branch back to the original. See #6672.
|
||||
try:
|
||||
_mm = getattr(self.agent, "_memory_manager", None)
|
||||
if _mm is not None:
|
||||
_mm.on_session_switch(
|
||||
new_session_id,
|
||||
parent_session_id=parent_session_id or "",
|
||||
reset=False,
|
||||
reason="branch",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
msg_count = len([m for m in self.conversation_history if m.get("role") == "user"])
|
||||
_cprint(
|
||||
f" ⑂ Branched session \"{branch_title}\""
|
||||
@@ -5329,6 +5412,7 @@ class HermesCLI:
|
||||
base_url=result.base_url or self.base_url or "",
|
||||
api_key=result.api_key or self.api_key or "",
|
||||
model_info=mi,
|
||||
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None,
|
||||
)
|
||||
if ctx:
|
||||
_cprint(f" Context: {ctx:,} tokens")
|
||||
@@ -5555,6 +5639,7 @@ class HermesCLI:
|
||||
base_url=result.base_url or self.base_url or "",
|
||||
api_key=result.api_key or self.api_key or "",
|
||||
model_info=mi,
|
||||
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None,
|
||||
)
|
||||
if ctx:
|
||||
_cprint(f" Context: {ctx:,} tokens")
|
||||
@@ -5979,7 +6064,29 @@ class HermesCLI:
|
||||
|
||||
print(f"(._.) Unknown cron command: {subcommand}")
|
||||
print(" Available: list, add, edit, pause, resume, run, remove")
|
||||
|
||||
|
||||
def _handle_curator_command(self, cmd: str):
|
||||
"""Handle /curator slash command.
|
||||
|
||||
Delegates to hermes_cli.curator so the CLI and the `hermes curator`
|
||||
subcommand share the same handler set.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
tokens = shlex.split(cmd)[1:] if cmd else []
|
||||
if not tokens:
|
||||
tokens = ["status"]
|
||||
|
||||
try:
|
||||
from hermes_cli.curator import cli_main
|
||||
cli_main(tokens)
|
||||
except SystemExit:
|
||||
# argparse calls sys.exit() on --help or errors; swallow so we
|
||||
# don't kill the interactive session.
|
||||
pass
|
||||
except Exception as exc:
|
||||
print(f"(._.) curator: {exc}")
|
||||
|
||||
def _handle_skills_command(self, cmd: str):
|
||||
"""Handle /skills slash command — delegates to hermes_cli.skills_hub."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
@@ -6223,6 +6330,8 @@ class HermesCLI:
|
||||
self.save_conversation()
|
||||
elif canonical == "cron":
|
||||
self._handle_cron_command(cmd_original)
|
||||
elif canonical == "curator":
|
||||
self._handle_curator_command(cmd_original)
|
||||
elif canonical == "skills":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._handle_skills_command(cmd_original)
|
||||
@@ -6263,8 +6372,13 @@ class HermesCLI:
|
||||
count = reload_env()
|
||||
print(f" Reloaded .env ({count} var(s) updated)")
|
||||
elif canonical == "reload-mcp":
|
||||
# Interactive reload: confirm first (unless the user has opted out).
|
||||
# The auto-reload path (file watcher) calls _reload_mcp directly
|
||||
# without this confirmation.
|
||||
self._confirm_and_reload_mcp(cmd_original)
|
||||
elif canonical == "reload-skills":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
self._reload_skills()
|
||||
elif canonical == "browser":
|
||||
self._handle_browser_command(cmd_original)
|
||||
elif canonical == "plugins":
|
||||
@@ -6606,34 +6720,7 @@ class HermesCLI:
|
||||
|
||||
Returns True if a launch command was executed (doesn't guarantee success).
|
||||
"""
|
||||
import subprocess as _sp
|
||||
|
||||
candidates = _get_chrome_debug_candidates(system)
|
||||
|
||||
if not candidates:
|
||||
return False
|
||||
|
||||
# Dedicated profile dir so debug Chrome won't collide with normal Chrome
|
||||
data_dir = str(_hermes_home / "chrome-debug")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
chrome = candidates[0]
|
||||
try:
|
||||
_sp.Popen(
|
||||
[
|
||||
chrome,
|
||||
f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={data_dir}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
stdout=_sp.DEVNULL,
|
||||
stderr=_sp.DEVNULL,
|
||||
start_new_session=True, # detach from terminal
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return try_launch_chrome_debug(port, system)
|
||||
|
||||
def _handle_browser_command(self, cmd: str):
|
||||
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
|
||||
@@ -6642,13 +6729,44 @@ class HermesCLI:
|
||||
parts = cmd.strip().split(None, 1)
|
||||
sub = parts[1].lower().strip() if len(parts) > 1 else "status"
|
||||
|
||||
_DEFAULT_CDP = "http://127.0.0.1:9222"
|
||||
_DEFAULT_CDP = DEFAULT_BROWSER_CDP_URL
|
||||
current = os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||
|
||||
if sub.startswith("connect"):
|
||||
# Optionally accept a custom CDP URL: /browser connect ws://host:port
|
||||
connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."]
|
||||
cdp_url = connect_parts[2].strip() if len(connect_parts) > 2 else _DEFAULT_CDP
|
||||
parsed_cdp = urlparse(cdp_url if "://" in cdp_url else f"http://{cdp_url}")
|
||||
if parsed_cdp.scheme not in {"http", "https", "ws", "wss"}:
|
||||
print()
|
||||
print(
|
||||
f" ⚠ Unsupported browser url scheme: {parsed_cdp.scheme or '(missing)'} "
|
||||
"(expected one of: http, https, ws, wss)"
|
||||
)
|
||||
print()
|
||||
return
|
||||
try:
|
||||
_port = parsed_cdp.port or (443 if parsed_cdp.scheme in {"https", "wss"} else 80)
|
||||
except ValueError:
|
||||
print()
|
||||
print(f" ⚠ Invalid port in browser url: {cdp_url}")
|
||||
print()
|
||||
return
|
||||
if not parsed_cdp.hostname:
|
||||
print()
|
||||
print(f" ⚠ Missing host in browser url: {cdp_url}")
|
||||
print()
|
||||
return
|
||||
_host = parsed_cdp.hostname
|
||||
if parsed_cdp.path.startswith("/devtools/browser/"):
|
||||
cdp_url = parsed_cdp.geturl()
|
||||
else:
|
||||
cdp_url = parsed_cdp._replace(
|
||||
path="",
|
||||
params="",
|
||||
query="",
|
||||
fragment="",
|
||||
).geturl()
|
||||
|
||||
# Clear any existing browser sessions so the next tool call uses the new backend
|
||||
try:
|
||||
@@ -6659,20 +6777,13 @@ class HermesCLI:
|
||||
|
||||
print()
|
||||
|
||||
# Extract port for connectivity checks
|
||||
_port = 9222
|
||||
try:
|
||||
_port = int(cdp_url.rsplit(":", 1)[-1].split("/")[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Check if Chrome is already listening on the debug port
|
||||
import socket
|
||||
_already_open = False
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(1)
|
||||
s.connect(("127.0.0.1", _port))
|
||||
s.connect((_host, _port))
|
||||
s.close()
|
||||
_already_open = True
|
||||
except (OSError, socket.timeout):
|
||||
@@ -6690,7 +6801,7 @@ class HermesCLI:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(1)
|
||||
s.connect(("127.0.0.1", _port))
|
||||
s.connect((_host, _port))
|
||||
s.close()
|
||||
_already_open = True
|
||||
break
|
||||
@@ -6703,33 +6814,22 @@ class HermesCLI:
|
||||
print(" Try again in a few seconds — the debug instance may still be starting")
|
||||
else:
|
||||
print(" ⚠ Could not auto-launch Chrome")
|
||||
# Show manual instructions as fallback
|
||||
_data_dir = str(_hermes_home / "chrome-debug")
|
||||
sys_name = _plat.system()
|
||||
if sys_name == "Darwin":
|
||||
chrome_cmd = (
|
||||
'open -a "Google Chrome" --args'
|
||||
f" --remote-debugging-port=9222"
|
||||
f' --user-data-dir="{_data_dir}"'
|
||||
" --no-first-run --no-default-browser-check"
|
||||
)
|
||||
elif sys_name == "Windows":
|
||||
chrome_cmd = (
|
||||
f'chrome.exe --remote-debugging-port=9222'
|
||||
f' --user-data-dir="{_data_dir}"'
|
||||
f" --no-first-run --no-default-browser-check"
|
||||
)
|
||||
chrome_cmd = manual_chrome_debug_command(_port, sys_name)
|
||||
if chrome_cmd:
|
||||
print(f" Launch Chrome manually:")
|
||||
print(f" {chrome_cmd}")
|
||||
else:
|
||||
chrome_cmd = (
|
||||
f"google-chrome --remote-debugging-port=9222"
|
||||
f' --user-data-dir="{_data_dir}"'
|
||||
f" --no-first-run --no-default-browser-check"
|
||||
)
|
||||
print(f" Launch Chrome manually:")
|
||||
print(f" {chrome_cmd}")
|
||||
print(" No Chrome/Chromium executable found in this environment")
|
||||
else:
|
||||
print(f" ⚠ Port {_port} is not reachable at {cdp_url}")
|
||||
|
||||
if not _already_open:
|
||||
print()
|
||||
print("Browser not connected — start Chrome with remote debugging and retry /browser connect")
|
||||
print()
|
||||
return
|
||||
|
||||
os.environ["BROWSER_CDP_URL"] = cdp_url
|
||||
# Eagerly start the CDP supervisor so pending_dialogs + frame_tree
|
||||
# show up in the next browser_snapshot. No-op if already started.
|
||||
@@ -7405,6 +7505,77 @@ class HermesCLI:
|
||||
if _reload_thread.is_alive():
|
||||
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
|
||||
|
||||
def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
|
||||
"""Interactive /reload-mcp — confirm with the user, then reload.
|
||||
|
||||
Reloading MCP tools invalidates the provider prompt cache for the
|
||||
active session (tool schemas are baked into the system prompt).
|
||||
The next message re-sends full input tokens — can be expensive on
|
||||
long-context or high-reasoning models.
|
||||
|
||||
Three options: Approve Once, Always Approve (persists
|
||||
``approvals.mcp_reload_confirm: false`` so future reloads run
|
||||
without this prompt), Cancel. Gated by
|
||||
``approvals.mcp_reload_confirm`` — default on.
|
||||
"""
|
||||
# Gate check — respects prior "Always Approve" clicks.
|
||||
try:
|
||||
cfg = load_cli_config()
|
||||
approvals = cfg.get("approvals") if isinstance(cfg, dict) else None
|
||||
confirm_required = True
|
||||
if isinstance(approvals, dict):
|
||||
confirm_required = bool(approvals.get("mcp_reload_confirm", True))
|
||||
except Exception:
|
||||
confirm_required = True
|
||||
|
||||
if not confirm_required:
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
return
|
||||
|
||||
# Render warning + prompt. Use a single-line prompt so the user
|
||||
# sees the warning as output and types a response into the composer.
|
||||
print()
|
||||
print("⚠️ /reload-mcp — Prompt cache invalidation warning")
|
||||
print()
|
||||
print(" Reloading MCP servers rebuilds the tool set for this session and")
|
||||
print(" invalidates the provider prompt cache. The next message will")
|
||||
print(" re-send full input tokens (can be expensive on long-context or")
|
||||
print(" high-reasoning models).")
|
||||
print()
|
||||
print(" [1] Approve Once — reload now")
|
||||
print(" [2] Always Approve — reload now and silence this prompt permanently")
|
||||
print(" [3] Cancel — leave MCP tools unchanged")
|
||||
print()
|
||||
raw = self._prompt_text_input("Choice [1/2/3]: ")
|
||||
if raw is None:
|
||||
print("🟡 /reload-mcp cancelled (no input).")
|
||||
return
|
||||
choice_raw = raw.strip().lower()
|
||||
if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
|
||||
choice = "once"
|
||||
elif choice_raw in ("2", "always", "remember"):
|
||||
choice = "always"
|
||||
elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
|
||||
choice = "cancel"
|
||||
else:
|
||||
print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
|
||||
return
|
||||
|
||||
if choice == "cancel":
|
||||
print("🟡 /reload-mcp cancelled. MCP tools unchanged.")
|
||||
return
|
||||
|
||||
if choice == "always":
|
||||
if save_config_value("approvals.mcp_reload_confirm", False):
|
||||
print("🔒 Future /reload-mcp calls will run without confirmation.")
|
||||
print(" Re-enable via `approvals.mcp_reload_confirm: true` in config.yaml.")
|
||||
else:
|
||||
print("⚠️ Couldn't persist opt-out — reloading once.")
|
||||
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
|
||||
def _reload_mcp(self):
|
||||
"""Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
|
||||
|
||||
@@ -7490,6 +7661,78 @@ class HermesCLI:
|
||||
except Exception as e:
|
||||
print(f" ❌ MCP reload failed: {e}")
|
||||
|
||||
def _reload_skills(self) -> None:
|
||||
"""Reload skills: rescan ~/.hermes/skills/ and queue a note for the
|
||||
next user turn.
|
||||
|
||||
Skills don't need to live in the system prompt for the model to use
|
||||
them (they're invoked via ``/skill-name``, ``skills_list``, or
|
||||
``skill_view`` at runtime), so this does NOT clear the prompt cache.
|
||||
It rescans the slash-command map, prints the diff for the user, and
|
||||
— if any skills were added or removed — queues a one-shot note that
|
||||
gets prepended to the next user message. This preserves message
|
||||
alternation (no phantom user turn injected out of band) and keeps
|
||||
prompt caching intact.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_commands import reload_skills
|
||||
|
||||
if not self._command_running:
|
||||
print("🔄 Reloading skills...")
|
||||
|
||||
result = reload_skills()
|
||||
added = result.get("added", []) # [{"name", "description"}, ...]
|
||||
removed = result.get("removed", []) # [{"name", "description"}, ...]
|
||||
total = result.get("total", 0)
|
||||
|
||||
if not added and not removed:
|
||||
print(" No new skills detected.")
|
||||
print(f" 📚 {total} skill(s) available")
|
||||
return
|
||||
|
||||
def _fmt_line(item: dict) -> str:
|
||||
nm = item.get("name", "")
|
||||
desc = item.get("description", "")
|
||||
return f" - {nm}: {desc}" if desc else f" - {nm}"
|
||||
|
||||
if added:
|
||||
print(" ➕ Added Skills:")
|
||||
for item in added:
|
||||
print(f" {_fmt_line(item)}")
|
||||
if removed:
|
||||
print(" ➖ Removed Skills:")
|
||||
for item in removed:
|
||||
print(f" {_fmt_line(item)}")
|
||||
print(f" 📚 {total} skill(s) available")
|
||||
|
||||
# Queue a one-shot note for the NEXT user turn. The CLI's agent
|
||||
# loop prepends ``_pending_skills_reload_note`` (if set) to the
|
||||
# API-call-local message at ~L8770, then clears it — same
|
||||
# pattern as ``_pending_model_switch_note``. Nothing is written
|
||||
# to conversation_history here, so message alternation stays
|
||||
# intact and no out-of-band user turn is persisted.
|
||||
#
|
||||
# Format matches how the system prompt renders pre-existing
|
||||
# skills (`` - name: description``) so the model reads the
|
||||
# diff in the same shape as its original skill catalog.
|
||||
sections = ["[USER INITIATED SKILLS RELOAD:"]
|
||||
if added:
|
||||
sections.append("")
|
||||
sections.append("Added Skills:")
|
||||
for item in added:
|
||||
sections.append(_fmt_line(item))
|
||||
if removed:
|
||||
sections.append("")
|
||||
sections.append("Removed Skills:")
|
||||
for item in removed:
|
||||
sections.append(_fmt_line(item))
|
||||
sections.append("")
|
||||
sections.append("Use skills_list to see the updated catalog.]")
|
||||
self._pending_skills_reload_note = "\n".join(sections)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Skills reload failed: {e}")
|
||||
|
||||
# ====================================================================
|
||||
# Tool-call generation indicator (shown during streaming)
|
||||
# ====================================================================
|
||||
@@ -8571,7 +8814,8 @@ class HermesCLI:
|
||||
from agent.context_references import preprocess_context_references
|
||||
from agent.model_metadata import get_model_context_length
|
||||
_ctx_len = get_model_context_length(
|
||||
self.model, base_url=self.base_url or "", api_key=self.api_key or "")
|
||||
self.model, base_url=self.base_url or "", api_key=self.api_key or "",
|
||||
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None)
|
||||
_ctx_result = preprocess_context_references(
|
||||
message, cwd=os.getcwd(), context_length=_ctx_len)
|
||||
if _ctx_result.expanded or _ctx_result.blocked:
|
||||
@@ -8698,6 +8942,13 @@ class HermesCLI:
|
||||
if _msn:
|
||||
agent_message = _msn + "\n\n" + agent_message
|
||||
self._pending_model_switch_note = None
|
||||
# Prepend pending /reload-skills note so the model sees which
|
||||
# skills were added/removed before handling this turn. Same
|
||||
# one-shot queue pattern as the model-switch note above.
|
||||
_srn = getattr(self, '_pending_skills_reload_note', None)
|
||||
if _srn:
|
||||
agent_message = _srn + "\n\n" + agent_message
|
||||
self._pending_skills_reload_note = None
|
||||
try:
|
||||
result = self.agent.run_conversation(
|
||||
user_message=agent_message,
|
||||
@@ -9344,6 +9595,21 @@ class HermesCLI:
|
||||
self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass # Tips are non-critical — never break startup
|
||||
|
||||
# Curator — kick off a background skill-maintenance pass on startup
|
||||
# if the schedule says we're due. Runs in a daemon thread so it
|
||||
# never blocks the interactive loop. Best-effort; any failure is
|
||||
# swallowed to avoid breaking session startup.
|
||||
try:
|
||||
from agent.curator import maybe_run_curator
|
||||
maybe_run_curator(
|
||||
idle_for_seconds=float("inf"), # CLI startup = fully idle
|
||||
on_summary=lambda msg: self._console_print(
|
||||
f"[dim #6b7684]💾 {msg}[/]"
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if self.preloaded_skills and not self._startup_skills_line_shown:
|
||||
skills_label = ", ".join(self.preloaded_skills)
|
||||
self._console_print(
|
||||
@@ -10013,7 +10279,9 @@ class HermesCLI:
|
||||
# so the 5-line collapse threshold and display are consistent.
|
||||
pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text)
|
||||
pasted_text = _strip_leaked_terminal_responses(pasted_text)
|
||||
pasted_text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(pasted_text)
|
||||
if _had_mouse_reports:
|
||||
self._recover_terminal_input_modes(reason="mouse reports leaked into bracketed paste payload")
|
||||
if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image():
|
||||
event.app.invalidate()
|
||||
if pasted_text:
|
||||
@@ -10167,7 +10435,9 @@ class HermesCLI:
|
||||
event so it never triggers this.
|
||||
"""
|
||||
text = _strip_leaked_bracketed_paste_wrappers(buf.text)
|
||||
text = _strip_leaked_terminal_responses(text)
|
||||
text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(text)
|
||||
if _had_mouse_reports:
|
||||
self._recover_terminal_input_modes(reason="mouse reports leaked into prompt buffer")
|
||||
if text != buf.text:
|
||||
cursor = min(buf.cursor_position, len(text))
|
||||
_paste_just_collapsed[0] = True
|
||||
@@ -10920,7 +11190,9 @@ class HermesCLI:
|
||||
|
||||
if isinstance(user_input, str):
|
||||
user_input = _strip_leaked_bracketed_paste_wrappers(user_input)
|
||||
user_input = _strip_leaked_terminal_responses(user_input)
|
||||
user_input, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(user_input)
|
||||
if _had_mouse_reports:
|
||||
self._recover_terminal_input_modes(reason="mouse reports leaked into submitted input")
|
||||
|
||||
# Check for commands — but detect dragged/pasted file paths first.
|
||||
# See _detect_file_drop() for details.
|
||||
|
||||
+12
-4
@@ -313,13 +313,21 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
|
||||
elif schedule["kind"] == "cron":
|
||||
if not HAS_CRONITER:
|
||||
logger.warning(
|
||||
"Cannot compute next run for cron schedule %r: 'croniter' "
|
||||
"is not installed. Install the 'cron' extra (pip install "
|
||||
"'hermes-agent[cron]') to re-enable recurring cron jobs.",
|
||||
"Cannot compute next run for cron schedule %r: 'croniter' is "
|
||||
"not installed. croniter is a core dependency as of v0.9.x; "
|
||||
"reinstall hermes-agent or run 'pip install croniter' in your "
|
||||
"runtime env.",
|
||||
schedule.get("expr"),
|
||||
)
|
||||
return None
|
||||
cron = croniter(schedule["expr"], now)
|
||||
# Use last_run_at as the croniter base when available, consistent
|
||||
# with interval jobs. This ensures that after a crash/restart,
|
||||
# the next run is anchored to the actual last execution time
|
||||
# rather than to an arbitrary restart time.
|
||||
base_time = now
|
||||
if last_run_at:
|
||||
base_time = _ensure_aware(datetime.fromisoformat(last_run_at))
|
||||
cron = croniter(schedule["expr"], base_time)
|
||||
next_run = cron.get_next(datetime)
|
||||
return next_run.isoformat()
|
||||
|
||||
|
||||
+104
-43
@@ -233,12 +233,32 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
|
||||
}
|
||||
|
||||
|
||||
def _normalize_deliver_value(deliver) -> str:
|
||||
"""Normalize a stored/submitted ``deliver`` value to its canonical string form.
|
||||
|
||||
The contract is that ``deliver`` is a string (``"local"``, ``"origin"``,
|
||||
``"telegram"``, ``"telegram:-1001:17"``, or comma-separated combinations).
|
||||
Historically some callers — MCP clients passing an array, direct edits of
|
||||
``jobs.json``, or stale code paths — have stored a list/tuple like
|
||||
``["telegram"]``. ``str(["telegram"])`` would serialize to the literal
|
||||
string ``"['telegram']"``, which is not a known platform and fails
|
||||
resolution silently. Flatten lists/tuples into a comma-separated string
|
||||
so both forms work. Returns ``"local"`` for anything falsy.
|
||||
"""
|
||||
if deliver is None or deliver == "":
|
||||
return "local"
|
||||
if isinstance(deliver, (list, tuple)):
|
||||
parts = [str(p).strip() for p in deliver if str(p).strip()]
|
||||
return ",".join(parts) if parts else "local"
|
||||
return str(deliver)
|
||||
|
||||
|
||||
def _resolve_delivery_targets(job: dict) -> List[dict]:
|
||||
"""Resolve all concrete auto-delivery targets for a cron job (supports comma-separated deliver)."""
|
||||
deliver = job.get("deliver", "local")
|
||||
deliver = _normalize_deliver_value(job.get("deliver", "local"))
|
||||
if deliver == "local":
|
||||
return []
|
||||
parts = [p.strip() for p in str(deliver).split(",") if p.strip()]
|
||||
parts = [p.strip() for p in deliver.split(",") if p.strip()]
|
||||
seen = set()
|
||||
targets = []
|
||||
for part in parts:
|
||||
@@ -257,13 +277,21 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||
return targets[0] if targets else None
|
||||
|
||||
|
||||
# Media extension sets — keep in sync with gateway/platforms/base.py:_process_message_background
|
||||
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a'})
|
||||
# Media extension sets — audio routing is centralized in gateway.platforms.base
|
||||
# via should_send_media_as_audio() so Telegram-specific rules stay in one place.
|
||||
_VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'})
|
||||
_IMAGE_EXTS = frozenset({'.jpg', '.jpeg', '.png', '.webp', '.gif'})
|
||||
|
||||
|
||||
def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata: dict | None, loop, job: dict) -> None:
|
||||
def _send_media_via_adapter(
|
||||
adapter,
|
||||
chat_id: str,
|
||||
media_files: list,
|
||||
metadata: dict | None,
|
||||
loop,
|
||||
job: dict,
|
||||
platform=None,
|
||||
) -> None:
|
||||
"""Send extracted MEDIA files as native platform attachments via a live adapter.
|
||||
|
||||
Routes each file to the appropriate adapter method (send_voice, send_image_file,
|
||||
@@ -272,10 +300,13 @@ def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata:
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from gateway.platforms.base import should_send_media_as_audio
|
||||
|
||||
for media_path, _is_voice in media_files:
|
||||
try:
|
||||
ext = Path(media_path).suffix.lower()
|
||||
if ext in _AUDIO_EXTS:
|
||||
route_platform = platform if platform is not None else getattr(adapter, "platform", None)
|
||||
if should_send_media_as_audio(route_platform, ext, is_voice=_is_voice):
|
||||
coro = adapter.send_voice(chat_id=chat_id, audio_path=media_path, metadata=metadata)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
coro = adapter.send_video(chat_id=chat_id, video_path=media_path, metadata=metadata)
|
||||
@@ -321,27 +352,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
from tools.send_message_tool import _send_to_platform
|
||||
from gateway.config import load_gateway_config, Platform
|
||||
|
||||
platform_map = {
|
||||
"telegram": Platform.TELEGRAM,
|
||||
"discord": Platform.DISCORD,
|
||||
"slack": Platform.SLACK,
|
||||
"whatsapp": Platform.WHATSAPP,
|
||||
"signal": Platform.SIGNAL,
|
||||
"matrix": Platform.MATRIX,
|
||||
"mattermost": Platform.MATTERMOST,
|
||||
"homeassistant": Platform.HOMEASSISTANT,
|
||||
"dingtalk": Platform.DINGTALK,
|
||||
"feishu": Platform.FEISHU,
|
||||
"wecom": Platform.WECOM,
|
||||
"wecom_callback": Platform.WECOM_CALLBACK,
|
||||
"weixin": Platform.WEIXIN,
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
"bluebubbles": Platform.BLUEBUBBLES,
|
||||
"qqbot": Platform.QQBOT,
|
||||
"yuanbao": Platform.YUANBAO,
|
||||
}
|
||||
|
||||
# Optionally wrap the content with a header/footer so the user knows this
|
||||
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
|
||||
# in config.yaml for clean output.
|
||||
@@ -398,13 +408,23 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
job["id"], platform_name, chat_id, thread_id,
|
||||
)
|
||||
|
||||
platform = platform_map.get(platform_name.lower())
|
||||
if not platform:
|
||||
# Built-in names resolve to their enum member; plugin platform names
|
||||
# create dynamic members via Platform._missing_().
|
||||
try:
|
||||
platform = Platform(platform_name.lower())
|
||||
except (ValueError, KeyError):
|
||||
msg = f"unknown platform '{platform_name}'"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
pconfig = config.platforms.get(platform)
|
||||
if not pconfig or not pconfig.enabled:
|
||||
msg = f"platform '{platform_name}' not configured/enabled"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
# Prefer the live adapter when the gateway is running — this supports E2EE
|
||||
# rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.
|
||||
runtime_adapter = (adapters or {}).get(platform)
|
||||
@@ -435,7 +455,15 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
|
||||
# Send extracted media files as native attachments via the live adapter
|
||||
if adapter_ok and media_files:
|
||||
_send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job)
|
||||
_send_media_via_adapter(
|
||||
runtime_adapter,
|
||||
chat_id,
|
||||
media_files,
|
||||
send_metadata,
|
||||
loop,
|
||||
job,
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
if adapter_ok:
|
||||
logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id)
|
||||
@@ -447,13 +475,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
)
|
||||
|
||||
if not delivered:
|
||||
pconfig = config.platforms.get(platform)
|
||||
if not pconfig or not pconfig.enabled:
|
||||
msg = f"platform '{platform_name}' not configured/enabled"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
# Standalone path: run the async send in a fresh event loop (safe from any thread)
|
||||
coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)
|
||||
try:
|
||||
@@ -840,6 +861,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
chat_id=str(origin["chat_id"]) if origin else "",
|
||||
chat_name=origin.get("chat_name", "") if origin else "",
|
||||
)
|
||||
_cron_delivery_vars = (
|
||||
"HERMES_CRON_AUTO_DELIVER_PLATFORM",
|
||||
"HERMES_CRON_AUTO_DELIVER_CHAT_ID",
|
||||
"HERMES_CRON_AUTO_DELIVER_THREAD_ID",
|
||||
)
|
||||
for _var_name in _cron_delivery_vars:
|
||||
_VAR_MAP[_var_name].set("")
|
||||
|
||||
# Per-job working directory. When set (and validated at create/update
|
||||
# time), we point TERMINAL_CWD at it so:
|
||||
@@ -878,8 +906,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
if delivery_target:
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_PLATFORM"].set(delivery_target["platform"])
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_CHAT_ID"].set(str(delivery_target["chat_id"]))
|
||||
if delivery_target.get("thread_id") is not None:
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_THREAD_ID"].set(str(delivery_target["thread_id"]))
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_THREAD_ID"].set(
|
||||
""
|
||||
if delivery_target.get("thread_id") is None
|
||||
else str(delivery_target["thread_id"])
|
||||
)
|
||||
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
|
||||
|
||||
@@ -1013,10 +1044,12 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
enabled_toolsets=_resolve_cron_enabled_toolsets(job, _cfg),
|
||||
disabled_toolsets=["cronjob", "messaging", "clarify"],
|
||||
quiet_mode=True,
|
||||
# When a workdir is configured, inject AGENTS.md / CLAUDE.md /
|
||||
# .cursorrules from that directory; otherwise preserve the old
|
||||
# behaviour (don't inject SOUL.md/AGENTS.md from the scheduler cwd).
|
||||
# Cron jobs should always inherit the user's SOUL.md identity from
|
||||
# HERMES_HOME. When a workdir is configured, also inject project
|
||||
# context files (AGENTS.md / CLAUDE.md / .cursorrules) from there.
|
||||
# Without a workdir, keep cwd context discovery disabled.
|
||||
skip_context_files=not bool(_job_workdir),
|
||||
load_soul_identity=True,
|
||||
skip_memory=True, # Cron system prompts would corrupt user representations
|
||||
platform="cron",
|
||||
session_id=_cron_session_id,
|
||||
@@ -1031,7 +1064,18 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
#
|
||||
# Uses the agent's built-in activity tracker (updated by
|
||||
# _touch_activity() on every tool call, API call, and stream delta).
|
||||
_cron_timeout = float(os.getenv("HERMES_CRON_TIMEOUT", 600))
|
||||
_raw_cron_timeout = os.getenv("HERMES_CRON_TIMEOUT", "").strip()
|
||||
if _raw_cron_timeout:
|
||||
try:
|
||||
_cron_timeout = float(_raw_cron_timeout)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"Invalid HERMES_CRON_TIMEOUT=%r; using default 600s",
|
||||
_raw_cron_timeout,
|
||||
)
|
||||
_cron_timeout = 600.0
|
||||
else:
|
||||
_cron_timeout = 600.0
|
||||
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
|
||||
_POLL_INTERVAL = 5.0
|
||||
_cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
@@ -1106,6 +1150,21 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
f"agent.run_conversation returned {type(result).__name__} instead of dict: {result!r}"
|
||||
)
|
||||
|
||||
# If the agent itself reported failure (e.g. all retries exhausted on
|
||||
# API errors, model abort, mid-run interrupt), do not silently mark the
|
||||
# job as successful. run_agent populates `failed=True`/`completed=False`
|
||||
# on these paths and may put the error into `final_response`, which
|
||||
# would otherwise be delivered as if it were the agent's reply and the
|
||||
# job's `last_status` set to "ok". Raise so the except handler below
|
||||
# builds the proper failure tuple. (issue #17855)
|
||||
if result.get("failed") is True or result.get("completed") is False:
|
||||
_err_text = (
|
||||
result.get("error")
|
||||
or (result.get("final_response") or "").strip()
|
||||
or "agent reported failure"
|
||||
)
|
||||
raise RuntimeError(_err_text)
|
||||
|
||||
final_response = result.get("final_response", "") or ""
|
||||
# Strip leaked placeholder text that upstream may inject on empty completions.
|
||||
if final_response.strip() == "(No response generated)":
|
||||
@@ -1165,6 +1224,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
os.environ["TERMINAL_CWD"] = _prior_terminal_cwd
|
||||
# Clean up ContextVar session/delivery state for this job.
|
||||
clear_session_vars(_ctx_tokens)
|
||||
for _var_name in _cron_delivery_vars:
|
||||
_VAR_MAP[_var_name].set("")
|
||||
if _session_db:
|
||||
try:
|
||||
_session_db.end_session(_cron_session_id, "cron_complete")
|
||||
|
||||
@@ -34,6 +34,13 @@ services:
|
||||
# uncomment BOTH lines (API_SERVER_KEY is mandatory for auth):
|
||||
# - API_SERVER_HOST=0.0.0.0
|
||||
# - API_SERVER_KEY=${API_SERVER_KEY}
|
||||
# Microsoft Teams — uncomment and fill in to enable Teams gateway.
|
||||
# Register your bot at https://dev.botframework.com/ to get these values.
|
||||
# - TEAMS_CLIENT_ID=${TEAMS_CLIENT_ID}
|
||||
# - TEAMS_CLIENT_SECRET=${TEAMS_CLIENT_SECRET}
|
||||
# - TEAMS_TENANT_ID=${TEAMS_TENANT_ID}
|
||||
# - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS}
|
||||
# - TEAMS_PORT=3978
|
||||
command: ["gateway", "run"]
|
||||
|
||||
dashboard:
|
||||
|
||||
@@ -86,6 +86,16 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
continue
|
||||
platforms[plat_name] = _build_from_sessions(plat_name)
|
||||
|
||||
# Include plugin-registered platforms (dynamic enum members aren't in
|
||||
# Platform.__members__, so the loop above misses them).
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
|
||||
platforms[entry.name] = _build_from_sessions(entry.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
directory = {
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"platforms": platforms,
|
||||
|
||||
+193
-60
@@ -13,7 +13,7 @@ import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
@@ -45,8 +45,19 @@ def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> st
|
||||
return default
|
||||
|
||||
|
||||
# Module-level cache for bundled platform plugin names (lives outside the
|
||||
# enum so it doesn't become an accidental enum member).
|
||||
_Platform__bundled_plugin_names: Optional[set] = None
|
||||
|
||||
|
||||
class Platform(Enum):
|
||||
"""Supported messaging platforms."""
|
||||
"""Supported messaging platforms.
|
||||
|
||||
Built-in platforms have explicit members. Plugin platforms use dynamic
|
||||
members created on-demand by ``_missing_()`` so that
|
||||
``Platform("irc")`` works without modifying this enum. Dynamic members
|
||||
are cached in ``_value2member_map_`` for identity-stable comparisons.
|
||||
"""
|
||||
LOCAL = "local"
|
||||
TELEGRAM = "telegram"
|
||||
DISCORD = "discord"
|
||||
@@ -68,6 +79,76 @@ class Platform(Enum):
|
||||
BLUEBUBBLES = "bluebubbles"
|
||||
QQBOT = "qqbot"
|
||||
YUANBAO = "yuanbao"
|
||||
@classmethod
|
||||
def _missing_(cls, value):
|
||||
"""Accept unknown platform names only for known plugin adapters.
|
||||
|
||||
Creates a pseudo-member cached in ``_value2member_map_`` so that
|
||||
``Platform("irc") is Platform("irc")`` holds True (identity-stable).
|
||||
Arbitrary strings are rejected to prevent enum pollution.
|
||||
"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
# Normalise to lowercase to avoid case mismatches in config
|
||||
value = value.strip().lower()
|
||||
# Check cache first (another call may have created it already)
|
||||
if value in cls._value2member_map_:
|
||||
return cls._value2member_map_[value]
|
||||
|
||||
# Only create pseudo-members for bundled plugin platforms (discovered
|
||||
# via filesystem scan) or runtime-registered plugin platforms.
|
||||
global _Platform__bundled_plugin_names
|
||||
if _Platform__bundled_plugin_names is None:
|
||||
_Platform__bundled_plugin_names = cls._scan_bundled_plugin_platforms()
|
||||
if value in _Platform__bundled_plugin_names:
|
||||
pseudo = object.__new__(cls)
|
||||
pseudo._value_ = value
|
||||
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
|
||||
cls._value2member_map_[value] = pseudo
|
||||
cls._member_map_[pseudo._name_] = pseudo
|
||||
return pseudo
|
||||
|
||||
# Runtime-registered plugins (e.g. user-installed, discovered after
|
||||
# the enum was defined).
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
if platform_registry.is_registered(value):
|
||||
pseudo = object.__new__(cls)
|
||||
pseudo._value_ = value
|
||||
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
|
||||
cls._value2member_map_[value] = pseudo
|
||||
cls._member_map_[pseudo._name_] = pseudo
|
||||
return pseudo
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _scan_bundled_plugin_platforms(cls) -> set:
|
||||
"""Return names of bundled platform plugins under ``plugins/platforms/``."""
|
||||
names: set = set()
|
||||
try:
|
||||
platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
|
||||
if platforms_dir.is_dir():
|
||||
for child in platforms_dir.iterdir():
|
||||
if (
|
||||
child.is_dir()
|
||||
and (child / "__init__.py").exists()
|
||||
and (
|
||||
(child / "plugin.yaml").exists()
|
||||
or (child / "plugin.yml").exists()
|
||||
)
|
||||
):
|
||||
names.add(child.name.lower())
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
# Snapshot of built-in platform values before any dynamic _missing_ lookups.
|
||||
# Used to distinguish real platforms from arbitrary strings.
|
||||
_BUILTIN_PLATFORM_VALUES = frozenset(m.value for m in Platform.__members__.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -231,6 +312,44 @@ class StreamingConfig:
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Built-in platform connection checkers
|
||||
# -----------------------------------------------------------------------------
|
||||
# Each callable receives a ``PlatformConfig`` and returns ``True`` when the
|
||||
# platform is sufficiently configured to be considered "connected". Platforms
|
||||
# that rely on the generic ``token or api_key`` check (Telegram, Discord,
|
||||
# Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here.
|
||||
_PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = {
|
||||
Platform.WEIXIN: lambda cfg: bool(
|
||||
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
|
||||
),
|
||||
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
|
||||
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
|
||||
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
|
||||
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
|
||||
Platform.API_SERVER: lambda cfg: True,
|
||||
Platform.WEBHOOK: lambda cfg: True,
|
||||
Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
|
||||
Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
|
||||
Platform.WECOM_CALLBACK: lambda cfg: bool(
|
||||
cfg.extra.get("corp_id") or cfg.extra.get("apps")
|
||||
),
|
||||
Platform.BLUEBUBBLES: lambda cfg: bool(
|
||||
cfg.extra.get("server_url") and cfg.extra.get("password")
|
||||
),
|
||||
Platform.QQBOT: lambda cfg: bool(
|
||||
cfg.extra.get("app_id") and cfg.extra.get("client_secret")
|
||||
),
|
||||
Platform.YUANBAO: lambda cfg: bool(
|
||||
cfg.extra.get("app_id") and cfg.extra.get("app_secret")
|
||||
),
|
||||
Platform.DINGTALK: lambda cfg: bool(
|
||||
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
||||
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""
|
||||
@@ -284,61 +403,43 @@ class GatewayConfig:
|
||||
for platform, config in self.platforms.items():
|
||||
if not config.enabled:
|
||||
continue
|
||||
# Weixin requires both a token and an account_id
|
||||
if platform == Platform.WEIXIN:
|
||||
if config.extra.get("account_id") and (config.token or config.extra.get("token")):
|
||||
connected.append(platform)
|
||||
continue
|
||||
# Platforms that use token/api_key auth
|
||||
if config.token or config.api_key:
|
||||
if self._is_platform_connected(platform, config):
|
||||
connected.append(platform)
|
||||
# WhatsApp uses enabled flag only (bridge handles auth)
|
||||
elif platform == Platform.WHATSAPP:
|
||||
connected.append(platform)
|
||||
# Signal uses extra dict for config (http_url + account)
|
||||
elif platform == Platform.SIGNAL and config.extra.get("http_url"):
|
||||
connected.append(platform)
|
||||
# Email uses extra dict for config (address + imap_host + smtp_host)
|
||||
elif platform == Platform.EMAIL and config.extra.get("address"):
|
||||
connected.append(platform)
|
||||
# SMS uses api_key (Twilio auth token) — SID checked via env
|
||||
elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"):
|
||||
connected.append(platform)
|
||||
# API Server uses enabled flag only (no token needed)
|
||||
elif platform == Platform.API_SERVER:
|
||||
connected.append(platform)
|
||||
# Webhook uses enabled flag only (secrets are per-route)
|
||||
elif platform == Platform.WEBHOOK:
|
||||
connected.append(platform)
|
||||
# Feishu uses extra dict for app credentials
|
||||
elif platform == Platform.FEISHU and config.extra.get("app_id"):
|
||||
connected.append(platform)
|
||||
# WeCom bot mode uses extra dict for bot credentials
|
||||
elif platform == Platform.WECOM and config.extra.get("bot_id"):
|
||||
connected.append(platform)
|
||||
# WeCom callback mode uses corp_id or apps list
|
||||
elif platform == Platform.WECOM_CALLBACK and (
|
||||
config.extra.get("corp_id") or config.extra.get("apps")
|
||||
):
|
||||
connected.append(platform)
|
||||
# BlueBubbles uses extra dict for local server config
|
||||
elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"):
|
||||
connected.append(platform)
|
||||
# QQBot uses extra dict for app credentials
|
||||
elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
|
||||
connected.append(platform)
|
||||
# Yuanbao uses extra dict for app credentials
|
||||
elif platform == Platform.YUANBAO and config.extra.get("app_id") and config.extra.get("app_secret"):
|
||||
connected.append(platform)
|
||||
# DingTalk uses client_id/client_secret from config.extra or env vars
|
||||
elif platform == Platform.DINGTALK and (
|
||||
config.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")
|
||||
) and (
|
||||
config.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")
|
||||
):
|
||||
connected.append(platform)
|
||||
|
||||
return connected
|
||||
|
||||
def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool:
|
||||
"""Check whether a single platform is sufficiently configured."""
|
||||
# Weixin requires both a token and an account_id (checked first so
|
||||
# the generic token branch doesn't let it through without account_id).
|
||||
if platform == Platform.WEIXIN:
|
||||
return bool(
|
||||
config.extra.get("account_id")
|
||||
and (config.token or config.extra.get("token"))
|
||||
)
|
||||
|
||||
# Generic token/api_key auth covers Telegram, Discord, Slack, etc.
|
||||
if config.token or config.api_key:
|
||||
return True
|
||||
|
||||
# Platform-specific check
|
||||
checker = _PLATFORM_CONNECTED_CHECKERS.get(platform)
|
||||
if checker is not None:
|
||||
return checker(config)
|
||||
|
||||
# Plugin-registered platforms
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(platform.value)
|
||||
if entry:
|
||||
if entry.is_connected is not None:
|
||||
return entry.is_connected(config)
|
||||
if entry.validate_config is not None:
|
||||
return entry.validate_config(config)
|
||||
return True
|
||||
except Exception:
|
||||
pass # Registry not yet initialised during early import
|
||||
|
||||
return False
|
||||
|
||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||
"""Get the home channel for a platform."""
|
||||
@@ -714,11 +815,21 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
|
||||
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
|
||||
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
|
||||
if "group_allowed_chats" in telegram_cfg and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
|
||||
gac = telegram_cfg["group_allowed_chats"]
|
||||
if isinstance(gac, list):
|
||||
gac = ",".join(str(v) for v in gac)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(gac)
|
||||
allowed_users = telegram_cfg.get("allow_from")
|
||||
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
|
||||
group_allowed_users = telegram_cfg.get("group_allow_from")
|
||||
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
|
||||
if isinstance(group_allowed_users, list):
|
||||
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
|
||||
group_allowed_chats = telegram_cfg.get("group_allowed_chats")
|
||||
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
|
||||
if isinstance(group_allowed_chats, list):
|
||||
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
|
||||
if "disable_link_previews" in telegram_cfg:
|
||||
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||
if not isinstance(plat_data, dict):
|
||||
@@ -1371,3 +1482,25 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
config.default_reset_policy.at_hour = int(reset_hour)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Registry-driven enable for plugin platforms. Built-ins have explicit
|
||||
# blocks above; plugins expose check_fn() which is the single source of
|
||||
# truth for "are my env vars set?". When it returns True, ensure the
|
||||
# platform is enabled so start() will create its adapter.
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins() # idempotent
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
try:
|
||||
if not entry.check_fn():
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("check_fn for %s raised: %s", entry.name, e)
|
||||
continue
|
||||
platform = Platform(entry.name)
|
||||
if platform not in config.platforms:
|
||||
config.platforms[platform] = PlatformConfig()
|
||||
config.platforms[platform].enabled = True
|
||||
except Exception as e:
|
||||
logger.debug("Plugin platform enable pass failed: %s", e)
|
||||
|
||||
+16
-3
@@ -21,6 +21,7 @@ Errors in hooks are caught and logged but never block the main pipeline.
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
@@ -97,16 +98,28 @@ class HookRegistry:
|
||||
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
|
||||
continue
|
||||
|
||||
# Dynamically load the handler module
|
||||
# Dynamically load the handler module.
|
||||
# Register in sys.modules BEFORE exec_module so Pydantic /
|
||||
# dataclasses / typing introspection can resolve forward
|
||||
# references (triggered by `from __future__ import annotations`
|
||||
# in the handler). Without this, a handler that declares a
|
||||
# Pydantic BaseModel for webhook/event payloads fails at first
|
||||
# dispatch with "TypeAdapter ... is not fully defined".
|
||||
module_name = f"hermes_hook_{hook_name}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hermes_hook_{hook_name}", handler_path
|
||||
module_name, handler_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
|
||||
continue
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
|
||||
handle_fn = getattr(module, "handle", None)
|
||||
if handle_fn is None:
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Platform Adapter Registry
|
||||
|
||||
Allows platform adapters (built-in and plugin) to self-register so the gateway
|
||||
can discover and instantiate them without hardcoded if/elif chains.
|
||||
|
||||
Built-in adapters continue to use the existing if/elif in _create_adapter()
|
||||
for now. Plugin adapters register here via PluginContext.register_platform()
|
||||
and are looked up first -- if nothing is found the gateway falls through to
|
||||
the legacy code path.
|
||||
|
||||
Usage (plugin side):
|
||||
|
||||
from gateway.platform_registry import platform_registry, PlatformEntry
|
||||
|
||||
platform_registry.register(PlatformEntry(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=check_requirements,
|
||||
validate_config=lambda cfg: bool(cfg.extra.get("server")),
|
||||
required_env=["IRC_SERVER"],
|
||||
install_hint="pip install irc",
|
||||
))
|
||||
|
||||
Usage (gateway side):
|
||||
|
||||
adapter = platform_registry.create_adapter("irc", platform_config)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformEntry:
|
||||
"""Metadata and factory for a single platform adapter."""
|
||||
|
||||
# Identifier used in config.yaml (e.g. "irc", "viber").
|
||||
name: str
|
||||
|
||||
# Human-readable label (e.g. "IRC", "Viber").
|
||||
label: str
|
||||
|
||||
# Factory callable: receives a PlatformConfig, returns an adapter instance.
|
||||
# Using a factory instead of a bare class lets plugins do custom init
|
||||
# (e.g. passing extra kwargs, wrapping in try/except).
|
||||
adapter_factory: Callable[[Any], Any]
|
||||
|
||||
# Returns True when the platform's dependencies are available.
|
||||
check_fn: Callable[[], bool]
|
||||
|
||||
# Optional: given a PlatformConfig, is it properly configured?
|
||||
# If None, the registry skips config validation and lets the adapter
|
||||
# fail at connect() time with a descriptive error.
|
||||
validate_config: Optional[Callable[[Any], bool]] = None
|
||||
|
||||
# Optional: given a PlatformConfig, is the platform connected/enabled?
|
||||
# Used by ``GatewayConfig.get_connected_platforms()`` and setup UI status.
|
||||
# If None, falls back to ``validate_config`` or ``check_fn``.
|
||||
is_connected: Optional[Callable[[Any], bool]] = None
|
||||
|
||||
# Env vars this platform needs (for ``hermes setup`` display).
|
||||
required_env: list = field(default_factory=list)
|
||||
|
||||
# Hint shown when check_fn returns False.
|
||||
install_hint: str = ""
|
||||
|
||||
# Optional setup function for interactive configuration.
|
||||
# Signature: () -> None (prompts user, saves env vars).
|
||||
# If None, falls back to _setup_standard_platform (needs token_var + vars)
|
||||
# or a generic "set these env vars" display.
|
||||
setup_fn: Optional[Callable[[], None]] = None
|
||||
|
||||
# "builtin" or "plugin"
|
||||
source: str = "plugin"
|
||||
|
||||
# Name of the plugin manifest that registered this entry (empty for
|
||||
# built-ins). Used by ``hermes gateway setup`` to auto-enable the
|
||||
# owning plugin when the user configures its platform.
|
||||
plugin_name: str = ""
|
||||
|
||||
# ── Auth env var names (for _is_user_authorized integration) ──
|
||||
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
|
||||
allowed_users_env: str = ""
|
||||
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
|
||||
allow_all_env: str = ""
|
||||
|
||||
# ── Message limits ──
|
||||
# Max message length for smart-chunking. 0 = no limit.
|
||||
max_message_length: int = 0
|
||||
|
||||
# ── Privacy ──
|
||||
# If True, session descriptions redact PII (phone numbers, etc.)
|
||||
pii_safe: bool = False
|
||||
|
||||
# ── Display ──
|
||||
# Emoji for CLI/gateway display (e.g. "💬")
|
||||
emoji: str = "🔌"
|
||||
|
||||
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
|
||||
# (allows /update command from this platform).
|
||||
allow_update_command: bool = True
|
||||
|
||||
# ── LLM guidance ──
|
||||
# Platform hint injected into the system prompt (e.g. "You are on IRC.
|
||||
# Do not use markdown."). Empty string = no hint.
|
||||
platform_hint: str = ""
|
||||
|
||||
|
||||
class PlatformRegistry:
|
||||
"""Central registry of platform adapters.
|
||||
|
||||
Thread-safe for reads (dict lookups are atomic under GIL).
|
||||
Writes happen at startup during sequential discovery.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: dict[str, PlatformEntry] = {}
|
||||
|
||||
def register(self, entry: PlatformEntry) -> None:
|
||||
"""Register a platform adapter entry.
|
||||
|
||||
If an entry with the same name exists, it is replaced (last writer
|
||||
wins -- this lets plugins override built-in adapters if desired).
|
||||
"""
|
||||
if entry.name in self._entries:
|
||||
prev = self._entries[entry.name]
|
||||
logger.info(
|
||||
"Platform '%s' re-registered (was %s, now %s)",
|
||||
entry.name,
|
||||
prev.source,
|
||||
entry.source,
|
||||
)
|
||||
self._entries[entry.name] = entry
|
||||
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Remove a platform entry. Returns True if it existed."""
|
||||
return self._entries.pop(name, None) is not None
|
||||
|
||||
def get(self, name: str) -> Optional[PlatformEntry]:
|
||||
"""Look up a platform entry by name."""
|
||||
return self._entries.get(name)
|
||||
|
||||
def all_entries(self) -> list[PlatformEntry]:
|
||||
"""Return all registered platform entries."""
|
||||
return list(self._entries.values())
|
||||
|
||||
def plugin_entries(self) -> list[PlatformEntry]:
|
||||
"""Return only plugin-registered platform entries."""
|
||||
return [e for e in self._entries.values() if e.source == "plugin"]
|
||||
|
||||
def is_registered(self, name: str) -> bool:
|
||||
return name in self._entries
|
||||
|
||||
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
|
||||
"""Create an adapter instance for the given platform name.
|
||||
|
||||
Returns None if:
|
||||
- No entry registered for *name*
|
||||
- check_fn() returns False (missing deps)
|
||||
- validate_config() returns False (misconfigured)
|
||||
- The factory raises an exception
|
||||
"""
|
||||
entry = self._entries.get(name)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
if not entry.check_fn():
|
||||
hint = f" ({entry.install_hint})" if entry.install_hint else ""
|
||||
logger.warning(
|
||||
"Platform '%s' requirements not met%s",
|
||||
entry.label,
|
||||
hint,
|
||||
)
|
||||
return None
|
||||
|
||||
if entry.validate_config is not None:
|
||||
try:
|
||||
if not entry.validate_config(config):
|
||||
logger.warning(
|
||||
"Platform '%s' config validation failed",
|
||||
entry.label,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Platform '%s' config validation error: %s",
|
||||
entry.label,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
adapter = entry.adapter_factory(config)
|
||||
return adapter
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create adapter for platform '%s': %s",
|
||||
entry.label,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
platform_registry = PlatformRegistry()
|
||||
@@ -1,9 +1,30 @@
|
||||
# Adding a New Messaging Platform
|
||||
|
||||
Checklist for integrating a new messaging platform into the Hermes gateway.
|
||||
Use this as a reference when building a new adapter — every item here is a
|
||||
real integration point that exists in the codebase. Missing any of them will
|
||||
cause broken functionality, missing features, or inconsistent behavior.
|
||||
There are two ways to add a platform to the Hermes gateway:
|
||||
|
||||
## Plugin Path (Recommended for Community/Third-Party)
|
||||
|
||||
Create a plugin directory in `~/.hermes/plugins/` with a `PLUGIN.yaml` and
|
||||
`adapter.py`. The adapter inherits from `BasePlatformAdapter` and registers
|
||||
via `ctx.register_platform()` in the `register(ctx)` entry point. This
|
||||
requires **zero changes to core Hermes code**.
|
||||
|
||||
The plugin system automatically handles: adapter creation, config parsing,
|
||||
user authorization, cron delivery, send_message routing, system prompt hints,
|
||||
status display, gateway setup, and more.
|
||||
|
||||
See `plugins/platforms/irc/` for a complete reference implementation, and
|
||||
`website/docs/developer-guide/adding-platform-adapters.md` for the full
|
||||
plugin guide with code examples.
|
||||
|
||||
---
|
||||
|
||||
## Built-in Path (Core Contributors Only)
|
||||
|
||||
Checklist for integrating a platform directly into the Hermes core.
|
||||
Use this as a reference when building a built-in adapter — every item here
|
||||
is a real integration point. Missing any of them will cause broken
|
||||
functionality, missing features, or inconsistent behavior.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+207
-47
@@ -7,7 +7,9 @@ Exposes an HTTP server with endpoints:
|
||||
- GET /v1/responses/{response_id} — Retrieve a stored response
|
||||
- DELETE /v1/responses/{response_id} — Delete a stored response
|
||||
- GET /v1/models — lists hermes-agent as an available model
|
||||
- GET /v1/capabilities — machine-readable API capabilities for external UIs
|
||||
- POST /v1/runs — start a run, returns run_id immediately (202)
|
||||
- GET /v1/runs/{run_id} — retrieve current run status
|
||||
- GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events
|
||||
- POST /v1/runs/{run_id}/stop — interrupt a running agent
|
||||
- GET /health — health check
|
||||
@@ -590,6 +592,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
# Active run agent/task references for stop support
|
||||
self._active_run_agents: Dict[str, Any] = {}
|
||||
self._active_run_tasks: Dict[str, "asyncio.Task"] = {}
|
||||
# Pollable run status for dashboards and external control-plane UIs.
|
||||
self._run_statuses: Dict[str, Dict[str, Any]] = {}
|
||||
self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity
|
||||
|
||||
@staticmethod
|
||||
@@ -808,6 +812,51 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
],
|
||||
})
|
||||
|
||||
async def _handle_capabilities(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /v1/capabilities — advertise the stable API surface.
|
||||
|
||||
External UIs and orchestrators use this endpoint to discover the API
|
||||
server's plugin-safe contract without scraping docs or assuming that
|
||||
every Hermes version exposes the same endpoints.
|
||||
"""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
return web.json_response({
|
||||
"object": "hermes.api_server.capabilities",
|
||||
"platform": "hermes-agent",
|
||||
"model": self._model_name,
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"required": bool(self._api_key),
|
||||
},
|
||||
"features": {
|
||||
"chat_completions": True,
|
||||
"chat_completions_streaming": True,
|
||||
"responses_api": True,
|
||||
"responses_streaming": True,
|
||||
"run_submission": True,
|
||||
"run_status": True,
|
||||
"run_events_sse": True,
|
||||
"run_stop": True,
|
||||
"tool_progress_events": True,
|
||||
"session_continuity_header": "X-Hermes-Session-Id",
|
||||
"cors": bool(self._cors_origins),
|
||||
},
|
||||
"endpoints": {
|
||||
"health": {"method": "GET", "path": "/health"},
|
||||
"health_detailed": {"method": "GET", "path": "/health/detailed"},
|
||||
"models": {"method": "GET", "path": "/v1/models"},
|
||||
"chat_completions": {"method": "POST", "path": "/v1/chat/completions"},
|
||||
"responses": {"method": "POST", "path": "/v1/responses"},
|
||||
"runs": {"method": "POST", "path": "/v1/runs"},
|
||||
"run_status": {"method": "GET", "path": "/v1/runs/{run_id}"},
|
||||
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
||||
"run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"},
|
||||
},
|
||||
})
|
||||
|
||||
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
|
||||
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
|
||||
auth_err = self._check_auth(request)
|
||||
@@ -932,39 +981,62 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if delta is not None:
|
||||
_stream_q.put(delta)
|
||||
|
||||
def _on_tool_progress(event_type, name, preview, args, **kwargs):
|
||||
"""Send tool progress as a separate SSE event.
|
||||
# Track which tool_call_ids we've emitted a "running" lifecycle
|
||||
# event for, so a "completed" event without a matching "running"
|
||||
# (e.g. internal/filtered tools) is silently dropped instead of
|
||||
# producing an orphaned event clients can't correlate.
|
||||
_started_tool_call_ids: set[str] = set()
|
||||
|
||||
Previously, progress markers like ``⏰ list`` were injected
|
||||
directly into ``delta.content``. OpenAI-compatible frontends
|
||||
(Open WebUI, LobeChat, …) store ``delta.content`` verbatim as
|
||||
the assistant message and send it back on subsequent requests.
|
||||
After enough turns the model learns to *emit* the markers as
|
||||
plain text instead of issuing real tool calls — silently
|
||||
hallucinating tool results. See #6972.
|
||||
def _on_tool_start(tool_call_id, function_name, function_args):
|
||||
"""Emit ``hermes.tool.progress`` with ``status: running``.
|
||||
|
||||
The fix: push a tagged tuple ``("__tool_progress__", payload)``
|
||||
onto the stream queue. The SSE writer emits it as a custom
|
||||
``event: hermes.tool.progress`` line that compliant frontends
|
||||
can render for UX but will *not* persist into conversation
|
||||
history. Clients that don't understand the custom event type
|
||||
silently ignore it per the SSE specification.
|
||||
Replaces the old ``tool_progress_callback("tool.started",
|
||||
...)`` emit so SSE consumers receive a single event per
|
||||
tool start, carrying both the legacy ``tool``/``emoji``/
|
||||
``label`` payload (for #6972 frontends) and the new
|
||||
``toolCallId``/``status`` correlation fields (#16588).
|
||||
|
||||
Skips tools whose names start with ``_`` so internal
|
||||
events (``_thinking``, …) stay off the wire — matching
|
||||
the prior ``_on_tool_progress`` filter exactly.
|
||||
"""
|
||||
if event_type != "tool.started":
|
||||
if not tool_call_id or function_name.startswith("_"):
|
||||
return
|
||||
if name.startswith("_"):
|
||||
return
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(name)
|
||||
label = preview or name
|
||||
_started_tool_call_ids.add(tool_call_id)
|
||||
from agent.display import build_tool_preview, get_tool_emoji
|
||||
label = build_tool_preview(function_name, function_args) or function_name
|
||||
_stream_q.put(("__tool_progress__", {
|
||||
"tool": name,
|
||||
"emoji": emoji,
|
||||
"tool": function_name,
|
||||
"emoji": get_tool_emoji(function_name),
|
||||
"label": label,
|
||||
"toolCallId": tool_call_id,
|
||||
"status": "running",
|
||||
}))
|
||||
|
||||
def _on_tool_complete(tool_call_id, function_name, function_args, function_result):
|
||||
"""Emit the matching ``status: completed`` event.
|
||||
|
||||
Dropped if the start was filtered (internal tool, missing
|
||||
id, or never seen) so clients never get an orphaned
|
||||
``completed`` they can't correlate to a prior ``running``.
|
||||
"""
|
||||
if not tool_call_id or tool_call_id not in _started_tool_call_ids:
|
||||
return
|
||||
_started_tool_call_ids.discard(tool_call_id)
|
||||
_stream_q.put(("__tool_progress__", {
|
||||
"tool": function_name,
|
||||
"toolCallId": tool_call_id,
|
||||
"status": "completed",
|
||||
}))
|
||||
|
||||
# Start agent in background. agent_ref is a mutable container
|
||||
# so the SSE writer can interrupt the agent on client disconnect.
|
||||
#
|
||||
# ``tool_progress_callback`` is intentionally not wired here:
|
||||
# it would duplicate every emit because ``run_agent`` fires it
|
||||
# side-by-side with ``tool_start_callback``/``tool_complete_callback``.
|
||||
# The structured callbacks are strictly richer (they carry the
|
||||
# tool_call id), so they own the chat-completions SSE channel.
|
||||
agent_ref = [None]
|
||||
agent_task = asyncio.ensure_future(self._run_agent(
|
||||
user_message=user_message,
|
||||
@@ -972,7 +1044,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
ephemeral_system_prompt=system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=_on_delta,
|
||||
tool_progress_callback=_on_tool_progress,
|
||||
tool_start_callback=_on_tool_start,
|
||||
tool_complete_callback=_on_tool_complete,
|
||||
agent_ref=agent_ref,
|
||||
))
|
||||
|
||||
@@ -1087,7 +1160,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
Tagged tuples ``("__tool_progress__", payload)`` are sent
|
||||
as a custom ``event: hermes.tool.progress`` SSE event so
|
||||
frontends can display them without storing the markers in
|
||||
conversation history. See #6972.
|
||||
conversation history. See #6972 for the original event,
|
||||
#16588 for the ``toolCallId``/``status`` lifecycle fields.
|
||||
"""
|
||||
if isinstance(item, tuple) and len(item) == 2 and item[0] == "__tool_progress__":
|
||||
event_data = json.dumps(item[1])
|
||||
@@ -2297,10 +2371,31 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
_MAX_CONCURRENT_RUNS = 10 # Prevent unbounded resource allocation
|
||||
_RUN_STREAM_TTL = 300 # seconds before orphaned runs are swept
|
||||
_RUN_STATUS_TTL = 3600 # seconds to retain terminal run status for polling
|
||||
|
||||
def _set_run_status(self, run_id: str, status: str, **fields: Any) -> Dict[str, Any]:
|
||||
"""Update pollable run status without exposing private agent objects."""
|
||||
now = time.time()
|
||||
current = self._run_statuses.get(run_id, {})
|
||||
current.update({
|
||||
"object": "hermes.run",
|
||||
"run_id": run_id,
|
||||
"status": status,
|
||||
"updated_at": now,
|
||||
})
|
||||
current.setdefault("created_at", fields.pop("created_at", now))
|
||||
current.update(fields)
|
||||
self._run_statuses[run_id] = current
|
||||
return current
|
||||
|
||||
def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop"):
|
||||
"""Return a tool_progress_callback that pushes structured events to the run's SSE queue."""
|
||||
def _push(event: Dict[str, Any]) -> None:
|
||||
self._set_run_status(
|
||||
run_id,
|
||||
self._run_statuses.get(run_id, {}).get("status", "running"),
|
||||
last_event=event.get("event"),
|
||||
)
|
||||
q = self._run_streams.get(run_id)
|
||||
if q is None:
|
||||
return
|
||||
@@ -2365,28 +2460,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if not user_message:
|
||||
return web.json_response(_openai_error("No user message found in input"), status=400)
|
||||
|
||||
run_id = f"run_{uuid.uuid4().hex}"
|
||||
loop = asyncio.get_running_loop()
|
||||
q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue()
|
||||
self._run_streams[run_id] = q
|
||||
self._run_streams_created[run_id] = time.time()
|
||||
|
||||
event_cb = self._make_run_event_callback(run_id, loop)
|
||||
|
||||
# Also wire stream_delta_callback so message.delta events flow through
|
||||
def _text_cb(delta: Optional[str]) -> None:
|
||||
if delta is None:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, {
|
||||
"event": "message.delta",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
"delta": delta,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
instructions = body.get("instructions")
|
||||
previous_response_id = body.get("previous_response_id")
|
||||
|
||||
@@ -2434,11 +2507,42 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
)
|
||||
conversation_history.append({"role": msg["role"], "content": str(content)})
|
||||
|
||||
run_id = f"run_{uuid.uuid4().hex}"
|
||||
session_id = body.get("session_id") or stored_session_id or run_id
|
||||
ephemeral_system_prompt = instructions
|
||||
loop = asyncio.get_running_loop()
|
||||
q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue()
|
||||
created_at = time.time()
|
||||
self._run_streams[run_id] = q
|
||||
self._run_streams_created[run_id] = created_at
|
||||
|
||||
event_cb = self._make_run_event_callback(run_id, loop)
|
||||
|
||||
# Also wire stream_delta_callback so message.delta events flow through.
|
||||
def _text_cb(delta: Optional[str]) -> None:
|
||||
if delta is None:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, {
|
||||
"event": "message.delta",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
"delta": delta,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._set_run_status(
|
||||
run_id,
|
||||
"queued",
|
||||
created_at=created_at,
|
||||
session_id=session_id,
|
||||
model=body.get("model", self._model_name),
|
||||
)
|
||||
|
||||
async def _run_and_close():
|
||||
try:
|
||||
self._set_run_status(run_id, "running")
|
||||
agent = self._create_agent(
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
session_id=session_id,
|
||||
@@ -2468,8 +2572,36 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"output": final_response,
|
||||
"usage": usage,
|
||||
})
|
||||
self._set_run_status(
|
||||
run_id,
|
||||
"completed",
|
||||
output=final_response,
|
||||
usage=usage,
|
||||
last_event="run.completed",
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
self._set_run_status(
|
||||
run_id,
|
||||
"cancelled",
|
||||
last_event="run.cancelled",
|
||||
)
|
||||
try:
|
||||
q.put_nowait({
|
||||
"event": "run.cancelled",
|
||||
"run_id": run_id,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("[api_server] run %s failed", run_id)
|
||||
self._set_run_status(
|
||||
run_id,
|
||||
"failed",
|
||||
error=str(exc),
|
||||
last_event="run.failed",
|
||||
)
|
||||
try:
|
||||
q.put_nowait({
|
||||
"event": "run.failed",
|
||||
@@ -2499,6 +2631,21 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
return web.json_response({"run_id": run_id, "status": "started"}, status=202)
|
||||
|
||||
async def _handle_get_run(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /v1/runs/{run_id} — return pollable run status for external UIs."""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
run_id = request.match_info["run_id"]
|
||||
status = self._run_statuses.get(run_id)
|
||||
if status is None:
|
||||
return web.json_response(
|
||||
_openai_error(f"Run not found: {run_id}", code="run_not_found"),
|
||||
status=404,
|
||||
)
|
||||
return web.json_response(status)
|
||||
|
||||
async def _handle_run_events(self, request: "web.Request") -> "web.StreamResponse":
|
||||
"""GET /v1/runs/{run_id}/events — SSE stream of structured agent lifecycle events."""
|
||||
auth_err = self._check_auth(request)
|
||||
@@ -2561,6 +2708,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if agent is None and task is None:
|
||||
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
|
||||
|
||||
self._set_run_status(run_id, "stopping", last_event="run.stopping")
|
||||
|
||||
if agent is not None:
|
||||
try:
|
||||
agent.interrupt("Stop requested via API")
|
||||
@@ -2603,6 +2752,15 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
self._active_run_agents.pop(run_id, None)
|
||||
self._active_run_tasks.pop(run_id, None)
|
||||
|
||||
stale_statuses = [
|
||||
run_id
|
||||
for run_id, status in list(self._run_statuses.items())
|
||||
if status.get("status") in {"completed", "failed", "cancelled"}
|
||||
and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL
|
||||
]
|
||||
for run_id in stale_statuses:
|
||||
self._run_statuses.pop(run_id, None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BasePlatformAdapter interface
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2621,6 +2779,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
|
||||
self._app.router.add_get("/v1/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/models", self._handle_models)
|
||||
self._app.router.add_get("/v1/capabilities", self._handle_capabilities)
|
||||
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
||||
self._app.router.add_post("/v1/responses", self._handle_responses)
|
||||
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
|
||||
@@ -2636,6 +2795,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
|
||||
# Structured event streaming
|
||||
self._app.router.add_post("/v1/runs", self._handle_runs)
|
||||
self._app.router.add_get("/v1/runs/{run_id}", self._handle_get_run)
|
||||
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
|
||||
self._app.router.add_post("/v1/runs/{run_id}/stop", self._handle_stop_run)
|
||||
# Start background sweep to clean up orphaned (unconsumed) run streams
|
||||
|
||||
+301
-84
@@ -23,6 +23,45 @@ from utils import normalize_proxy_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Audio file extensions Hermes recognizes for native audio delivery.
|
||||
# Kept in sync with tools/send_message_tool.py and cron/scheduler.py via
|
||||
# should_send_media_as_audio() below.
|
||||
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
|
||||
# Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio
|
||||
# formats either need to go through sendVoice (Opus/OGG) or must be
|
||||
# delivered as a regular document.
|
||||
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
|
||||
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
|
||||
|
||||
|
||||
def _platform_name(platform) -> str:
|
||||
"""Normalize a Platform enum / raw string into a lowercase name."""
|
||||
value = getattr(platform, "value", platform)
|
||||
return str(value or "").lower()
|
||||
|
||||
|
||||
def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bool:
|
||||
"""Return True when a media file should use the platform's audio sender.
|
||||
|
||||
Other platforms: every recognized audio extension routes through the
|
||||
audio sender.
|
||||
|
||||
Telegram: the Bot API only accepts MP3/M4A for sendAudio and
|
||||
Opus/OGG for sendVoice. Opus/OGG is only routed as audio when the
|
||||
caller flagged ``is_voice=True`` (so we don't turn a regular audio
|
||||
attachment into a voice bubble just because the file happens to be
|
||||
Opus). Everything else falls through to document delivery by
|
||||
returning ``False``.
|
||||
"""
|
||||
normalized_ext = (ext or "").lower()
|
||||
if normalized_ext not in _AUDIO_EXTS:
|
||||
return False
|
||||
if _platform_name(platform) == "telegram":
|
||||
if normalized_ext in _TELEGRAM_VOICE_EXTS:
|
||||
return is_voice
|
||||
return normalized_ext in _TELEGRAM_AUDIO_ATTACHMENT_EXTS
|
||||
return True
|
||||
|
||||
|
||||
def utf16_len(s: str) -> int:
|
||||
"""Count UTF-16 code units in *s*.
|
||||
@@ -1415,6 +1454,41 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
async def send_slash_confirm(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
message: str,
|
||||
session_key: str,
|
||||
confirm_id: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a three-option slash-command confirmation prompt.
|
||||
|
||||
Used by the gateway's generic slash-confirm primitive (see
|
||||
``GatewayRunner._request_slash_confirm``) for commands that have a
|
||||
non-destructive but expensive side effect the user should explicitly
|
||||
acknowledge — the current caller is ``/reload-mcp``, which
|
||||
invalidates the provider prompt cache.
|
||||
|
||||
Platforms with inline-button support (Telegram, Discord, Slack,
|
||||
Matrix, Feishu) should override this to render three buttons:
|
||||
Approve Once / Always Approve / Cancel. Button callbacks MUST be
|
||||
routed back through the gateway by calling
|
||||
``GatewayRunner._resolve_slash_confirm(confirm_id, choice)`` where
|
||||
``choice`` is ``"once"`` / ``"always"`` / ``"cancel"``.
|
||||
|
||||
Platforms without button UIs leave this as the default and fall
|
||||
through to the gateway's text fallback (which sends ``message`` as
|
||||
plain text and intercepts the next ``/approve`` / ``/always`` /
|
||||
``/cancel`` reply).
|
||||
|
||||
``confirm_id`` is a short string generated by the gateway; the
|
||||
adapter stores it alongside any platform-specific state needed to
|
||||
route the callback (e.g. Telegram's ``_approval_state`` dict).
|
||||
"""
|
||||
return SendResult(success=False, error="Not supported")
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""
|
||||
Send a typing indicator.
|
||||
@@ -1431,7 +1505,64 @@ class BasePlatformAdapter(ABC):
|
||||
Default is a no-op for platforms with one-shot typing indicators.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images.
|
||||
|
||||
Accepts ``http(s)://``, ``file://`` URIs in the first tuple
|
||||
element.
|
||||
|
||||
Default implementation sends each item individually,
|
||||
routing animated GIFs through ``send_animation`` and local
|
||||
files through ``send_image_file``.
|
||||
|
||||
Override in subclasses to bundle into a single native API call
|
||||
(e.g. Signal's multi-attachment RPC)
|
||||
"""
|
||||
from urllib.parse import unquote as _unquote
|
||||
|
||||
for image_url, alt_text in images:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
logger.info(
|
||||
"[%s] Sending image: %s (alt=%s)",
|
||||
self.name,
|
||||
safe_url_for_log(image_url),
|
||||
alt_text[:30] if alt_text else "",
|
||||
)
|
||||
if image_url.startswith("file://"):
|
||||
img_result = await self.send_image_file(
|
||||
chat_id=chat_id,
|
||||
image_path=_unquote(image_url[7:]),
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
elif self._is_animation_url(image_url):
|
||||
img_result = await self.send_animation(
|
||||
chat_id=chat_id,
|
||||
animation_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
else:
|
||||
img_result = await self.send_image(
|
||||
chat_id=chat_id,
|
||||
image_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
if not img_result.success:
|
||||
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
|
||||
except Exception as img_err:
|
||||
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1640,7 +1771,7 @@ class BasePlatformAdapter(ABC):
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
media_pattern = re.compile(
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
|
||||
)
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
@@ -1780,11 +1911,19 @@ class BasePlatformAdapter(ABC):
|
||||
if stop_event is None:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + interval
|
||||
while not stop_event.is_set():
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
# Poll instead of wait_for(stop_event.wait()). Cancelling
|
||||
# wait_for while it owns the inner Event.wait task can leave
|
||||
# shutdown paths stuck awaiting the typing task on Python
|
||||
# 3.11/pytest-asyncio; sleep cancellation is immediate.
|
||||
await asyncio.sleep(min(0.25, remaining))
|
||||
if stop_event.is_set():
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
pass # Normal cancellation when handler completes
|
||||
finally:
|
||||
@@ -2117,6 +2256,12 @@ class BasePlatformAdapter(ABC):
|
||||
``release_guard=False`` keeps the adapter-level session guard in place
|
||||
so reset-like commands can finish atomically before follow-up messages
|
||||
are allowed to start a fresh background task.
|
||||
|
||||
Bounded by a 5s timeout so a wedged finally block in the cancelled
|
||||
task (typing-task cleanup, on_processing_complete hook, etc.) can't
|
||||
stall the calling dispatch coroutine — particularly under pytest-
|
||||
asyncio where the event loop's cancellation-propagation semantics
|
||||
differ subtly from a bare ``asyncio.run`` harness.
|
||||
"""
|
||||
task = self._session_tasks.pop(session_key, None)
|
||||
if task is not None and not task.done():
|
||||
@@ -2128,9 +2273,15 @@ class BasePlatformAdapter(ABC):
|
||||
self._expected_cancelled_tasks.add(task)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[%s] Cancelled task for %s did not exit within 5s; "
|
||||
"unblocking dispatch and letting the task unwind in the background",
|
||||
self.name, session_key,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[%s] Session cancellation raised while unwinding %s",
|
||||
@@ -2382,6 +2533,16 @@ class BasePlatformAdapter(ABC):
|
||||
**_keep_typing_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
async def _stop_typing_task() -> None:
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
# Cancellation cleanup must not block adapter shutdown. The
|
||||
# typing task is already cancelled; if the parent task is also
|
||||
# cancelling, let this message-processing task unwind now.
|
||||
pass
|
||||
|
||||
try:
|
||||
await self._run_processing_hook("on_processing_start", event)
|
||||
@@ -2483,47 +2644,57 @@ class BasePlatformAdapter(ABC):
|
||||
# Send extracted images as native attachments
|
||||
if images:
|
||||
logger.info("[%s] Extracted %d image(s) to send as attachments", self.name, len(images))
|
||||
for image_url, alt_text in images:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
logger.info(
|
||||
"[%s] Sending image: %s (alt=%s)",
|
||||
self.name,
|
||||
safe_url_for_log(image_url),
|
||||
alt_text[:30] if alt_text else "",
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=images,
|
||||
metadata=_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
# Route animated GIFs through send_animation for proper playback
|
||||
if self._is_animation_url(image_url):
|
||||
img_result = await self.send_animation(
|
||||
chat_id=event.source.chat_id,
|
||||
animation_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
img_result = await self.send_image(
|
||||
chat_id=event.source.chat_id,
|
||||
image_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
if not img_result.success:
|
||||
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
|
||||
except Exception as img_err:
|
||||
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
|
||||
except Exception as batch_err:
|
||||
logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
|
||||
|
||||
|
||||
# Send extracted media files — route by file type
|
||||
_AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
|
||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
|
||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
# Partition images out of media_files + local_files so they
|
||||
# can be sent as a single batch (Signal RPC)
|
||||
from urllib.parse import quote as _quote
|
||||
_image_paths: list = []
|
||||
_non_image_media: list = []
|
||||
for media_path, is_voice in media_files:
|
||||
_ext = Path(media_path).suffix.lower()
|
||||
if _ext in _IMAGE_EXTS and not is_voice:
|
||||
_image_paths.append(media_path)
|
||||
else:
|
||||
_non_image_media.append((media_path, is_voice))
|
||||
_non_image_local: list = []
|
||||
for file_path in local_files:
|
||||
if Path(file_path).suffix.lower() in _IMAGE_EXTS:
|
||||
_image_paths.append(file_path)
|
||||
else:
|
||||
_non_image_local.append(file_path)
|
||||
|
||||
if _image_paths:
|
||||
try:
|
||||
_batch = [(f"file://{_quote(p)}", "") for p in _image_paths]
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=_batch,
|
||||
metadata=_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
except Exception as batch_err:
|
||||
logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
|
||||
|
||||
for media_path, is_voice in _non_image_media:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
ext = Path(media_path).suffix.lower()
|
||||
if ext in _AUDIO_EXTS:
|
||||
if should_send_media_as_audio(self.platform, ext, is_voice=is_voice):
|
||||
media_result = await self.send_voice(
|
||||
chat_id=event.source.chat_id,
|
||||
audio_path=media_path,
|
||||
@@ -2535,12 +2706,6 @@ class BasePlatformAdapter(ABC):
|
||||
video_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif ext in _IMAGE_EXTS:
|
||||
media_result = await self.send_image_file(
|
||||
chat_id=event.source.chat_id,
|
||||
image_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
media_result = await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
@@ -2553,19 +2718,13 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception as media_err:
|
||||
logger.warning("[%s] Error sending media: %s", self.name, media_err)
|
||||
|
||||
# Send auto-detected local files as native attachments
|
||||
for file_path in local_files:
|
||||
# Send auto-detected local non-image files as native attachments
|
||||
for file_path in _non_image_local:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
await self.send_image_file(
|
||||
chat_id=event.source.chat_id,
|
||||
image_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
if ext in _VIDEO_EXTS:
|
||||
await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=file_path,
|
||||
@@ -2604,14 +2763,28 @@ class BasePlatformAdapter(ABC):
|
||||
_active = self._active_sessions.get(session_key)
|
||||
if _active is not None:
|
||||
_active.clear()
|
||||
typing_task.cancel()
|
||||
await _stop_typing_task()
|
||||
# Spawn a fresh task for the pending message instead of
|
||||
# recursing. Issue #17758: `await
|
||||
# self._process_message_background(...)` here grew the
|
||||
# call stack one frame per chained follow-up, and under
|
||||
# sustained pending-queue activity the C stack would
|
||||
# exhaust at ~2000 frames and SIGSEGV the process.
|
||||
# Mirror the late-arrival drain pattern below: hand off
|
||||
# to a new task and return so this frame can unwind.
|
||||
drain_task = asyncio.create_task(
|
||||
self._process_message_background(pending_event, session_key)
|
||||
)
|
||||
# Hand ownership of the session to the drain task so
|
||||
# stale-lock detection keeps working while it runs.
|
||||
self._session_tasks[session_key] = drain_task
|
||||
try:
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
self._background_tasks.add(drain_task)
|
||||
drain_task.add_done_callback(self._background_tasks.discard)
|
||||
except TypeError:
|
||||
# Tests stub create_task() with non-hashable sentinels; tolerate.
|
||||
pass
|
||||
# Process pending message in new background task
|
||||
await self._process_message_background(pending_event, session_key)
|
||||
return # Already cleaned up
|
||||
return # Drain task owns the session now.
|
||||
|
||||
except asyncio.CancelledError:
|
||||
current_task = asyncio.current_task()
|
||||
@@ -2656,11 +2829,7 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception:
|
||||
pass
|
||||
# Stop typing indicator
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await _stop_typing_task()
|
||||
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
|
||||
# that may have been recreated by _keep_typing after the last stop_typing()
|
||||
try:
|
||||
@@ -2677,25 +2846,41 @@ class BasePlatformAdapter(ABC):
|
||||
# dropped (user never gets a reply).
|
||||
late_pending = self._pending_messages.pop(session_key, None)
|
||||
if late_pending is not None:
|
||||
logger.debug(
|
||||
"[%s] Late-arrival pending message during cleanup — spawning drain task",
|
||||
self.name,
|
||||
)
|
||||
_active = self._active_sessions.get(session_key)
|
||||
if _active is not None:
|
||||
_active.clear()
|
||||
drain_task = asyncio.create_task(
|
||||
self._process_message_background(late_pending, session_key)
|
||||
)
|
||||
# Hand ownership of the session to the drain task so stale-lock
|
||||
# detection keeps working while it runs.
|
||||
self._session_tasks[session_key] = drain_task
|
||||
try:
|
||||
self._background_tasks.add(drain_task)
|
||||
drain_task.add_done_callback(self._background_tasks.discard)
|
||||
except TypeError:
|
||||
# Tests stub create_task() with non-hashable sentinels; tolerate.
|
||||
pass
|
||||
current_task = asyncio.current_task()
|
||||
existing_task = self._session_tasks.get(session_key)
|
||||
if (
|
||||
existing_task is not None
|
||||
and existing_task is not current_task
|
||||
):
|
||||
# The in-band drain (or an earlier late-arrival drain)
|
||||
# already spawned a follow-up task that owns this
|
||||
# session. Re-queue the late-arrival event so that
|
||||
# task picks it up — avoids spawning two concurrent
|
||||
# _process_message_background tasks for the same key
|
||||
# (#17758 follow-up: prevents the create_task path
|
||||
# from racing with itself across the in-band/finally
|
||||
# boundary).
|
||||
self._pending_messages[session_key] = late_pending
|
||||
else:
|
||||
logger.debug(
|
||||
"[%s] Late-arrival pending message during cleanup — spawning drain task",
|
||||
self.name,
|
||||
)
|
||||
_active = self._active_sessions.get(session_key)
|
||||
if _active is not None:
|
||||
_active.clear()
|
||||
drain_task = asyncio.create_task(
|
||||
self._process_message_background(late_pending, session_key)
|
||||
)
|
||||
# Hand ownership of the session to the drain task so stale-lock
|
||||
# detection keeps working while it runs.
|
||||
self._session_tasks[session_key] = drain_task
|
||||
try:
|
||||
self._background_tasks.add(drain_task)
|
||||
drain_task.add_done_callback(self._background_tasks.discard)
|
||||
except TypeError:
|
||||
# Tests stub create_task() with non-hashable sentinels; tolerate.
|
||||
pass
|
||||
# Leave _active_sessions[session_key] populated — the drain
|
||||
# task's own lifecycle will clean it up.
|
||||
else:
|
||||
@@ -2703,16 +2888,34 @@ class BasePlatformAdapter(ABC):
|
||||
# reset-like command that already swapped in its own
|
||||
# command_guard (and cancelled us) can't be accidentally
|
||||
# cleared by our unwind. The command owns the session now.
|
||||
#
|
||||
# The owner-check also covers the in-band drain handoff
|
||||
# above: when we spawned a drain_task and transferred
|
||||
# ownership via ``_session_tasks[session_key] = drain_task``,
|
||||
# ``_session_tasks.get(session_key) is current_task`` is
|
||||
# False, so we leave _active_sessions populated. Without
|
||||
# this guard, the drain task picks up the same
|
||||
# interrupt_event in its own _process_message_background
|
||||
# entry, _release_session_guard's guard-match succeeds,
|
||||
# and we'd delete the entry while the drain task is still
|
||||
# running — letting a concurrent inbound message pass
|
||||
# the Level-1 guard and spawn a second handler for the
|
||||
# same session.
|
||||
current_task = asyncio.current_task()
|
||||
if current_task is not None and self._session_tasks.get(session_key) is current_task:
|
||||
del self._session_tasks[session_key]
|
||||
self._release_session_guard(session_key, guard=interrupt_event)
|
||||
self._release_session_guard(session_key, guard=interrupt_event)
|
||||
|
||||
async def cancel_background_tasks(self) -> None:
|
||||
"""Cancel any in-flight background message-processing tasks.
|
||||
|
||||
Used during gateway shutdown/replacement so active sessions from the old
|
||||
process do not keep running after adapters are being torn down.
|
||||
|
||||
Each cancelled task is awaited with a 5s bound so a wedged finally
|
||||
(typing-task cleanup, on_processing_complete hook) can't stall the
|
||||
whole shutdown path. Stragglers are released from our tracking and
|
||||
allowed to finish unwinding on their own.
|
||||
"""
|
||||
# Loop until no new tasks appear. Without this, a message
|
||||
# arriving during the `await asyncio.gather` below would spawn
|
||||
@@ -2731,7 +2934,21 @@ class BasePlatformAdapter(ABC):
|
||||
for task in tasks:
|
||||
self._expected_cancelled_tasks.add(task)
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
*(asyncio.shield(t) for t in tasks),
|
||||
return_exceptions=True,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[%s] %d background task(s) did not exit within 5s; "
|
||||
"releasing tracking and letting them unwind in the background",
|
||||
self.name, len([t for t in tasks if not t.done()]),
|
||||
)
|
||||
break
|
||||
# Loop: late-arrival tasks spawned during the gather above
|
||||
# will be in self._background_tasks now. Re-check.
|
||||
self._background_tasks.clear()
|
||||
|
||||
@@ -18,7 +18,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
from typing import Callable, Dict, List, Optional, Any, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1343,6 +1343,134 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
msg = await channel.send(content=caption if caption else None, file=file)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single Discord message with multiple attachments.
|
||||
|
||||
Discord permits up to 10 file attachments per message. Batches are
|
||||
chunked accordingly. URL images are downloaded into memory and
|
||||
uploaded as inline attachments (same pattern as ``send_image`` so
|
||||
they render inline, not as bare links). Local files are opened
|
||||
directly. On per-chunk failure the remaining images in that chunk
|
||||
fall back to the base per-image loop.
|
||||
"""
|
||||
if not self._client:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
|
||||
try:
|
||||
import discord as _discord_mod
|
||||
import io as _io
|
||||
from urllib.parse import unquote as _unquote
|
||||
except Exception: # pragma: no cover
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
try:
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
if not channel:
|
||||
logger.warning("[%s] Channel %s not found for multi-image send", self.name, chat_id)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Failed to resolve channel for multi-image send: %s", self.name, e)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
CHUNK = 10
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
files: List[Any] = []
|
||||
captions: List[str] = []
|
||||
aiohttp_session = None
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
captions.append(alt_text)
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning("[%s] Skipping missing image: %s", self.name, local_path)
|
||||
continue
|
||||
files.append(_discord_mod.File(local_path, filename=os.path.basename(local_path)))
|
||||
else:
|
||||
if not is_safe_url(image_url):
|
||||
logger.warning("[%s] Blocked unsafe image URL in batch", self.name)
|
||||
continue
|
||||
# Download to BytesIO so it renders inline
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
if aiohttp_session is None:
|
||||
aiohttp_session = _aiohttp.ClientSession(**_sess_kw)
|
||||
async with aiohttp_session.get(
|
||||
image_url, timeout=_aiohttp.ClientTimeout(total=30), **_req_kw,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(
|
||||
"[%s] Failed to download image (HTTP %d) in batch: %s",
|
||||
self.name, resp.status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
data = await resp.read()
|
||||
ct = resp.headers.get("content-type", "image/png")
|
||||
ext = "png"
|
||||
if "jpeg" in ct or "jpg" in ct:
|
||||
ext = "jpg"
|
||||
elif "gif" in ct:
|
||||
ext = "gif"
|
||||
elif "webp" in ct:
|
||||
ext = "webp"
|
||||
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
|
||||
except Exception as dl_err:
|
||||
logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err)
|
||||
continue
|
||||
|
||||
if not files:
|
||||
continue
|
||||
|
||||
# Use the first caption if any (Discord only has one message body for the group)
|
||||
content = captions[0] if captions else None
|
||||
logger.info(
|
||||
"[%s] Sending %d image(s) as single Discord message (chunk %d/%d)",
|
||||
self.name, len(files), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
|
||||
if self._is_forum_parent(channel):
|
||||
await self._forum_post_file(
|
||||
channel,
|
||||
content=(content or "").strip(),
|
||||
files=files,
|
||||
)
|
||||
else:
|
||||
await channel.send(content=content, files=files)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Multi-image Discord send failed (chunk %d/%d), falling back to per-image: %s",
|
||||
self.name, chunk_idx + 1, len(chunks), e,
|
||||
exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
finally:
|
||||
if aiohttp_session is not None:
|
||||
try:
|
||||
await aiohttp_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def play_tts(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -2270,6 +2398,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
async def slash_reload_mcp(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, "/reload-mcp")
|
||||
|
||||
@tree.command(name="reload-skills", description="Re-scan ~/.hermes/skills/ for new or removed skills")
|
||||
async def slash_reload_skills(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, "/reload-skills")
|
||||
|
||||
@tree.command(name="voice", description="Toggle voice reply mode")
|
||||
@discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
|
||||
@discord.app_commands.choices(mode=[
|
||||
@@ -2906,6 +3038,43 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_slash_confirm(
|
||||
self, chat_id: str, title: str, message: str, session_key: str,
|
||||
confirm_id: str, metadata: Optional[dict] = None,
|
||||
) -> SendResult:
|
||||
"""Send a three-button slash-command confirmation prompt."""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
target_id = chat_id
|
||||
if metadata and metadata.get("thread_id"):
|
||||
target_id = metadata["thread_id"]
|
||||
|
||||
channel = self._client.get_channel(int(target_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(target_id))
|
||||
|
||||
# Embed description limit is 4096; message usually fits easily.
|
||||
max_desc = 4088
|
||||
body = message if len(message) <= max_desc else message[: max_desc - 3] + "..."
|
||||
embed = discord.Embed(
|
||||
title=title or "Confirm",
|
||||
description=body,
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
|
||||
view = SlashConfirmView(
|
||||
session_key=session_key,
|
||||
confirm_id=confirm_id,
|
||||
allowed_user_ids=self._allowed_user_ids,
|
||||
)
|
||||
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
@@ -3639,6 +3808,103 @@ if DISCORD_AVAILABLE:
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
class SlashConfirmView(discord.ui.View):
|
||||
"""Three-button view for generic slash-command confirmations.
|
||||
|
||||
Used by ``/reload-mcp`` and any future slash command routed through
|
||||
``GatewayRunner._request_slash_confirm``. Buttons map to the
|
||||
gateway's three choices:
|
||||
|
||||
* "Approve Once" → ``choice="once"``
|
||||
* "Always Approve" → ``choice="always"``
|
||||
* "Cancel" → ``choice="cancel"``
|
||||
|
||||
Clicking calls the module-level
|
||||
``tools.slash_confirm.resolve(session_key, confirm_id, choice)``
|
||||
which runs the handler the runner stored for this ``session_key``.
|
||||
Only users in the adapter's allowlist can click. Times out after
|
||||
5 minutes (matches the gateway primitive's timeout).
|
||||
"""
|
||||
|
||||
def __init__(self, session_key: str, confirm_id: str, allowed_user_ids: set):
|
||||
super().__init__(timeout=300)
|
||||
self.session_key = session_key
|
||||
self.confirm_id = confirm_id
|
||||
self.allowed_user_ids = allowed_user_ids
|
||||
self.resolved = False
|
||||
|
||||
def _check_auth(self, interaction: discord.Interaction) -> bool:
|
||||
if not self.allowed_user_ids:
|
||||
return True
|
||||
return str(interaction.user.id) in self.allowed_user_ids
|
||||
|
||||
async def _resolve(
|
||||
self, interaction: discord.Interaction, choice: str,
|
||||
color: discord.Color, label: str,
|
||||
):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"This prompt has already been resolved~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized to answer this prompt~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
self.resolved = True
|
||||
|
||||
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||
if embed:
|
||||
embed.color = color
|
||||
embed.set_footer(text=f"{label} by {interaction.user.display_name}")
|
||||
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
# Resolve via the module-level primitive. If the handler
|
||||
# returns a follow-up message, post it in the same channel.
|
||||
try:
|
||||
from tools import slash_confirm as _slash_confirm_mod
|
||||
result_text = await _slash_confirm_mod.resolve(
|
||||
self.session_key, self.confirm_id, choice,
|
||||
)
|
||||
if result_text:
|
||||
await interaction.followup.send(result_text)
|
||||
logger.info(
|
||||
"Discord button resolved slash-confirm for session %s "
|
||||
"(choice=%s, user=%s)",
|
||||
self.session_key, choice, interaction.user.display_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Discord slash-confirm resolve failed: %s", exc, exc_info=True)
|
||||
|
||||
@discord.ui.button(label="Approve Once", style=discord.ButtonStyle.green)
|
||||
async def approve_once(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button,
|
||||
):
|
||||
await self._resolve(interaction, "once", discord.Color.green(), "Approved once")
|
||||
|
||||
@discord.ui.button(label="Always Approve", style=discord.ButtonStyle.blurple)
|
||||
async def approve_always(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button,
|
||||
):
|
||||
await self._resolve(interaction, "always", discord.Color.purple(), "Always approved")
|
||||
|
||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red)
|
||||
async def cancel(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button,
|
||||
):
|
||||
await self._resolve(interaction, "cancel", discord.Color.greyple(), "Cancelled")
|
||||
|
||||
async def on_timeout(self):
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
class UpdatePromptView(discord.ui.View):
|
||||
"""Interactive Yes/No buttons for ``hermes update`` prompts.
|
||||
|
||||
|
||||
+108
-1
@@ -31,7 +31,7 @@ from email.mime.base import MIMEBase
|
||||
from email.utils import formatdate
|
||||
from email import encoders
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
@@ -540,6 +540,113 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
text += f"\n\nImage: {image_url}"
|
||||
return await self.send(chat_id, text.strip(), reply_to)
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single email with multiple MIME attachments.
|
||||
|
||||
Local files are attached directly. URL images have their URL
|
||||
appended to the body (email adapter does not download remote
|
||||
images). No hard cap — email clients handle dozens of
|
||||
attachments fine, subject to SMTP message size limits.
|
||||
"""
|
||||
if not images:
|
||||
return
|
||||
|
||||
from urllib.parse import unquote as _unquote
|
||||
|
||||
body_parts: List[str] = []
|
||||
local_paths: List[str] = []
|
||||
for image_url, alt_text in images:
|
||||
if alt_text:
|
||||
body_parts.append(alt_text)
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if Path(local_path).exists():
|
||||
local_paths.append(local_path)
|
||||
else:
|
||||
logger.warning("[Email] Skipping missing image: %s", local_path)
|
||||
else:
|
||||
# Remote URLs just get linked in the body (parity with send_image)
|
||||
body_parts.append(f"Image: {image_url}")
|
||||
|
||||
if not local_paths and not body_parts:
|
||||
return
|
||||
|
||||
body = "\n\n".join(body_parts)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._send_email_with_attachments,
|
||||
chat_id,
|
||||
body,
|
||||
local_paths,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[Email] Multi-image send failed, falling back: %s", e, exc_info=True)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
|
||||
def _send_email_with_attachments(
|
||||
self,
|
||||
to_addr: str,
|
||||
body: str,
|
||||
file_paths: List[str],
|
||||
) -> str:
|
||||
"""Send an email with multiple file attachments via SMTP."""
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = self._address
|
||||
msg["To"] = to_addr
|
||||
|
||||
ctx = self._thread_context.get(to_addr, {})
|
||||
subject = ctx.get("subject", "Hermes Agent")
|
||||
if not subject.startswith("Re:"):
|
||||
subject = f"Re: {subject}"
|
||||
msg["Subject"] = subject
|
||||
|
||||
original_msg_id = ctx.get("message_id")
|
||||
if original_msg_id:
|
||||
msg["In-Reply-To"] = original_msg_id
|
||||
msg["References"] = original_msg_id
|
||||
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
|
||||
msg["Message-ID"] = msg_id
|
||||
|
||||
if body:
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
for file_path in file_paths:
|
||||
p = Path(file_path)
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(f.read())
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f"attachment; filename={p.name}")
|
||||
msg.attach(part)
|
||||
except Exception as e:
|
||||
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
smtp.close()
|
||||
|
||||
logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths))
|
||||
return msg_id
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import MessageDeduplicator
|
||||
@@ -496,6 +496,100 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Failed to post with file")
|
||||
return SendResult(success=True, message_id=data["id"])
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single Mattermost post with multiple attachments.
|
||||
|
||||
Mattermost supports up to 5 ``file_ids`` per post. Each image is
|
||||
uploaded individually (Mattermost's file API is one-at-a-time),
|
||||
then a single post is created referencing all uploaded file_ids
|
||||
at once. Batches larger than 5 are chunked. Falls back to the
|
||||
base per-image loop on total failure.
|
||||
"""
|
||||
if not images:
|
||||
return
|
||||
|
||||
import mimetypes
|
||||
import aiohttp
|
||||
from urllib.parse import unquote as _unquote
|
||||
|
||||
CHUNK = 5 # Mattermost post file_ids cap
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
file_ids: List[str] = []
|
||||
caption_parts: List[str] = []
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
caption_parts.append(alt_text)
|
||||
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
p = Path(local_path)
|
||||
if not p.exists():
|
||||
logger.warning("Mattermost: skipping missing image %s", local_path)
|
||||
continue
|
||||
fname = p.name
|
||||
ct = mimetypes.guess_type(fname)[0] or "image/png"
|
||||
file_data = p.read_bytes()
|
||||
else:
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(image_url):
|
||||
logger.warning("Mattermost: blocked unsafe image URL in batch")
|
||||
continue
|
||||
try:
|
||||
async with self._session.get(
|
||||
image_url, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
logger.warning(
|
||||
"Mattermost: failed to download image (HTTP %d): %s",
|
||||
resp.status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
file_data = await resp.read()
|
||||
ct = resp.content_type or "image/png"
|
||||
except Exception as dl_err:
|
||||
logger.warning("Mattermost: download failed for %s: %s", image_url[:80], dl_err)
|
||||
continue
|
||||
fname = image_url.rsplit("/", 1)[-1].split("?")[0] or f"image_{len(file_ids)}.png"
|
||||
|
||||
fid = await self._upload_file(chat_id, file_data, fname, ct)
|
||||
if fid:
|
||||
file_ids.append(fid)
|
||||
|
||||
if not file_ids:
|
||||
continue
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": chat_id,
|
||||
"message": "\n".join(caption_parts),
|
||||
"file_ids": file_ids,
|
||||
}
|
||||
logger.info(
|
||||
"Mattermost: sending %d image(s) as single post (chunk %d/%d)",
|
||||
len(file_ids), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
data = await self._api_post("posts", payload)
|
||||
if not data or "id" not in data:
|
||||
logger.warning("Mattermost: multi-image post failed, falling back")
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Mattermost: multi-image send failed (chunk %d/%d), falling back: %s",
|
||||
chunk_idx + 1, len(chunks), e, exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -976,6 +976,18 @@ class QQAdapter(BasePlatformAdapter):
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
# Apply group_policy ACL — guild channels are group-like contexts.
|
||||
# Without this check any member of any guild the bot is in could
|
||||
# bypass the configured allowlist.
|
||||
guild_id = str(d.get("guild_id", ""))
|
||||
author_id = str(author.get("id", ""))
|
||||
if not self._is_group_allowed(guild_id or channel_id, author_id):
|
||||
logger.debug(
|
||||
"[%s] Guild message blocked by ACL: channel=%s user=%s",
|
||||
self._log_tag, channel_id, author_id,
|
||||
)
|
||||
return
|
||||
|
||||
member = d.get("member") if isinstance(d.get("member"), dict) else {}
|
||||
nick = str(member.get("nick", "")) or str(author.get("username", ""))
|
||||
|
||||
@@ -1032,6 +1044,17 @@ class QQAdapter(BasePlatformAdapter):
|
||||
if not guild_id:
|
||||
return
|
||||
|
||||
# Apply dm_policy ACL — guild DMs were previously unauthenticated.
|
||||
# Without this check any member of any guild the bot is in could
|
||||
# bypass the configured allowlist via direct messages.
|
||||
author_id = str(author.get("id", ""))
|
||||
if not self._is_dm_allowed(author_id):
|
||||
logger.debug(
|
||||
"[%s] Guild DM blocked by ACL: guild=%s user=%s",
|
||||
self._log_tag, guild_id, author_id,
|
||||
)
|
||||
return
|
||||
|
||||
text = content
|
||||
att_result = await self._process_attachments(d.get("attachments"))
|
||||
image_urls = att_result["image_urls"]
|
||||
|
||||
+490
-12
@@ -21,7 +21,7 @@ import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import httpx
|
||||
@@ -31,6 +31,7 @@ from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
cache_image_from_bytes,
|
||||
cache_audio_from_bytes,
|
||||
@@ -38,6 +39,17 @@ from gateway.platforms.base import (
|
||||
cache_image_from_url,
|
||||
)
|
||||
from gateway.platforms.helpers import redact_phone
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD,
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG,
|
||||
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
SignalRateLimitError,
|
||||
_extract_retry_after_seconds,
|
||||
_format_wait,
|
||||
_is_signal_rate_limit_error,
|
||||
_signal_send_timeout,
|
||||
get_scheduler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +64,7 @@ SSE_RETRY_DELAY_MAX = 60.0
|
||||
HEALTH_CHECK_INTERVAL = 30.0 # seconds between health checks
|
||||
HEALTH_CHECK_STALE_THRESHOLD = 120.0 # seconds without SSE activity before concern
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -162,6 +175,10 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
"""Signal messenger adapter using signal-cli HTTP daemon."""
|
||||
|
||||
platform = Platform.SIGNAL
|
||||
# Signal has no real edit API for already-sent messages. Mark it explicitly
|
||||
# so streaming suppresses the visible cursor instead of leaving a stale tofu
|
||||
# square behind in chat clients when edit attempts fail.
|
||||
SUPPORTS_MESSAGE_EDITING = False
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.SIGNAL)
|
||||
@@ -488,6 +505,11 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if text and mentions:
|
||||
text = _render_mentions(text, mentions)
|
||||
|
||||
# Extract quote (reply-to) context from Signal dataMessage
|
||||
quote_data = data_message.get("quote") or {}
|
||||
reply_to_id = str(quote_data.get("id")) if quote_data.get("id") else None
|
||||
reply_to_text = quote_data.get("text")
|
||||
|
||||
# Process attachments
|
||||
attachments_data = data_message.get("attachments", [])
|
||||
media_urls = []
|
||||
@@ -541,7 +563,9 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
else:
|
||||
timestamp = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Build and dispatch event
|
||||
# Build and dispatch event.
|
||||
# Store raw envelope data in raw_message so on_processing_start/complete
|
||||
# can extract targetAuthor + targetTimestamp for sendReaction.
|
||||
event = MessageEvent(
|
||||
source=source,
|
||||
text=text or "",
|
||||
@@ -549,6 +573,9 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
timestamp=timestamp,
|
||||
raw_message={"sender": sender, "timestamp_ms": ts_ms},
|
||||
reply_to_message_id=reply_to_id,
|
||||
reply_to_text=reply_to_text,
|
||||
)
|
||||
|
||||
logger.debug("Signal: message from %s in %s: %s",
|
||||
@@ -659,6 +686,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
rpc_id: str = None,
|
||||
*,
|
||||
log_failures: bool = True,
|
||||
raise_on_rate_limit: bool = False,
|
||||
timeout: float = 30.0,
|
||||
) -> Any:
|
||||
"""Send a JSON-RPC 2.0 request to signal-cli daemon.
|
||||
|
||||
@@ -667,6 +696,11 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
repeated NETWORK_FAILURE spam for unreachable recipients while
|
||||
still preserving visibility for the first occurrence and for
|
||||
unrelated RPCs.
|
||||
|
||||
When ``raise_on_rate_limit=True``, a Signal ``[429]`` /
|
||||
``RateLimitException`` response raises ``SignalRateLimitError``
|
||||
instead of being swallowed — lets callers (multi-attachment send)
|
||||
opt into backoff-retry without changing default behaviour.
|
||||
"""
|
||||
if not self.client:
|
||||
logger.warning("Signal: RPC called but client not connected")
|
||||
@@ -686,20 +720,28 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
resp = await self.client.post(
|
||||
f"{self.http_url}/api/v1/rpc",
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
if raise_on_rate_limit:
|
||||
if _is_signal_rate_limit_error(err):
|
||||
err_msg = str(err.get("message", "")) if isinstance(err, dict) else str(err)
|
||||
retry_after = _extract_retry_after_seconds(err)
|
||||
raise SignalRateLimitError(err_msg, retry_after=retry_after)
|
||||
if log_failures:
|
||||
logger.warning("Signal RPC error (%s): %s", method, data["error"])
|
||||
logger.warning("Signal RPC error (%s): %s", method, err)
|
||||
else:
|
||||
logger.debug("Signal RPC error (%s): %s", method, data["error"])
|
||||
logger.debug("Signal RPC error (%s): %s", method, err)
|
||||
return None
|
||||
|
||||
return data.get("result")
|
||||
|
||||
except SignalRateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if log_failures:
|
||||
logger.warning("Signal RPC %s failed: %s", method, e)
|
||||
@@ -707,6 +749,159 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
logger.debug("Signal RPC %s failed: %s", method, e)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Formatting — markdown → Signal body ranges
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _markdown_to_signal(text: str) -> tuple:
|
||||
"""Convert markdown to plain text + Signal textStyles list.
|
||||
|
||||
Signal doesn't render markdown. Instead it uses ``bodyRanges``
|
||||
(exposed by signal-cli as ``textStyle`` / ``textStyles`` params)
|
||||
with the format ``start:length:STYLE``.
|
||||
|
||||
Positions are measured in **UTF-16 code units** (not Python code
|
||||
points) because that's what the Signal protocol uses.
|
||||
|
||||
Supported styles: BOLD, ITALIC, STRIKETHROUGH, MONOSPACE.
|
||||
(Signal's SPOILER style is not currently mapped — no standard
|
||||
markdown syntax for it; would need ``||spoiler||`` parsing.)
|
||||
|
||||
Returns ``(plain_text, styles_list)`` where *styles_list* may be
|
||||
empty if there's nothing to format.
|
||||
"""
|
||||
import re
|
||||
|
||||
def _utf16_len(s: str) -> int:
|
||||
"""Length of *s* in UTF-16 code units."""
|
||||
return len(s.encode("utf-16-le")) // 2
|
||||
|
||||
# Pre-process: normalize whitespace before any position tracking
|
||||
# so later operations don't invalidate recorded offsets.
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
text = text.strip()
|
||||
|
||||
styles: list = []
|
||||
|
||||
# --- Phase 1: fenced code blocks ```...``` → MONOSPACE ---
|
||||
_CB = re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL)
|
||||
while m := _CB.search(text):
|
||||
inner = m.group(1).rstrip("\n")
|
||||
start = m.start()
|
||||
text = text[: m.start()] + inner + text[m.end() :]
|
||||
styles.append((start, len(inner), "MONOSPACE"))
|
||||
|
||||
# --- Phase 2: heading markers # Foo → Foo (BOLD) ---
|
||||
_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
|
||||
new_text = ""
|
||||
last_end = 0
|
||||
for m in _HEADING.finditer(text):
|
||||
new_text += text[last_end : m.start()]
|
||||
last_end = m.end()
|
||||
eol = text.find("\n", m.end())
|
||||
if eol == -1:
|
||||
eol = len(text)
|
||||
heading_text = text[m.end() : eol]
|
||||
start = len(new_text)
|
||||
new_text += heading_text
|
||||
styles.append((start, len(heading_text), "BOLD"))
|
||||
last_end = eol
|
||||
new_text += text[last_end:]
|
||||
text = new_text
|
||||
|
||||
# --- Phase 3: inline patterns (single-pass to avoid offset drift) ---
|
||||
# The old code processed each pattern sequentially, stripping markers
|
||||
# and recording positions per-pass. Later passes shifted text without
|
||||
# adjusting earlier positions → bold/italic landed mid-word.
|
||||
#
|
||||
# Fix: collect ALL non-overlapping matches first, then strip every
|
||||
# marker in one pass so positions are computed against the final text.
|
||||
_PATTERNS = [
|
||||
(re.compile(r"\*\*(.+?)\*\*", re.DOTALL), "BOLD"),
|
||||
(re.compile(r"__(.+?)__", re.DOTALL), "BOLD"),
|
||||
(re.compile(r"~~(.+?)~~", re.DOTALL), "STRIKETHROUGH"),
|
||||
(re.compile(r"`(.+?)`"), "MONOSPACE"),
|
||||
(re.compile(r"(?<!\*)\*(?!\*| )(.+?)(?<!\*)\*(?!\*)"), "ITALIC"),
|
||||
(re.compile(r"(?<!\w)_(?!_)(.+?)(?<!_)_(?!\w)"), "ITALIC"),
|
||||
]
|
||||
|
||||
# Collect all non-overlapping matches (earlier patterns win ties).
|
||||
all_matches: list = [] # (start, end, g1_start, g1_end, style)
|
||||
occupied: list = [] # (start, end) intervals already claimed
|
||||
for pat, style in _PATTERNS:
|
||||
for m in pat.finditer(text):
|
||||
ms, me = m.start(), m.end()
|
||||
if not any(ms < oe and me > os for os, oe in occupied):
|
||||
all_matches.append((ms, me, m.start(1), m.end(1), style))
|
||||
occupied.append((ms, me))
|
||||
all_matches.sort()
|
||||
|
||||
# Build removal list so we can adjust Phase 1/2 styles.
|
||||
# Each match removes its prefix markers (start..g1_start) and
|
||||
# suffix markers (g1_end..end).
|
||||
removals: list = [] # (position, length) sorted
|
||||
for ms, me, g1s, g1e, _ in all_matches:
|
||||
if g1s > ms:
|
||||
removals.append((ms, g1s - ms))
|
||||
if me > g1e:
|
||||
removals.append((g1e, me - g1e))
|
||||
removals.sort()
|
||||
|
||||
# Adjust Phase 1/2 styles for characters about to be removed.
|
||||
def _adj(pos: int) -> int:
|
||||
shift = 0
|
||||
for rp, rl in removals:
|
||||
if rp < pos:
|
||||
shift += min(rl, pos - rp)
|
||||
else:
|
||||
break
|
||||
return pos - shift
|
||||
|
||||
adjusted_prior: list = []
|
||||
for s, l, st in styles:
|
||||
ns = _adj(s)
|
||||
ne = _adj(s + l)
|
||||
if ne > ns:
|
||||
adjusted_prior.append((ns, ne - ns, st))
|
||||
|
||||
# Strip all inline markers in one pass → positions are correct.
|
||||
result = ""
|
||||
last_end = 0
|
||||
inline_styles: list = []
|
||||
for ms, me, g1s, g1e, sty in all_matches:
|
||||
result += text[last_end:ms]
|
||||
pos = len(result)
|
||||
inner = text[g1s:g1e]
|
||||
result += inner
|
||||
inline_styles.append((pos, len(inner), sty))
|
||||
last_end = me
|
||||
result += text[last_end:]
|
||||
text = result
|
||||
|
||||
styles = adjusted_prior + inline_styles
|
||||
|
||||
# Convert code-point offsets → UTF-16 code-unit offsets
|
||||
style_strings = []
|
||||
for cp_start, cp_len, stype in sorted(styles):
|
||||
# Safety: skip any out-of-bounds styles
|
||||
if cp_start < 0 or cp_start + cp_len > len(text):
|
||||
continue
|
||||
u16_start = _utf16_len(text[:cp_start])
|
||||
u16_len = _utf16_len(text[cp_start : cp_start + cp_len])
|
||||
style_strings.append(f"{u16_start}:{u16_len}:{stype}")
|
||||
|
||||
return text, style_strings
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Strip markdown for plain-text fallback (used by base class).
|
||||
|
||||
The actual rich formatting happens in send() via _markdown_to_signal().
|
||||
"""
|
||||
# This is only called if someone uses the base-class send path.
|
||||
# Our send() override bypasses this entirely.
|
||||
return content
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sending
|
||||
# ------------------------------------------------------------------
|
||||
@@ -718,14 +913,22 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a text message."""
|
||||
"""Send a text message with native Signal formatting."""
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
|
||||
plain_text, text_styles = self._markdown_to_signal(content)
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"account": self.account,
|
||||
"message": content,
|
||||
"message": plain_text,
|
||||
}
|
||||
|
||||
if text_styles:
|
||||
if len(text_styles) == 1:
|
||||
params["textStyle"] = text_styles[0]
|
||||
else:
|
||||
params["textStyles"] = text_styles
|
||||
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
@@ -735,11 +938,10 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
|
||||
if result is not None:
|
||||
self._track_sent_timestamp(result)
|
||||
# Use the timestamp from the RPC result as a pseudo message_id.
|
||||
# Signal doesn't have real message IDs, but the stream consumer
|
||||
# needs a truthy value to follow its edit→fallback path correctly.
|
||||
_msg_id = str(result.get("timestamp", "")) if isinstance(result, dict) else None
|
||||
return SendResult(success=True, message_id=_msg_id or None)
|
||||
# Signal has no editable message identifier. Returning None keeps the
|
||||
# stream consumer on the non-edit fallback path instead of pretending
|
||||
# future edits can remove an in-progress cursor from the chat thread.
|
||||
return SendResult(success=True, message_id=None)
|
||||
return SendResult(success=False, error="RPC send failed")
|
||||
|
||||
def _track_sent_timestamp(self, rpc_result) -> None:
|
||||
@@ -803,6 +1005,178 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
self._typing_failures.pop(chat_id, None)
|
||||
self._typing_skip_until.pop(chat_id, None)
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images via chunked Signal RPC calls.
|
||||
|
||||
Per-image alt texts are dropped — Signal's send RPC only carries
|
||||
one shared message body. Bad images (download failure, missing
|
||||
file, oversize) are skipped with a warning so one bad URL
|
||||
doesn't lose the rest of the batch. ``human_delay`` is ignored:
|
||||
the rate-limit scheduler handles inter-batch pacing.
|
||||
"""
|
||||
if not images:
|
||||
return
|
||||
|
||||
scheduler = get_scheduler()
|
||||
logger.info(
|
||||
"Signal send_multiple_images: received %d image(s) for %s — "
|
||||
"scheduler state: %s",
|
||||
len(images), chat_id[:30], scheduler.state(),
|
||||
)
|
||||
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
|
||||
attachments: List[str] = []
|
||||
skipped_download = 0
|
||||
skipped_missing = 0
|
||||
skipped_oversize = 0
|
||||
for image_url, _alt_text in images:
|
||||
if image_url.startswith("file://"):
|
||||
file_path = unquote(image_url[7:])
|
||||
else:
|
||||
try:
|
||||
file_path = await cache_image_from_url(image_url)
|
||||
except Exception as e:
|
||||
logger.warning("Signal: failed to download image %s: %s", image_url, e)
|
||||
skipped_download += 1
|
||||
continue
|
||||
|
||||
if not file_path or not Path(file_path).exists():
|
||||
logger.warning("Signal: image file not found for %s", image_url)
|
||||
skipped_missing += 1
|
||||
continue
|
||||
|
||||
file_size = Path(file_path).stat().st_size
|
||||
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
|
||||
logger.warning(
|
||||
"Signal: image too large (%d bytes), skipping %s", file_size, image_url
|
||||
)
|
||||
skipped_oversize += 1
|
||||
continue
|
||||
|
||||
attachments.append(file_path)
|
||||
|
||||
if not attachments:
|
||||
logger.error(
|
||||
"Signal: no valid images in batch of %d "
|
||||
"(download=%d missing=%d oversize=%d)",
|
||||
len(images), skipped_download, skipped_missing, skipped_oversize,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Signal send_multiple_images: %d/%d images valid, sending in chunks",
|
||||
len(attachments), len(images),
|
||||
)
|
||||
|
||||
base_params: Dict[str, Any] = {
|
||||
"account": self.account,
|
||||
"message": "",
|
||||
}
|
||||
if chat_id.startswith("group:"):
|
||||
base_params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
base_params["recipient"] = [await self._resolve_recipient(chat_id)]
|
||||
|
||||
att_batches = [
|
||||
attachments[i:i + SIGNAL_MAX_ATTACHMENTS_PER_MSG]
|
||||
for i in range(0, len(attachments), SIGNAL_MAX_ATTACHMENTS_PER_MSG)
|
||||
]
|
||||
|
||||
for idx, att_batch in enumerate(att_batches):
|
||||
n = len(att_batch)
|
||||
estimated = scheduler.estimate_wait(n)
|
||||
logger.debug(
|
||||
"Signal batch %d/%d: %d attachments, estimated wait=%.1fs",
|
||||
idx + 1, len(att_batches), n, estimated,
|
||||
)
|
||||
if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD:
|
||||
await self._notify_batch_pacing(
|
||||
chat_id, idx + 1, len(att_batches), estimated
|
||||
)
|
||||
|
||||
params = dict(base_params, attachments=att_batch)
|
||||
send_timeout = _signal_send_timeout(n)
|
||||
|
||||
for attempt in range(1, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS + 1):
|
||||
await scheduler.acquire(n)
|
||||
try:
|
||||
_rpc_t0 = time.monotonic()
|
||||
result = await self._rpc(
|
||||
"send", params, raise_on_rate_limit=True, timeout=send_timeout,
|
||||
)
|
||||
_rpc_duration = time.monotonic() - _rpc_t0
|
||||
if result is not None:
|
||||
self._track_sent_timestamp(result)
|
||||
await scheduler.report_rpc_duration(_rpc_duration, n)
|
||||
logger.info(
|
||||
"Signal batch %d/%d: %d attachments sent in %.1fs "
|
||||
"(attempt %d/%d)",
|
||||
idx + 1, len(att_batches), n, _rpc_duration,
|
||||
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
)
|
||||
else:
|
||||
# Assume the server didn't accept the batch, don't deduce tokens
|
||||
logger.error(
|
||||
"Signal: RPC send failed for batch %d/%d (%d attachments, "
|
||||
"attempt %d/%d, rpc_duration=%.1fs)",
|
||||
idx + 1, len(att_batches), n,
|
||||
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
_rpc_duration,
|
||||
)
|
||||
# Retry transient (non-rate-limit) failures once
|
||||
if attempt < SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
backoff = 2.0 ** attempt
|
||||
logger.info(
|
||||
"Signal: retrying batch %d/%d after %.1fs backoff",
|
||||
idx + 1, len(att_batches), backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
break
|
||||
except SignalRateLimitError as e:
|
||||
scheduler.feedback(e.retry_after, n)
|
||||
if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
"Signal: rate-limit retries exhausted on batch %d/%d "
|
||||
"(%d attachments lost, server retry_after=%s)",
|
||||
idx + 1, len(att_batches), n,
|
||||
f"{e.retry_after:.0f}s" if e.retry_after else "unknown",
|
||||
)
|
||||
break
|
||||
logger.warning(
|
||||
"Signal: rate-limited on batch %d/%d "
|
||||
"(attempt %d/%d, server retry_after=%s); "
|
||||
"scheduler will pace the retry",
|
||||
idx + 1, len(att_batches),
|
||||
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
f"{e.retry_after:.0f}s" if e.retry_after else "unknown",
|
||||
)
|
||||
|
||||
async def _notify_batch_pacing(
|
||||
self,
|
||||
chat_id: str,
|
||||
next_batch_idx: int,
|
||||
total_batches: int,
|
||||
wait_s: float,
|
||||
) -> None:
|
||||
"""Inform the user when an inter-batch pacing wait crosses the
|
||||
notice threshold. Best-effort; logs and continues on failure."""
|
||||
try:
|
||||
await self.send(
|
||||
chat_id,
|
||||
f"(More images coming — pausing ~{_format_wait(wait_s)} "
|
||||
f"for Signal rate limit, batch {next_batch_idx}/{total_batches}.)",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Signal: failed to send pacing notice: %s", e)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -963,6 +1337,110 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
_keep_typing finally block to clean up platform-level typing tasks."""
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reactions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_reaction(
|
||||
self,
|
||||
chat_id: str,
|
||||
emoji: str,
|
||||
target_author: str,
|
||||
target_timestamp: int,
|
||||
) -> bool:
|
||||
"""Send a reaction emoji to a specific message via signal-cli RPC.
|
||||
|
||||
Args:
|
||||
chat_id: The chat (phone number or "group:<id>")
|
||||
emoji: Reaction emoji string (e.g. "👀", "✅")
|
||||
target_author: Phone number / UUID of the message author
|
||||
target_timestamp: Signal timestamp (ms) of the message to react to
|
||||
"""
|
||||
params: Dict[str, Any] = {
|
||||
"account": self.account,
|
||||
"emoji": emoji,
|
||||
"targetAuthor": target_author,
|
||||
"targetTimestamp": target_timestamp,
|
||||
}
|
||||
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
params["recipient"] = [chat_id]
|
||||
|
||||
result = await self._rpc("sendReaction", params)
|
||||
if result is not None:
|
||||
return True
|
||||
logger.debug("Signal: sendReaction failed (chat=%s, emoji=%s)", chat_id[:20], emoji)
|
||||
return False
|
||||
|
||||
async def remove_reaction(
|
||||
self,
|
||||
chat_id: str,
|
||||
target_author: str,
|
||||
target_timestamp: int,
|
||||
) -> bool:
|
||||
"""Remove a reaction by sending an empty-string emoji."""
|
||||
params: Dict[str, Any] = {
|
||||
"account": self.account,
|
||||
"emoji": "",
|
||||
"targetAuthor": target_author,
|
||||
"targetTimestamp": target_timestamp,
|
||||
"remove": True,
|
||||
}
|
||||
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
params["recipient"] = [chat_id]
|
||||
|
||||
result = await self._rpc("sendReaction", params)
|
||||
return result is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Processing Lifecycle Hooks (reactions as progress indicators)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_reaction_target(self, event: MessageEvent) -> Optional[tuple]:
|
||||
"""Extract (target_author, target_timestamp) from a MessageEvent.
|
||||
|
||||
Returns None if the event doesn't carry the raw Signal envelope data
|
||||
needed for sendReaction.
|
||||
"""
|
||||
raw = event.raw_message
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
author = raw.get("sender")
|
||||
ts = raw.get("timestamp_ms")
|
||||
if not author or not ts:
|
||||
return None
|
||||
return (author, ts)
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""React with 👀 when processing begins."""
|
||||
target = self._extract_reaction_target(event)
|
||||
if target:
|
||||
await self.send_reaction(event.source.chat_id, "👀", *target)
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: "ProcessingOutcome") -> None:
|
||||
"""Swap the 👀 reaction for ✅ (success) or ❌ (failure).
|
||||
|
||||
On CANCELLED we leave the 👀 in place — no terminal outcome means
|
||||
the reaction should keep reflecting "in progress" (matches Telegram).
|
||||
"""
|
||||
if outcome == ProcessingOutcome.CANCELLED:
|
||||
return
|
||||
target = self._extract_reaction_target(event)
|
||||
if not target:
|
||||
return
|
||||
chat_id = event.source.chat_id
|
||||
# Remove the in-progress reaction, then add the final one
|
||||
await self.remove_reaction(chat_id, *target)
|
||||
if outcome == ProcessingOutcome.SUCCESS:
|
||||
await self.send_reaction(chat_id, "✅", *target)
|
||||
elif outcome == ProcessingOutcome.FAILURE:
|
||||
await self.send_reaction(chat_id, "❌", *target)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Chat Info
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Signal attachment rate-limit scheduler.
|
||||
|
||||
Process-wide token-bucket simulator that mirrors the per-account
|
||||
attachment rate limit signal-cli/Signal-Server enforce. Producers
|
||||
(``SignalAdapter.send_multiple_images`` and the ``send_message`` tool's
|
||||
Signal path) call ``acquire(n)`` before an attachment send; on a 429
|
||||
they call ``feedback(retry_after, n)`` so the model recalibrates from
|
||||
the server's authoritative hint.
|
||||
|
||||
The scheduler serializes concurrent calls through an ``asyncio.Lock``,
|
||||
giving FIFO fairness across agent sessions sharing one signal-cli
|
||||
daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (source: Signal-{Android,Desktop} source code)
|
||||
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments rate limiting
|
||||
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER = 4 # fallback token refill interval for signal-cli < v0.14.3
|
||||
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS = 2 # initial attempt + 1 retry
|
||||
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # if estimated waiting time > 10s, notify the user about the delay
|
||||
SIGNAL_RPC_ERROR_RATELIMIT = -5 # signal-cli (v0.14.3+) JSON-RPC error code for RateLimitException
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SignalRateLimitError(Exception):
|
||||
"""
|
||||
Raised by ``SignalAdapter._rpc`` for rate-limit responses when the
|
||||
caller has opted in via ``raise_on_rate_limit=True``.
|
||||
|
||||
Carries the server-supplied per-token Retry-After (in seconds) on
|
||||
signal-cli ≥ v0.14.3
|
||||
``retry_after`` is None when the version doesn't expose it.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class SignalSchedulerError(Exception):
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection helpers — used to fish a 429 out of signal-cli's various error
|
||||
# shapes (typed code, [429] substring, libsignal-net RetryLaterException
|
||||
# leaked through AttachmentInvalidException).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# "Retry after 4 seconds" / "retry after 4 second" — libsignal-net's
|
||||
# RetryLaterException string form, surfaced when 429s hit during
|
||||
# attachment upload (signal-cli wraps these as AttachmentInvalidException
|
||||
# rather than RateLimitException, so the typed path doesn't fire).
|
||||
_RETRY_AFTER_RE = re.compile(r"Retry after (\d+(?:\.\d+)?)\s*second", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_retry_after_seconds(err: Any) -> Optional[float]:
|
||||
"""Pull the per-token Retry-After window from a signal-cli rate-limit error.
|
||||
|
||||
Tries two sources, in order:
|
||||
1. ``error.data.response.results[*].retryAfterSeconds`` — the
|
||||
structured field signal-cli ≥ v0.14.3 surfaces for plain
|
||||
RateLimitException.
|
||||
2. ``"Retry after N seconds"`` parsed out of the message — covers
|
||||
libsignal-net's RetryLaterException that gets wrapped as
|
||||
AttachmentInvalidException during attachment upload, where the
|
||||
structured field stays null.
|
||||
|
||||
Returns None when neither yields a value.
|
||||
"""
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
data = err.get("data") or {}
|
||||
response = data.get("response") or {}
|
||||
results = response.get("results") or []
|
||||
candidates = [
|
||||
r.get("retryAfterSeconds") for r in results
|
||||
if isinstance(r, dict) and r.get("retryAfterSeconds")
|
||||
]
|
||||
if candidates:
|
||||
return float(max(candidates))
|
||||
msg = str(err.get("message", ""))
|
||||
else:
|
||||
msg = str(err)
|
||||
match = _RETRY_AFTER_RE.search(msg)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _is_signal_rate_limit_error(err: Any) -> bool:
|
||||
"""True if a signal-cli RPC error reflects a rate-limit failure.
|
||||
|
||||
Matches three layers:
|
||||
- typed ``RATELIMIT_ERROR`` code (signal-cli ≥ v0.14.3, plain
|
||||
RateLimitException)
|
||||
- legacy ``[429] / RateLimitException`` substrings
|
||||
- libsignal-net's ``RetryLaterException`` / ``Retry after N seconds``
|
||||
surfaced inside ``AttachmentInvalidException`` when the rate
|
||||
limit is hit during attachment upload — signal-cli never re-tags
|
||||
these as RateLimitException, so substring is the only signal.
|
||||
"""
|
||||
if isinstance(err, dict) and err.get("code") == SIGNAL_RPC_ERROR_RATELIMIT:
|
||||
return True
|
||||
|
||||
message = (
|
||||
str(err.get("message", ""))
|
||||
if isinstance(err, dict)
|
||||
else str(err)
|
||||
)
|
||||
msg_lower = message.lower()
|
||||
return (
|
||||
"[429]" in message
|
||||
or "ratelimit" in msg_lower
|
||||
or "retrylaterexception" in msg_lower
|
||||
or "retry after" in msg_lower
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_wait(seconds: float) -> str:
|
||||
"""Human-friendly wait label for user-facing pacing notices."""
|
||||
s = max(0.0, seconds)
|
||||
if s < 90:
|
||||
return f"{int(round(s))}s"
|
||||
return f"{max(1, int(round(s / 60)))} min"
|
||||
|
||||
|
||||
def _signal_send_timeout(num_attachments: int) -> float:
|
||||
"""HTTP timeout for a Signal ``send`` RPC.
|
||||
|
||||
signal-cli uploads attachments serially during the call, so the
|
||||
server-side time scales with batch size. Default 30s is fine for
|
||||
text-only sends but truncates large attachment batches mid-upload —
|
||||
we then log a phantom failure even though signal-cli completes the
|
||||
send a few seconds later. Scale at 5s/attachment with a 60s floor.
|
||||
"""
|
||||
if num_attachments <= 0:
|
||||
return 30.0
|
||||
return max(60.0, 5.0 * num_attachments)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SignalAttachmentScheduler:
|
||||
"""Process-wide token-bucket simulator for Signal attachment sends.
|
||||
|
||||
The bucket holds up to ``capacity`` tokens (default 50, matching
|
||||
Signal's server-side rate-limit bucket size). Each attachment consumes one
|
||||
token. Tokens refill at ``refill_rate`` tokens/second, calibrated
|
||||
from the per-token Retry-After hint we get from the server when a
|
||||
429 fires. Until we've observed one, we use the documented default
|
||||
(1 token / 4 seconds).
|
||||
|
||||
Concurrent ``acquire(n)`` calls serialize through an
|
||||
``asyncio.Lock`` — natural FIFO across agent sessions hitting the
|
||||
same daemon.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY),
|
||||
default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER),
|
||||
) -> None:
|
||||
self.capacity = float(capacity)
|
||||
self.tokens = float(capacity)
|
||||
self.refill_rate = 1.0 / float(default_retry_after)
|
||||
self.last_refill = time.monotonic()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refill(self) -> None:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
if elapsed > 0 and self.tokens < self.capacity:
|
||||
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
|
||||
self.last_refill = now
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def estimate_wait(self, n: int) -> float:
|
||||
"""Best-effort estimate of the seconds until ``n`` tokens would
|
||||
be available. Used to decide whether to emit a user-facing
|
||||
pacing notice *before* committing to an ``acquire`` that may
|
||||
block silently. Lock-free; small races vs. concurrent acquires
|
||||
are benign for an informational notice.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
projected = self.tokens
|
||||
if elapsed > 0 and projected < self.capacity:
|
||||
projected = min(self.capacity, projected + elapsed * self.refill_rate)
|
||||
deficit = n - projected
|
||||
if deficit <= 0:
|
||||
return 0.0
|
||||
return deficit / self.refill_rate
|
||||
|
||||
async def acquire(self, n: int) -> float:
|
||||
"""Block until at least ``n`` tokens are available, return the
|
||||
seconds slept.
|
||||
|
||||
Does **not** deduct tokens — the bucket is a read-only model of
|
||||
server-side capacity. Call ``report_rpc_duration()`` after the
|
||||
RPC to synchronise the model with the server timeline.
|
||||
|
||||
Not perfect in case lots of coroutines try to acquire for big
|
||||
uploads (``report_rpc_duration`` will take a long time to get hit)
|
||||
but this is just a simulation. Signal server is ground truth and
|
||||
will raise rate-limit exceptions triggering requeues.
|
||||
|
||||
The lock is released during ``asyncio.sleep`` so other callers
|
||||
can interleave. A retry loop re-checks after each sleep in
|
||||
case the deadline was pessimistic.
|
||||
"""
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
if n > self.capacity:
|
||||
raise SignalSchedulerError(
|
||||
f"Signal scheduler was called requesting {n} tokens "
|
||||
f"(max is {self.capacity})",
|
||||
)
|
||||
|
||||
total_slept = 0.0
|
||||
first_pass = True
|
||||
while True:
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
if self.tokens >= n:
|
||||
if not first_pass or total_slept > 0:
|
||||
logger.debug(
|
||||
"Signal scheduler: tokens sufficient for %d "
|
||||
"(remaining=%.1f, total_slept=%.1fs)",
|
||||
n, self.tokens, total_slept,
|
||||
)
|
||||
return total_slept
|
||||
deficit = n - self.tokens
|
||||
wait = deficit / self.refill_rate
|
||||
if first_pass:
|
||||
logger.info(
|
||||
"Signal scheduler: pausing %.1fs for %d tokens "
|
||||
"(available=%.1f, deficit=%.1f, refill=%.4f/s ≈ %.1fs/token)",
|
||||
wait, n, self.tokens, deficit,
|
||||
self.refill_rate, 1.0 / self.refill_rate,
|
||||
)
|
||||
first_pass = False
|
||||
await asyncio.sleep(wait)
|
||||
total_slept += wait
|
||||
|
||||
async def report_rpc_duration(self, rpc_duration: float, n_attachments: int) -> None:
|
||||
"""Record an attachment-send RPC that just completed.
|
||||
|
||||
Deducts ``n_attachments`` tokens without crediting refill during
|
||||
the upload window. Signal's server checks the bucket at RPC start
|
||||
and does *not* refill during request processing — refill resumes
|
||||
after the response. Crediting upload-time refill causes cumulative
|
||||
drift that eventually triggers 429s.
|
||||
|
||||
Advances ``last_refill`` so the next ``acquire`` / ``_refill``
|
||||
starts counting from this point.
|
||||
"""
|
||||
if n_attachments <= 0:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
token_before = self.tokens
|
||||
self.tokens = max(0.0, token_before - float(n_attachments))
|
||||
self.last_refill = now
|
||||
logger.log(
|
||||
logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG,
|
||||
"Signal scheduler: RPC for %d att took %.1fs — "
|
||||
"tokens %.1f → %.1f (deducted=%d, no upload refill credited, refill=%.4fs⁻¹)",
|
||||
n_attachments, rpc_duration,
|
||||
token_before, self.tokens,
|
||||
n_attachments, self.refill_rate,
|
||||
)
|
||||
|
||||
def feedback(self, retry_after: Optional[float], n_attempted: int) -> None:
|
||||
"""Apply server feedback after a 429.
|
||||
|
||||
``retry_after`` is the per-*token* refill window the server
|
||||
reports (None when signal-cli is older than v0.14.3 and didn't
|
||||
surface it).
|
||||
|
||||
When present we calibrate ``refill_rate`` from it:
|
||||
the server is authoritative.
|
||||
"""
|
||||
if retry_after and retry_after > 0:
|
||||
new_rate = 1.0 / float(retry_after)
|
||||
if new_rate != self.refill_rate:
|
||||
logger.info(
|
||||
"Signal scheduler: calibrating refill_rate to %.4f tokens/sec "
|
||||
"(server retry_after=%.1fs per token)",
|
||||
new_rate, retry_after,
|
||||
)
|
||||
self.refill_rate = new_rate
|
||||
self.tokens = 0.0
|
||||
self.last_refill = time.monotonic()
|
||||
|
||||
def state(self) -> dict:
|
||||
"""Return current scheduler state for diagnostic logging (read-only).
|
||||
|
||||
Does not advance ``last_refill`` — safe to call from logging paths
|
||||
without perturbing the bucket.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
projected = self.tokens
|
||||
if elapsed > 0 and projected < self.capacity:
|
||||
projected = min(self.capacity, projected + elapsed * self.refill_rate)
|
||||
return {
|
||||
"tokens": round(projected, 1),
|
||||
"capacity": int(self.capacity),
|
||||
"refill_rate": round(self.refill_rate, 4),
|
||||
"refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-wide singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_scheduler: Optional[SignalAttachmentScheduler] = None
|
||||
|
||||
|
||||
def get_scheduler() -> SignalAttachmentScheduler:
|
||||
"""Return the process-wide scheduler, creating it on first access."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = SignalAttachmentScheduler()
|
||||
logger.info(
|
||||
"Signal scheduler: created (capacity=%d tokens, refill=%.4f/s ≈ %.1fs/token)",
|
||||
int(_scheduler.capacity),
|
||||
_scheduler.refill_rate,
|
||||
1.0 / _scheduler.refill_rate,
|
||||
)
|
||||
return _scheduler
|
||||
|
||||
|
||||
def _reset_scheduler() -> None:
|
||||
"""Drop the cached scheduler so the next ``get_scheduler`` call
|
||||
builds a fresh one. Test-only — never call from production paths."""
|
||||
global _scheduler
|
||||
_scheduler = None
|
||||
@@ -514,6 +514,15 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
):
|
||||
self._app.action(_action_id)(self._handle_approval_action)
|
||||
|
||||
# Register Block Kit action handlers for slash-confirm buttons
|
||||
# (generic three-option prompts; see tools/slash_confirm.py).
|
||||
for _action_id in (
|
||||
"hermes_confirm_once",
|
||||
"hermes_confirm_always",
|
||||
"hermes_confirm_cancel",
|
||||
):
|
||||
self._app.action(_action_id)(self._handle_slash_confirm_action)
|
||||
|
||||
# Start Socket Mode handler in background
|
||||
self._handler = AsyncSocketModeHandler(self._app, app_token, proxy=proxy_url)
|
||||
_apply_slack_proxy(self._handler.client, proxy_url)
|
||||
@@ -783,6 +792,111 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
raise last_exc
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single Slack message with multiple file uploads.
|
||||
|
||||
Uses ``files_upload_v2`` with its ``file_uploads`` parameter so all
|
||||
images show up attached to one ``initial_comment`` message instead
|
||||
of N separate messages. Falls back to the base per-image loop on
|
||||
any failure.
|
||||
|
||||
The batch limit is 10 file uploads per call (Slack server-side cap).
|
||||
"""
|
||||
if not self._app:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
|
||||
try:
|
||||
import httpx as _httpx
|
||||
from urllib.parse import unquote as _unquote
|
||||
from tools.url_safety import is_safe_url as _is_safe_url
|
||||
except Exception:
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
|
||||
CHUNK = 10
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
file_uploads: List[Dict[str, Any]] = []
|
||||
initial_comment_parts: List[str] = []
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http_client:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
initial_comment_parts.append(alt_text)
|
||||
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning("[Slack] Skipping missing image: %s", local_path)
|
||||
continue
|
||||
file_uploads.append({
|
||||
"file": local_path,
|
||||
"filename": os.path.basename(local_path),
|
||||
})
|
||||
else:
|
||||
if not _is_safe_url(image_url):
|
||||
logger.warning("[Slack] Blocked unsafe image URL in batch")
|
||||
continue
|
||||
try:
|
||||
response = await http_client.get(image_url)
|
||||
response.raise_for_status()
|
||||
ext = "png"
|
||||
ct = response.headers.get("content-type", "")
|
||||
if "jpeg" in ct or "jpg" in ct:
|
||||
ext = "jpg"
|
||||
elif "gif" in ct:
|
||||
ext = "gif"
|
||||
elif "webp" in ct:
|
||||
ext = "webp"
|
||||
file_uploads.append({
|
||||
"content": response.content,
|
||||
"filename": f"image_{len(file_uploads)}.{ext}",
|
||||
})
|
||||
except Exception as dl_err:
|
||||
logger.warning(
|
||||
"[Slack] Download failed for %s: %s",
|
||||
safe_url_for_log(image_url), dl_err,
|
||||
)
|
||||
continue
|
||||
|
||||
if not file_uploads:
|
||||
continue
|
||||
|
||||
initial_comment = "\n".join(initial_comment_parts) if initial_comment_parts else ""
|
||||
logger.info(
|
||||
"[Slack] Sending %d image(s) in single files_upload_v2 (chunk %d/%d)",
|
||||
len(file_uploads), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
channel=chat_id,
|
||||
file_uploads=file_uploads,
|
||||
initial_comment=initial_comment,
|
||||
thread_ts=thread_ts,
|
||||
)
|
||||
self._record_uploaded_file_thread(chat_id, thread_ts)
|
||||
_ = result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[Slack] Multi-image files_upload_v2 failed (chunk %d/%d), falling back to per-image: %s",
|
||||
chunk_idx + 1, len(chunks), e,
|
||||
exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
|
||||
def _record_uploaded_file_thread(self, chat_id: str, thread_ts: Optional[str]) -> None:
|
||||
"""Treat successful file uploads as bot participation in a thread."""
|
||||
if not thread_ts:
|
||||
@@ -1931,6 +2045,168 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
logger.error("[Slack] send_exec_approval failed: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_slash_confirm(
|
||||
self, chat_id: str, title: str, message: str, session_key: str,
|
||||
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a Block Kit three-option slash-command confirmation prompt."""
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
body = message[:2900] + "..." if len(message) > 2900 else message
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
# Encode session_key and confirm_id into the button value so the
|
||||
# callback handler can resolve without extra bookkeeping.
|
||||
value = f"{session_key}|{confirm_id}"
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{title or 'Confirm'}*\n\n{body}",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Approve Once"},
|
||||
"style": "primary",
|
||||
"action_id": "hermes_confirm_once",
|
||||
"value": value,
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Always Approve"},
|
||||
"action_id": "hermes_confirm_always",
|
||||
"value": value,
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Cancel"},
|
||||
"style": "danger",
|
||||
"action_id": "hermes_confirm_cancel",
|
||||
"value": value,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"channel": chat_id,
|
||||
"text": f"{title or 'Confirm'}: {body[:100]}",
|
||||
"blocks": blocks,
|
||||
}
|
||||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
|
||||
result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||
return SendResult(success=True, message_id=result.get("ts", ""), raw_response=result)
|
||||
except Exception as e:
|
||||
logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def _handle_slash_confirm_action(self, ack, body, action) -> None:
|
||||
"""Handle a slash-confirm button click from Block Kit."""
|
||||
await ack()
|
||||
|
||||
action_id = action.get("action_id", "")
|
||||
value = action.get("value", "")
|
||||
message = body.get("message", {})
|
||||
msg_ts = message.get("ts", "")
|
||||
channel_id = body.get("channel", {}).get("id", "")
|
||||
user_name = body.get("user", {}).get("name", "unknown")
|
||||
user_id = body.get("user", {}).get("id", "")
|
||||
|
||||
# Authorization — reuse the exec-approval allowlist.
|
||||
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
|
||||
if allowed_csv:
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
if "*" not in allowed_ids and user_id not in allowed_ids:
|
||||
logger.warning(
|
||||
"[Slack] Unauthorized slash-confirm click by %s (%s) — ignoring",
|
||||
user_name, user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Parse session_key|confirm_id back out
|
||||
if "|" not in value:
|
||||
logger.warning("[Slack] Malformed slash-confirm value: %s", value)
|
||||
return
|
||||
session_key, confirm_id = value.split("|", 1)
|
||||
|
||||
choice_map = {
|
||||
"hermes_confirm_once": "once",
|
||||
"hermes_confirm_always": "always",
|
||||
"hermes_confirm_cancel": "cancel",
|
||||
}
|
||||
choice = choice_map.get(action_id, "cancel")
|
||||
|
||||
label_map = {
|
||||
"once": f"✅ Approved once by {user_name}",
|
||||
"always": f"🔒 Always approved by {user_name}",
|
||||
"cancel": f"❌ Cancelled by {user_name}",
|
||||
}
|
||||
decision_text = label_map.get(choice, f"Resolved by {user_name}")
|
||||
|
||||
# Pull original prompt body out of the section block so we can show
|
||||
# the decision inline without losing context.
|
||||
original_text = ""
|
||||
for block in message.get("blocks", []):
|
||||
if block.get("type") == "section":
|
||||
original_text = block.get("text", {}).get("text", "")
|
||||
break
|
||||
|
||||
updated_blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": original_text or "Confirmation prompt",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{"type": "mrkdwn", "text": decision_text},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
await self._get_client(channel_id).chat_update(
|
||||
channel=channel_id,
|
||||
ts=msg_ts,
|
||||
text=decision_text,
|
||||
blocks=updated_blocks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[Slack] Failed to update slash-confirm message: %s", e)
|
||||
|
||||
# Resolve via the module-level primitive and post any follow-up.
|
||||
try:
|
||||
from tools import slash_confirm as _slash_confirm_mod
|
||||
result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice)
|
||||
if result_text:
|
||||
post_kwargs: Dict[str, Any] = {
|
||||
"channel": channel_id,
|
||||
"text": result_text,
|
||||
}
|
||||
# Inherit the thread so the reply stays in the same place.
|
||||
thread_ts = message.get("thread_ts") or msg_ts
|
||||
if thread_ts:
|
||||
post_kwargs["thread_ts"] = thread_ts
|
||||
await self._get_client(channel_id).chat_postMessage(**post_kwargs)
|
||||
logger.info(
|
||||
"Slack button resolved slash-confirm for session %s (choice=%s, user=%s)",
|
||||
session_key, choice, user_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve slash-confirm from Slack button: %s", exc, exc_info=True)
|
||||
|
||||
async def _handle_approval_action(self, ack, body, action) -> None:
|
||||
"""Handle an approval button click from Block Kit."""
|
||||
await ack()
|
||||
|
||||
+245
-16
@@ -237,14 +237,14 @@ def _wrap_markdown_tables(text: str) -> str:
|
||||
class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
Telegram bot adapter.
|
||||
|
||||
|
||||
Handles:
|
||||
- Receiving messages from users and groups
|
||||
- Sending responses with Telegram markdown
|
||||
- Forum topics (thread_id support)
|
||||
- Media messages
|
||||
"""
|
||||
|
||||
|
||||
# Telegram message limits
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
@@ -252,7 +252,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
MEDIA_GROUP_WAIT_SECONDS = 0.8
|
||||
_GENERAL_TOPIC_THREAD_ID = "1"
|
||||
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.TELEGRAM)
|
||||
self._app: Optional[Application] = None
|
||||
@@ -286,6 +286,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._model_picker_state: Dict[str, dict] = {}
|
||||
# Approval button state: message_id → session_key
|
||||
self._approval_state: Dict[int, str] = {}
|
||||
# Slash-confirm button state: confirm_id → session_key (for /reload-mcp
|
||||
# and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).
|
||||
self._slash_confirm_state: Dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
def _is_callback_user_authorized(user_id: str) -> bool:
|
||||
@@ -994,7 +997,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._set_fatal_error("telegram_connect_error", message, retryable=True)
|
||||
logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Stop polling/webhook, cancel pending album flushes, and disconnect."""
|
||||
pending_media_group_tasks = list(self._media_group_tasks.values())
|
||||
@@ -1411,6 +1414,48 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_slash_confirm(
|
||||
self, chat_id: str, title: str, message: str, session_key: str,
|
||||
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Render a three-button slash-command confirmation prompt."""
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
# Message body: render as plain text (message already contains
|
||||
# markdown formatting from the gateway primitive).
|
||||
preview = message if len(message) <= 3800 else message[:3800] + "..."
|
||||
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton("✅ Approve Once", callback_data=f"sc:once:{confirm_id}"),
|
||||
InlineKeyboardButton("🔒 Always Approve", callback_data=f"sc:always:{confirm_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("❌ Cancel", callback_data=f"sc:cancel:{confirm_id}"),
|
||||
],
|
||||
])
|
||||
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"text": preview,
|
||||
"parse_mode": ParseMode.MARKDOWN,
|
||||
"reply_markup": keyboard,
|
||||
**self._link_preview_kwargs(),
|
||||
}
|
||||
message_thread_id = self._message_thread_id_for_send(thread_id)
|
||||
if message_thread_id is not None:
|
||||
kwargs["message_thread_id"] = message_thread_id
|
||||
|
||||
msg = await self._bot.send_message(**kwargs)
|
||||
self._slash_confirm_state[confirm_id] = session_key
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_slash_confirm failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_model_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1779,6 +1824,68 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
|
||||
return
|
||||
|
||||
# --- Slash-confirm callbacks (sc:choice:confirm_id) ---
|
||||
if data.startswith("sc:"):
|
||||
parts = data.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
choice = parts[1] # once, always, cancel
|
||||
confirm_id = parts[2]
|
||||
|
||||
caller_id = str(getattr(query.from_user, "id", ""))
|
||||
if not self._is_callback_user_authorized(caller_id):
|
||||
await query.answer(text="⛔ You are not authorized to answer this prompt.")
|
||||
return
|
||||
|
||||
session_key = self._slash_confirm_state.pop(confirm_id, None)
|
||||
if not session_key:
|
||||
await query.answer(text="This prompt has already been resolved.")
|
||||
return
|
||||
|
||||
label_map = {
|
||||
"once": "✅ Approved once",
|
||||
"always": "🔒 Always approve",
|
||||
"cancel": "❌ Cancelled",
|
||||
}
|
||||
user_display = getattr(query.from_user, "first_name", "User")
|
||||
label = label_map.get(choice, "Resolved")
|
||||
|
||||
await query.answer(text=label)
|
||||
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=f"{label} by {user_display}",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve via the module-level primitive. The runner stored
|
||||
# a handler keyed by session_key; we run it on the event
|
||||
# loop and (if it returns a string) send it as a follow-up
|
||||
# message in the same chat.
|
||||
try:
|
||||
from tools import slash_confirm as _slash_confirm_mod
|
||||
result_text = await _slash_confirm_mod.resolve(
|
||||
session_key, confirm_id, choice,
|
||||
)
|
||||
if result_text and query.message:
|
||||
# Inherit the prompt message's thread so the reply
|
||||
# lands in the same supergroup topic / reply chain.
|
||||
thread_id = getattr(query.message, "message_thread_id", None)
|
||||
send_kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(query.message.chat_id),
|
||||
"text": result_text,
|
||||
"parse_mode": ParseMode.MARKDOWN,
|
||||
**self._link_preview_kwargs(),
|
||||
}
|
||||
if thread_id is not None:
|
||||
send_kwargs["message_thread_id"] = thread_id
|
||||
await self._bot.send_message(**send_kwargs)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
|
||||
return
|
||||
|
||||
# --- Update prompt callbacks ---
|
||||
if not data.startswith("update_prompt:"):
|
||||
return
|
||||
@@ -1844,8 +1951,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
|
||||
|
||||
with open(audio_path, "rb") as audio_file:
|
||||
# .ogg files -> send as voice (round playable bubble)
|
||||
if audio_path.endswith((".ogg", ".opus")):
|
||||
ext = os.path.splitext(audio_path)[1].lower()
|
||||
# .ogg / .opus files -> send as voice (round playable bubble)
|
||||
if ext in (".ogg", ".opus"):
|
||||
_voice_thread = self._metadata_thread_id(metadata)
|
||||
msg = await self._bot.send_voice(
|
||||
chat_id=int(chat_id),
|
||||
@@ -1854,8 +1962,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
reply_to_message_id=int(reply_to) if reply_to else None,
|
||||
message_thread_id=self._message_thread_id_for_send(_voice_thread),
|
||||
)
|
||||
else:
|
||||
# .mp3 and others -> send as audio file
|
||||
elif ext in (".mp3", ".m4a"):
|
||||
# Telegram's Bot API sendAudio only accepts MP3 / M4A.
|
||||
_audio_thread = self._metadata_thread_id(metadata)
|
||||
msg = await self._bot.send_audio(
|
||||
chat_id=int(chat_id),
|
||||
@@ -1864,6 +1972,16 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
reply_to_message_id=int(reply_to) if reply_to else None,
|
||||
message_thread_id=self._message_thread_id_for_send(_audio_thread),
|
||||
)
|
||||
else:
|
||||
# Formats Telegram can't play natively (.wav, .flac, ...)
|
||||
# — fall back to document delivery instead of raising.
|
||||
return await self.send_document(
|
||||
chat_id=chat_id,
|
||||
file_path=audio_path,
|
||||
caption=caption,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -1873,7 +1991,118 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
exc_info=True,
|
||||
)
|
||||
return await super().send_voice(chat_id, audio_path, caption, reply_to)
|
||||
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[tuple],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images natively via Telegram's media group API.
|
||||
|
||||
Telegram's ``send_media_group`` bundles up to 10 photos/videos into
|
||||
a single album. Larger batches are chunked. Animated GIFs cannot
|
||||
go into a media group (they require ``send_animation``), so they
|
||||
are peeled off and sent individually via the base default path.
|
||||
|
||||
URL-based photos go into the group directly; local files are
|
||||
opened as byte streams. On failure the whole batch falls back to
|
||||
the base adapter's per-image loop.
|
||||
"""
|
||||
if not self._bot:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
|
||||
try:
|
||||
from telegram import InputMediaPhoto
|
||||
except Exception as exc: # pragma: no cover - missing SDK
|
||||
logger.warning(
|
||||
"[%s] InputMediaPhoto unavailable, falling back to per-image send: %s",
|
||||
self.name, exc,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
# Peel off animations — they need send_animation, not send_media_group
|
||||
animations: List[tuple] = []
|
||||
photos: List[tuple] = []
|
||||
for image_url, alt_text in images:
|
||||
if not image_url.startswith("file://") and self._is_animation_url(image_url):
|
||||
animations.append((image_url, alt_text))
|
||||
else:
|
||||
photos.append((image_url, alt_text))
|
||||
|
||||
# Animations: route through the base default (per-image send_animation)
|
||||
if animations:
|
||||
await super().send_multiple_images(
|
||||
chat_id, animations, metadata, human_delay=human_delay,
|
||||
)
|
||||
|
||||
if not photos:
|
||||
return
|
||||
|
||||
from urllib.parse import unquote as _unquote
|
||||
_thread = self._metadata_thread_id(metadata)
|
||||
_thread_id = self._message_thread_id_for_send(_thread)
|
||||
|
||||
# Chunk into groups of 10 (Telegram's album limit)
|
||||
CHUNK = 10
|
||||
chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
media: List[Any] = []
|
||||
opened_files: List[Any] = []
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
caption = alt_text[:1024] if alt_text else None
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning(
|
||||
"[%s] Skipping missing image in media group: %s",
|
||||
self.name, local_path,
|
||||
)
|
||||
continue
|
||||
fh = open(local_path, "rb")
|
||||
opened_files.append(fh)
|
||||
media.append(InputMediaPhoto(media=fh, caption=caption))
|
||||
else:
|
||||
media.append(InputMediaPhoto(media=image_url, caption=caption))
|
||||
|
||||
if not media:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"[%s] Sending media group of %d photo(s) (chunk %d/%d)",
|
||||
self.name, len(media), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
await self._bot.send_media_group(
|
||||
chat_id=int(chat_id),
|
||||
media=media,
|
||||
message_thread_id=_thread_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s",
|
||||
self.name, chunk_idx + 1, len(chunks), e,
|
||||
exc_info=True,
|
||||
)
|
||||
# Fallback: send each photo in this chunk individually
|
||||
await super().send_multiple_images(
|
||||
chat_id, chunk, metadata, human_delay=human_delay,
|
||||
)
|
||||
finally:
|
||||
for fh in opened_files:
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -2040,7 +2269,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
# Final fallback: send URL as text
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
||||
|
||||
|
||||
async def send_animation(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -2102,7 +2331,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Get information about a Telegram chat."""
|
||||
if not self._bot:
|
||||
@@ -2136,7 +2365,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
exc_info=True,
|
||||
)
|
||||
return {"name": str(chat_id), "type": "dm", "error": str(e)}
|
||||
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""
|
||||
Convert standard markdown to Telegram MarkdownV2 format.
|
||||
@@ -2308,7 +2537,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
text = ''.join(_safe_parts)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# ── Group mention gating ──────────────────────────────────────────────
|
||||
|
||||
def _telegram_require_mention(self) -> bool:
|
||||
@@ -2523,7 +2752,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
event = self._build_message_event(update.message, MessageType.TEXT, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
|
||||
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming command messages."""
|
||||
if not update.message or not update.message.text:
|
||||
@@ -2533,7 +2762,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.COMMAND, update_id=update.update_id)
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming location/venue pin messages."""
|
||||
if not update.message:
|
||||
@@ -2891,7 +3120,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
|
||||
"""Buffer Telegram media-group items so albums arrive as one logical event.
|
||||
|
||||
|
||||
@@ -202,26 +202,22 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
if deliver_type == "github_comment":
|
||||
return await self._deliver_github_comment(content, delivery)
|
||||
|
||||
# Cross-platform delivery — any platform with a gateway adapter
|
||||
if self.gateway_runner and deliver_type in (
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"signal",
|
||||
"sms",
|
||||
"whatsapp",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"homeassistant",
|
||||
"email",
|
||||
"dingtalk",
|
||||
"feishu",
|
||||
"wecom",
|
||||
"wecom_callback",
|
||||
"weixin",
|
||||
"bluebubbles",
|
||||
"qqbot",
|
||||
):
|
||||
# Cross-platform delivery — any platform with a gateway adapter.
|
||||
# Check both built-in names and plugin-registered platforms.
|
||||
_BUILTIN_DELIVER_PLATFORMS = {
|
||||
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
|
||||
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
|
||||
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
|
||||
"qqbot", "yuanbao",
|
||||
}
|
||||
_is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS
|
||||
if not _is_known_platform:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
_is_known_platform = platform_registry.is_registered(deliver_type)
|
||||
except Exception:
|
||||
pass
|
||||
if self.gateway_runner and _is_known_platform:
|
||||
return await self._deliver_cross_platform(
|
||||
deliver_type, content, delivery
|
||||
)
|
||||
|
||||
@@ -92,6 +92,18 @@ SESSION_EXPIRED_ERRCODE = -14
|
||||
RATE_LIMIT_ERRCODE = -2 # iLink frequency limit — backoff and retry
|
||||
MESSAGE_DEDUP_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _is_stale_session_ret(
|
||||
ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]",
|
||||
) -> bool:
|
||||
"""True when iLink returns ret=-2 / errcode=-2 with 'unknown error',
|
||||
which is a stale-session signal (same as errcode=-14) rather than
|
||||
a genuine rate limit."""
|
||||
if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
|
||||
return False
|
||||
return (errmsg or "").lower() == "unknown error"
|
||||
|
||||
|
||||
MEDIA_IMAGE = 1
|
||||
MEDIA_VIDEO = 2
|
||||
MEDIA_FILE = 3
|
||||
@@ -1210,6 +1222,17 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
self._mark_connected()
|
||||
_LIVE_ADAPTERS[self._token] = self
|
||||
logger.info("[%s] Connected account=%s base=%s", self.name, _safe_id(self._account_id), self._base_url)
|
||||
if self._group_policy != "disabled":
|
||||
logger.warning(
|
||||
"[%s] WEIXIN_GROUP_POLICY=%s is set, but QR-login connects an iLink bot "
|
||||
"identity (e.g. ...@im.bot) which typically cannot be invited into ordinary "
|
||||
"WeChat groups. iLink usually does not deliver ordinary-group events for "
|
||||
"these accounts, so group messages may never reach Hermes regardless of this "
|
||||
"policy. If group delivery doesn't work, the limitation is on the iLink side, "
|
||||
"not in Hermes.",
|
||||
self.name,
|
||||
self._group_policy,
|
||||
)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
@@ -1254,7 +1277,8 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
ret = response.get("ret", 0)
|
||||
errcode = response.get("errcode", 0)
|
||||
if ret not in (0, None) or errcode not in (0, None):
|
||||
if ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE:
|
||||
if (ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE
|
||||
or _is_stale_session_ret(ret, errcode, response.get("errmsg"))):
|
||||
logger.error("[%s] Session expired; pausing for 10 minutes", self.name)
|
||||
await asyncio.sleep(600)
|
||||
consecutive_failures = 0
|
||||
@@ -1519,6 +1543,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
is_session_expired = (
|
||||
ret == SESSION_EXPIRED_ERRCODE
|
||||
or errcode == SESSION_EXPIRED_ERRCODE
|
||||
or _is_stale_session_ret(ret, errcode, resp.get("errmsg"))
|
||||
)
|
||||
# Session expired — strip token and retry once
|
||||
if is_session_expired and not retried_without_token and context_token:
|
||||
@@ -1595,7 +1620,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
_, image_cleaned = self.extract_images(cleaned_content)
|
||||
local_files, final_content = self.extract_local_files(image_cleaned)
|
||||
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
|
||||
|
||||
+833
-106
File diff suppressed because it is too large
Load Diff
+24
-13
@@ -62,6 +62,7 @@ from .config import (
|
||||
)
|
||||
from .whatsapp_identity import (
|
||||
canonical_whatsapp_identifier,
|
||||
normalize_whatsapp_identifier, # noqa: F401 - re-exported for gateway.session callers
|
||||
)
|
||||
from utils import atomic_replace
|
||||
|
||||
@@ -234,7 +235,7 @@ def build_session_context_prompt(
|
||||
) -> str:
|
||||
"""
|
||||
Build the dynamic system prompt section that tells the agent about its context.
|
||||
|
||||
|
||||
This is injected into the system prompt so the agent knows:
|
||||
- Where messages are coming from
|
||||
- What platforms are connected
|
||||
@@ -246,13 +247,23 @@ def build_session_context_prompt(
|
||||
Platforms like Discord are excluded because mentions need real IDs.
|
||||
Routing still uses the original values (they stay in SessionSource).
|
||||
"""
|
||||
# Only apply redaction on platforms where IDs aren't needed for mentions
|
||||
redact_pii = redact_pii and context.source.platform in _PII_SAFE_PLATFORMS
|
||||
# Only apply redaction on platforms where IDs aren't needed for mentions.
|
||||
# Check both the hardcoded set (builtins) and the plugin registry.
|
||||
_is_pii_safe = context.source.platform in _PII_SAFE_PLATFORMS
|
||||
if not _is_pii_safe:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(context.source.platform.value)
|
||||
if entry and entry.pii_safe:
|
||||
_is_pii_safe = True
|
||||
except Exception:
|
||||
pass
|
||||
redact_pii = redact_pii and _is_pii_safe
|
||||
lines = [
|
||||
"## Current Session Context",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
# Source info
|
||||
platform_name = context.source.platform.value.title()
|
||||
if context.source.platform == Platform.LOCAL:
|
||||
@@ -277,7 +288,7 @@ def build_session_context_prompt(
|
||||
else:
|
||||
desc = src.description
|
||||
lines.append(f"**Source:** {platform_name} ({desc})")
|
||||
|
||||
|
||||
# Channel topic (if available - provides context about the channel's purpose)
|
||||
if context.source.chat_topic:
|
||||
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
|
||||
@@ -302,7 +313,7 @@ def build_session_context_prompt(
|
||||
if redact_pii:
|
||||
uid = _hash_sender_id(uid)
|
||||
lines.append(f"**User ID:** {uid}")
|
||||
|
||||
|
||||
# Platform-specific behavioral notes
|
||||
if context.source.platform == Platform.SLACK:
|
||||
lines.append("")
|
||||
@@ -368,9 +379,9 @@ def build_session_context_prompt(
|
||||
for p in context.connected_platforms:
|
||||
if p != Platform.LOCAL:
|
||||
platforms_list.append(f"{p.value}: Connected ✓")
|
||||
|
||||
|
||||
lines.append(f"**Connected Platforms:** {', '.join(platforms_list)}")
|
||||
|
||||
|
||||
# Home channels
|
||||
if context.home_channels:
|
||||
lines.append("")
|
||||
@@ -378,11 +389,11 @@ def build_session_context_prompt(
|
||||
for platform, home in context.home_channels.items():
|
||||
hc_id = _hash_chat_id(home.chat_id) if redact_pii else home.chat_id
|
||||
lines.append(f" - {platform.value}: {home.name} (ID: {hc_id})")
|
||||
|
||||
|
||||
# Delivery options for scheduled tasks
|
||||
lines.append("")
|
||||
lines.append("**Delivery options for scheduled tasks:**")
|
||||
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
# Origin delivery
|
||||
@@ -398,15 +409,15 @@ def build_session_context_prompt(
|
||||
lines.append(
|
||||
f"- `\"local\"` → Save to local files only ({display_hermes_home()}/cron/output/)"
|
||||
)
|
||||
|
||||
|
||||
# Platform home channels
|
||||
for platform, home in context.home_channels.items():
|
||||
lines.append(f"- `\"{platform.value}\"` → Home channel ({home.name})")
|
||||
|
||||
|
||||
# Note about explicit targeting
|
||||
lines.append("")
|
||||
lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID.*")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -91,11 +91,20 @@ class GatewayStreamConsumer:
|
||||
chat_id: str,
|
||||
config: Optional[StreamConsumerConfig] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
on_new_message: Optional[callable] = None,
|
||||
):
|
||||
self.adapter = adapter
|
||||
self.chat_id = chat_id
|
||||
self.cfg = config or StreamConsumerConfig()
|
||||
self.metadata = metadata
|
||||
# Fired whenever a fresh content bubble is created on the platform
|
||||
# (first-send of a new message, commentary, overflow chunk, or
|
||||
# fallback continuation). The gateway uses this to linearize the
|
||||
# tool-progress bubble: when content resumes after a tool batch,
|
||||
# the next tool.started should open a NEW progress bubble below
|
||||
# the content, not edit the old bubble above it.
|
||||
# Called with no arguments. Exceptions are swallowed.
|
||||
self._on_new_message = on_new_message
|
||||
self._queue: queue.Queue = queue.Queue()
|
||||
self._accumulated = ""
|
||||
self._message_id: Optional[str] = None
|
||||
@@ -146,6 +155,16 @@ class GatewayStreamConsumer:
|
||||
if text:
|
||||
self._queue.put((_COMMENTARY, text))
|
||||
|
||||
def _notify_new_message(self) -> None:
|
||||
"""Fire the on_new_message callback, swallowing any errors."""
|
||||
cb = self._on_new_message
|
||||
if cb is None:
|
||||
return
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
logger.debug("on_new_message callback error", exc_info=True)
|
||||
|
||||
def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None:
|
||||
if preserve_no_edit and self._message_id == "__no_edit__":
|
||||
return
|
||||
@@ -529,6 +548,9 @@ class GatewayStreamConsumer:
|
||||
self._message_id = str(result.message_id)
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
# Fresh content bubble — close off any stale tool bubble
|
||||
# above so the next tool starts a new bubble below.
|
||||
self._notify_new_message()
|
||||
return str(result.message_id)
|
||||
else:
|
||||
self._edit_supported = False
|
||||
@@ -661,6 +683,9 @@ class GatewayStreamConsumer:
|
||||
sent_any_chunk = True
|
||||
last_successful_chunk = chunk
|
||||
last_message_id = result.message_id or last_message_id
|
||||
# Each fallback chunk is a fresh platform message — notify
|
||||
# so any stale tool-progress bubble gets closed off.
|
||||
self._notify_new_message()
|
||||
|
||||
self._message_id = last_message_id
|
||||
self._already_sent = True
|
||||
@@ -744,6 +769,11 @@ class GatewayStreamConsumer:
|
||||
# tool..."), not the final response. Setting already_sent would cause
|
||||
# the final response to be incorrectly suppressed when there are
|
||||
# multiple tool calls. See: https://github.com/NousResearch/hermes-agent/issues/10454
|
||||
if result.success:
|
||||
# Commentary counts as fresh content — close off any
|
||||
# stale tool bubble above it so the next tool starts a
|
||||
# new bubble below.
|
||||
self._notify_new_message()
|
||||
return result.success
|
||||
except Exception as e:
|
||||
logger.error("Commentary send error: %s", e)
|
||||
@@ -973,6 +1003,11 @@ class GatewayStreamConsumer:
|
||||
# every delta/tool boundary when platforms accept a
|
||||
# message but do not return an editable message id.
|
||||
self._message_id = "__no_edit__"
|
||||
# Notify the gateway that a fresh content bubble was
|
||||
# created so any accumulated tool-progress bubble above
|
||||
# gets closed off — the next tool fires into a new
|
||||
# bubble below, preserving chronological order.
|
||||
self._notify_new_message()
|
||||
return True
|
||||
else:
|
||||
# Initial send failed — disable streaming for this session
|
||||
|
||||
@@ -11,5 +11,5 @@ Provides subcommands for:
|
||||
- hermes cron - Manage cron jobs
|
||||
"""
|
||||
|
||||
__version__ = "0.11.0"
|
||||
__release_date__ = "2026.4.23"
|
||||
__version__ = "0.12.0"
|
||||
__release_date__ = "2026.4.30"
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Top-level argparse construction for the hermes CLI.
|
||||
|
||||
Lives in its own module so other modules (e.g. ``relaunch.py``) can
|
||||
introspect the parser to discover which flags exist without running the
|
||||
``main`` fn.
|
||||
|
||||
Only the top-level parser and the ``chat`` subparser live here. Every other
|
||||
subparser (model, gateway, sessions, …) is built inline in ``main.py``
|
||||
because its dispatch is tightly coupled to module-level ``cmd_*`` functions.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
# `--profile` / `-p` is consumed by ``main._apply_profile_override`` before
|
||||
# argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``),
|
||||
# so it isn't on the parser. Listed here so all "carry over on relaunch"
|
||||
# metadata lives in one file.
|
||||
PRE_ARGPARSE_INHERITED_FLAGS: list[tuple[str, bool]] = [
|
||||
("--profile", True),
|
||||
("-p", True),
|
||||
]
|
||||
|
||||
|
||||
def _inherited_flag(parser, *args, **kwargs):
|
||||
"""Register a flag that ``hermes_cli.relaunch`` should carry over when
|
||||
the CLI re-execs itself (e.g. after ``sessions browse`` picks a session,
|
||||
or after the setup wizard launches chat).
|
||||
|
||||
Equivalent to ``parser.add_argument(...)`` plus tagging the resulting
|
||||
Action with ``inherit_on_relaunch = True`` so the relaunch table builder
|
||||
can find it via introspection.
|
||||
"""
|
||||
action = parser.add_argument(*args, **kwargs)
|
||||
action.inherit_on_relaunch = True
|
||||
return action
|
||||
|
||||
|
||||
_EPILOGUE = """
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
hermes gateway Run messaging gateway
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
"""
|
||||
|
||||
|
||||
def build_top_level_parser():
|
||||
"""Build the top-level parser, the subparsers action, and the ``chat`` subparser.
|
||||
|
||||
Returns ``(parser, subparsers, chat_parser)``. The caller wires
|
||||
``chat_parser.set_defaults(func=cmd_chat)`` and continues registering
|
||||
other subparsers via ``subparsers.add_parser(...)``.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes",
|
||||
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=_EPILOGUE,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--toolsets",
|
||||
default=None,
|
||||
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# =========================================================================
|
||||
# chat command
|
||||
# =========================================================================
|
||||
chat_parser = subparsers.add_parser(
|
||||
"chat",
|
||||
help="Interactive chat with the agent",
|
||||
description="Start an interactive chat session with Hermes Agent",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-q", "--query", help="Single query (non-interactive mode)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-t", "--toolsets", help="Comma-separated toolsets to enable"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-Q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
return parser, subparsers, chat_parser
|
||||
+341
-1
@@ -72,6 +72,14 @@ DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes
|
||||
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
|
||||
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
|
||||
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"
|
||||
MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"
|
||||
MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"
|
||||
MINIMAX_OAUTH_GLOBAL_BASE = "https://api.minimax.io"
|
||||
MINIMAX_OAUTH_CN_BASE = "https://api.minimaxi.com"
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic"
|
||||
MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic"
|
||||
MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60
|
||||
DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
|
||||
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
|
||||
@@ -126,7 +134,7 @@ class ProviderConfig:
|
||||
"""Describes a known inference provider."""
|
||||
id: str
|
||||
name: str
|
||||
auth_type: str # "oauth_device_code", "oauth_external", or "api_key"
|
||||
auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key"
|
||||
portal_base_url: str = ""
|
||||
inference_base_url: str = ""
|
||||
client_id: str = ""
|
||||
@@ -255,6 +263,17 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
api_key_env_vars=("MINIMAX_API_KEY",),
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
),
|
||||
"minimax-oauth": ProviderConfig(
|
||||
id="minimax-oauth",
|
||||
name="MiniMax (OAuth \u00b7 minimax.io)",
|
||||
auth_type="oauth_minimax",
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
scope=MINIMAX_OAUTH_SCOPE,
|
||||
extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE,
|
||||
"cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE},
|
||||
),
|
||||
"anthropic": ProviderConfig(
|
||||
id="anthropic",
|
||||
name="Anthropic",
|
||||
@@ -1153,6 +1172,7 @@ def resolve_provider(
|
||||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||||
"gmi-cloud": "gmi", "gmicloud": "gmi",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
|
||||
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
|
||||
"alibaba_coding_plan": "alibaba-coding-plan",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
@@ -4116,6 +4136,326 @@ def _codex_device_code_login() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ==================== MiniMax Portal OAuth ====================
|
||||
|
||||
def _minimax_pkce_pair() -> tuple:
|
||||
"""Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth."""
|
||||
import secrets
|
||||
verifier = secrets.token_urlsafe(64)[:96]
|
||||
challenge = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode()).digest()
|
||||
).decode().rstrip("=")
|
||||
state = secrets.token_urlsafe(16)
|
||||
return verifier, challenge, state
|
||||
|
||||
|
||||
def _minimax_request_user_code(
|
||||
client: httpx.Client, *, portal_base_url: str, client_id: str,
|
||||
code_challenge: str, state: str,
|
||||
) -> Dict[str, Any]:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/code",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"scope": MINIMAX_OAUTH_SCOPE,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"x-request-id": str(uuid.uuid4()),
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth authorization failed: {response.text or response.reason_phrase}",
|
||||
provider="minimax-oauth", code="authorization_failed",
|
||||
)
|
||||
payload = response.json()
|
||||
for field in ("user_code", "verification_uri", "expired_in"):
|
||||
if field not in payload:
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth response missing field: {field}",
|
||||
provider="minimax-oauth", code="authorization_incomplete",
|
||||
)
|
||||
if payload.get("state") != state:
|
||||
raise AuthError(
|
||||
"MiniMax OAuth state mismatch (possible CSRF).",
|
||||
provider="minimax-oauth", code="state_mismatch",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _minimax_poll_token(
|
||||
client: httpx.Client, *, portal_base_url: str, client_id: str,
|
||||
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
|
||||
) -> Dict[str, Any]:
|
||||
# OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs).
|
||||
# Defensive parsing: if it's small enough to be a duration, treat as seconds.
|
||||
import time as _time
|
||||
now_ms = int(_time.time() * 1000)
|
||||
if expired_in > now_ms // 2:
|
||||
# Looks like a unix-ms timestamp.
|
||||
deadline = expired_in / 1000.0
|
||||
else:
|
||||
# Treat as duration in seconds from now.
|
||||
deadline = _time.time() + max(1, expired_in)
|
||||
interval = max(2.0, (interval_ms or 2000) / 1000.0)
|
||||
|
||||
while _time.time() < deadline:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": MINIMAX_OAUTH_GRANT_TYPE,
|
||||
"client_id": client_id,
|
||||
"user_code": user_code,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
payload = response.json() if response.text else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
if response.status_code != 200:
|
||||
msg = (payload.get("base_resp", {}) or {}).get("status_msg") or response.text
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth error: {msg or 'unknown'}",
|
||||
provider="minimax-oauth", code="token_exchange_failed",
|
||||
)
|
||||
|
||||
status = payload.get("status")
|
||||
if status == "error":
|
||||
raise AuthError(
|
||||
"MiniMax OAuth reported an error. Please try again later.",
|
||||
provider="minimax-oauth", code="authorization_denied",
|
||||
)
|
||||
if status == "success":
|
||||
if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")):
|
||||
raise AuthError(
|
||||
"MiniMax OAuth success payload missing required token fields.",
|
||||
provider="minimax-oauth", code="token_incomplete",
|
||||
)
|
||||
return payload
|
||||
# "pending" or any other status -> keep polling
|
||||
_time.sleep(interval)
|
||||
|
||||
raise AuthError(
|
||||
"MiniMax OAuth timed out before authorization completed.",
|
||||
provider="minimax-oauth", code="timeout",
|
||||
)
|
||||
|
||||
|
||||
def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None:
|
||||
"""Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json)."""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
_save_provider_state(auth_store, "minimax-oauth", auth_state)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def _minimax_oauth_login(
|
||||
*, region: str = "global", open_browser: bool = True,
|
||||
timeout_seconds: float = 15.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run MiniMax OAuth flow, persist tokens, return auth state dict."""
|
||||
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
|
||||
if region == "cn":
|
||||
portal_base_url = pconfig.extra["cn_portal_base_url"]
|
||||
inference_base_url = pconfig.extra["cn_inference_base_url"]
|
||||
else:
|
||||
portal_base_url = pconfig.portal_base_url
|
||||
inference_base_url = pconfig.inference_base_url
|
||||
|
||||
verifier, challenge, state = _minimax_pkce_pair()
|
||||
|
||||
if _is_remote_session():
|
||||
open_browser = False
|
||||
|
||||
print(f"Starting Hermes login via MiniMax ({region}) OAuth...")
|
||||
print(f"Portal: {portal_base_url}")
|
||||
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
|
||||
headers={"Accept": "application/json"}) as client:
|
||||
code_data = _minimax_request_user_code(
|
||||
client, portal_base_url=portal_base_url,
|
||||
client_id=pconfig.client_id,
|
||||
code_challenge=challenge, state=state,
|
||||
)
|
||||
verification_url = str(code_data["verification_uri"])
|
||||
user_code = str(code_data["user_code"])
|
||||
|
||||
print()
|
||||
print("To continue:")
|
||||
print(f" 1. Open: {verification_url}")
|
||||
print(f" 2. If prompted, enter code: {user_code}")
|
||||
if open_browser:
|
||||
if webbrowser.open(verification_url):
|
||||
print(" (Opened browser for verification)")
|
||||
else:
|
||||
print(" Could not open browser automatically -- use the URL above.")
|
||||
|
||||
interval_raw = code_data.get("interval")
|
||||
interval_ms = int(interval_raw) if interval_raw is not None else None
|
||||
print("Waiting for approval...")
|
||||
|
||||
token_data = _minimax_poll_token(
|
||||
client, portal_base_url=portal_base_url,
|
||||
client_id=pconfig.client_id,
|
||||
user_code=user_code, code_verifier=verifier,
|
||||
expired_in=int(code_data["expired_in"]),
|
||||
interval_ms=interval_ms,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at = now.timestamp() + expires_in_s
|
||||
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
"region": region,
|
||||
"portal_base_url": portal_base_url,
|
||||
"inference_base_url": inference_base_url,
|
||||
"client_id": pconfig.client_id,
|
||||
"scope": MINIMAX_OAUTH_SCOPE,
|
||||
"token_type": token_data.get("token_type", "Bearer"),
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"resource_url": token_data.get("resource_url"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
}
|
||||
|
||||
_minimax_save_auth_state(auth_state)
|
||||
print("\u2713 MiniMax OAuth login successful.")
|
||||
if msg := token_data.get("notification_message"):
|
||||
print(f"Note from MiniMax: {msg}")
|
||||
return auth_state
|
||||
|
||||
|
||||
def _refresh_minimax_oauth_state(
|
||||
state: Dict[str, Any], *, timeout_seconds: float = 15.0,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Refresh MiniMax OAuth access token if close to expiry (or forced)."""
|
||||
if not state.get("refresh_token"):
|
||||
raise AuthError(
|
||||
"MiniMax OAuth state has no refresh_token; please re-login.",
|
||||
provider="minimax-oauth", code="no_refresh_token", relogin_required=True,
|
||||
)
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
|
||||
except Exception:
|
||||
expires_at = 0.0
|
||||
now = time.time()
|
||||
if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
|
||||
return state
|
||||
|
||||
portal_base_url = state["portal_base_url"]
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_seconds)) as client:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": state["client_id"],
|
||||
"refresh_token": state["refresh_token"],
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
body = response.text.lower()
|
||||
relogin = any(m in body for m in
|
||||
("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth refresh failed: {response.text or response.reason_phrase}",
|
||||
provider="minimax-oauth", code="refresh_failed",
|
||||
relogin_required=relogin,
|
||||
)
|
||||
payload = response.json()
|
||||
if payload.get("status") != "success":
|
||||
raise AuthError(
|
||||
"MiniMax OAuth refresh did not return success.",
|
||||
provider="minimax-oauth", code="refresh_failed",
|
||||
relogin_required=True,
|
||||
)
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
expires_in_s = int(payload["expired_in"])
|
||||
new_state = dict(state)
|
||||
new_state.update({
|
||||
"access_token": payload["access_token"],
|
||||
"refresh_token": payload.get("refresh_token", state["refresh_token"]),
|
||||
"obtained_at": now_dt.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
|
||||
tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
})
|
||||
_minimax_save_auth_state(new_state)
|
||||
return new_state
|
||||
|
||||
|
||||
def resolve_minimax_oauth_runtime_credentials(
|
||||
*, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return {provider, api_key, base_url, source} for minimax-oauth."""
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
raise AuthError(
|
||||
"Not logged into MiniMax OAuth. Run `hermes model` and select "
|
||||
"MiniMax (OAuth).",
|
||||
provider="minimax-oauth", code="not_logged_in", relogin_required=True,
|
||||
)
|
||||
state = _refresh_minimax_oauth_state(state)
|
||||
return {
|
||||
"provider": "minimax-oauth",
|
||||
"api_key": state["access_token"],
|
||||
"base_url": state["inference_base_url"].rstrip("/"),
|
||||
"source": "oauth",
|
||||
}
|
||||
|
||||
|
||||
def get_minimax_oauth_auth_status() -> Dict[str, Any]:
|
||||
"""Return auth status dict for MiniMax OAuth provider."""
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
return {"logged_in": False, "provider": "minimax-oauth"}
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
|
||||
token_valid = (expires_at - time.time()) > 0
|
||||
except Exception:
|
||||
token_valid = bool(state.get("access_token"))
|
||||
return {
|
||||
"logged_in": token_valid,
|
||||
"provider": "minimax-oauth",
|
||||
"region": state.get("region", "global"),
|
||||
"expires_at": state.get("expires_at"),
|
||||
}
|
||||
|
||||
|
||||
def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None:
|
||||
"""CLI entry for MiniMax OAuth login."""
|
||||
region = getattr(args, "region", None) or "global"
|
||||
open_browser = not getattr(args, "no_browser", False)
|
||||
timeout = getattr(args, "timeout", None) or 15.0
|
||||
try:
|
||||
_minimax_oauth_login(
|
||||
region=region, open_browser=open_browser, timeout_seconds=timeout,
|
||||
)
|
||||
except AuthError as exc:
|
||||
print(format_auth_error(exc))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _nous_device_code_login(
|
||||
*,
|
||||
portal_base_url: Optional[str] = None,
|
||||
|
||||
@@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
|
||||
# Providers that support OAuth login in addition to API keys.
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"}
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"}
|
||||
|
||||
|
||||
def _get_custom_provider_names() -> list:
|
||||
@@ -170,7 +170,7 @@ def auth_add_command(args) -> None:
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
requested_type = AUTH_TYPE_API_KEY
|
||||
else:
|
||||
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} else AUTH_TYPE_API_KEY
|
||||
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} else AUTH_TYPE_API_KEY
|
||||
|
||||
pool = load_pool(provider)
|
||||
|
||||
@@ -333,6 +333,27 @@ def auth_add_command(args) -> None:
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["api_key"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:minimax_oauth",
|
||||
access_token=creds["api_key"],
|
||||
base_url=creds.get("base_url"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.")
|
||||
|
||||
|
||||
|
||||
+85
-37
@@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -122,35 +123,36 @@ def get_available_skills() -> Dict[str, List[str]]:
|
||||
# Cache update check results for 6 hours to avoid repeated git fetches
|
||||
_UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
|
||||
|
||||
# Sentinel returned when we know an update exists but can't count commits
|
||||
# (e.g. nix-built hermes — no local git history to count against).
|
||||
UPDATE_AVAILABLE_NO_COUNT = -1
|
||||
|
||||
def check_for_updates() -> Optional[int]:
|
||||
"""Check how many commits behind origin/main the local repo is.
|
||||
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
||||
|
||||
Does a ``git fetch`` at most once every 6 hours (cached to
|
||||
``~/.hermes/.update_check``). Returns the number of commits behind,
|
||||
or ``None`` if the check fails or isn't applicable.
|
||||
|
||||
def _check_via_rev(local_rev: str) -> Optional[int]:
|
||||
"""Compare an embedded git revision to upstream main via ls-remote.
|
||||
|
||||
Returns 0 if up-to-date, ``UPDATE_AVAILABLE_NO_COUNT`` if behind,
|
||||
or ``None`` on failure.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
cache_file = hermes_home / ".update_check"
|
||||
|
||||
# Must be a git repo — fall back to project root for dev installs
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
|
||||
# Read cache
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS:
|
||||
return cached.get("behind")
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return None
|
||||
upstream_rev = result.stdout.split()[0]
|
||||
if not upstream_rev:
|
||||
return None
|
||||
return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT
|
||||
|
||||
# Fetch latest refs (fast — only downloads ref metadata, no files)
|
||||
|
||||
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
|
||||
"""Count commits behind origin/main in a local checkout."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", "--quiet"],
|
||||
@@ -160,7 +162,6 @@ def check_for_updates() -> Optional[int]:
|
||||
except Exception:
|
||||
pass # Offline or timeout — use stale refs, that's fine
|
||||
|
||||
# Count commits behind
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD..origin/main"],
|
||||
@@ -168,15 +169,52 @@ def check_for_updates() -> Optional[int]:
|
||||
cwd=str(repo_dir),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
behind = int(result.stdout.strip())
|
||||
else:
|
||||
behind = None
|
||||
return int(result.stdout.strip())
|
||||
except Exception:
|
||||
behind = None
|
||||
pass
|
||||
return None
|
||||
|
||||
# Write cache
|
||||
|
||||
def check_for_updates() -> Optional[int]:
|
||||
"""Check whether a Hermes update is available.
|
||||
|
||||
Two paths: if ``HERMES_REVISION`` is set (nix builds embed it), compare
|
||||
it to upstream main via ``git ls-remote``. Otherwise look for a local
|
||||
git checkout and count commits behind ``origin/main``.
|
||||
|
||||
Returns the number of commits behind, ``UPDATE_AVAILABLE_NO_COUNT`` (-1)
|
||||
if behind but the count is unknown, ``0`` if up-to-date, or ``None`` if
|
||||
the check failed or doesn't apply. Cached for 6 hours.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
now = time.time()
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind}))
|
||||
if cache_file.exists():
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
):
|
||||
return cached.get("behind")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if embedded_rev:
|
||||
behind = _check_via_rev(embedded_rev)
|
||||
else:
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
behind = _check_via_local_git(repo_dir)
|
||||
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -549,13 +587,23 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
# Update check — use prefetched result if available
|
||||
try:
|
||||
behind = get_update_result(timeout=0.5)
|
||||
if behind and behind > 0:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
)
|
||||
if behind is not None and behind != 0:
|
||||
from hermes_cli.config import get_managed_update_command, recommended_update_command
|
||||
if behind > 0:
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
)
|
||||
else:
|
||||
# UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update
|
||||
# exists but not by how much, and we don't know how the user
|
||||
# installed it (nix run, profile, system flake, home-manager).
|
||||
managed_cmd = get_managed_update_command()
|
||||
line = "[bold yellow]⚠ update available[/]"
|
||||
if managed_cmd:
|
||||
line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]"
|
||||
right_lines.append(line)
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Shared helpers for attaching Hermes to a local Chrome CDP port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
DEFAULT_BROWSER_CDP_PORT = 9222
|
||||
DEFAULT_BROWSER_CDP_URL = f"http://127.0.0.1:{DEFAULT_BROWSER_CDP_PORT}"
|
||||
|
||||
_DARWIN_APPS = (
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
)
|
||||
|
||||
_WINDOWS_INSTALL_PARTS = (
|
||||
("Google", "Chrome", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chromium.exe"),
|
||||
("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
("Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
)
|
||||
|
||||
_LINUX_BIN_NAMES = (
|
||||
"google-chrome", "google-chrome-stable", "chromium-browser",
|
||||
"chromium", "brave-browser", "microsoft-edge",
|
||||
)
|
||||
|
||||
_WINDOWS_BIN_NAMES = (
|
||||
"chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
|
||||
"chrome", "msedge", "brave", "chromium",
|
||||
)
|
||||
|
||||
|
||||
def get_chrome_debug_candidates(system: str) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(path: str | None) -> None:
|
||||
if not path:
|
||||
return
|
||||
normalized = os.path.normcase(os.path.normpath(path))
|
||||
if normalized in seen or not os.path.isfile(path):
|
||||
return
|
||||
candidates.append(path)
|
||||
seen.add(normalized)
|
||||
|
||||
def add_install_paths(bases: tuple[str | None, ...]) -> None:
|
||||
for base in filter(None, bases):
|
||||
for parts in _WINDOWS_INSTALL_PARTS:
|
||||
add(os.path.join(base, *parts))
|
||||
|
||||
if system == "Darwin":
|
||||
for app in _DARWIN_APPS:
|
||||
add(app)
|
||||
return candidates
|
||||
|
||||
if system == "Windows":
|
||||
for name in _WINDOWS_BIN_NAMES:
|
||||
add(shutil.which(name))
|
||||
add_install_paths((
|
||||
os.environ.get("ProgramFiles"),
|
||||
os.environ.get("ProgramFiles(x86)"),
|
||||
os.environ.get("LOCALAPPDATA"),
|
||||
))
|
||||
return candidates
|
||||
|
||||
for name in _LINUX_BIN_NAMES:
|
||||
add(shutil.which(name))
|
||||
add_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"))
|
||||
return candidates
|
||||
|
||||
|
||||
def chrome_debug_data_dir() -> str:
|
||||
return str(get_hermes_home() / "chrome-debug")
|
||||
|
||||
|
||||
def _chrome_debug_args(port: int) -> list[str]:
|
||||
return [
|
||||
f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={chrome_debug_data_dir()}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
]
|
||||
|
||||
|
||||
def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None:
|
||||
system = system or platform.system()
|
||||
candidates = get_chrome_debug_candidates(system)
|
||||
|
||||
if candidates:
|
||||
argv = [candidates[0], *_chrome_debug_args(port)]
|
||||
return subprocess.list2cmdline(argv) if system == "Windows" else shlex.join(argv)
|
||||
|
||||
if system == "Darwin":
|
||||
data_dir = chrome_debug_data_dir()
|
||||
return (
|
||||
f'open -a "Google Chrome" --args --remote-debugging-port={port} '
|
||||
f'--user-data-dir="{data_dir}" --no-first-run --no-default-browser-check'
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detach_kwargs(system: str) -> dict:
|
||||
if system != "Windows":
|
||||
return {"start_new_session": True}
|
||||
flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
||||
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
||||
)
|
||||
return {"creationflags": flags} if flags else {}
|
||||
|
||||
|
||||
def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool:
|
||||
system = system or platform.system()
|
||||
candidates = get_chrome_debug_candidates(system)
|
||||
if not candidates:
|
||||
return False
|
||||
|
||||
os.makedirs(chrome_debug_data_dir(), exist_ok=True)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[candidates[0], *_chrome_debug_args(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_detach_kwargs(system),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -148,10 +148,15 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("curator", "Background skill maintenance (status, run, pin, archive)",
|
||||
"Tools & Skills", args_hint="[subcommand]",
|
||||
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore")),
|
||||
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
|
||||
cli_only=True),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
||||
"Tools & Skills", aliases=("reload_skills",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
|
||||
+321
-34
@@ -73,6 +73,8 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
|
||||
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
|
||||
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
|
||||
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
|
||||
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||||
@@ -348,7 +350,7 @@ def ensure_hermes_home():
|
||||
else:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
_secure_dir(home)
|
||||
for subdir in ("cron", "sessions", "logs", "memories"):
|
||||
for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"):
|
||||
d = home / subdir
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_secure_dir(d)
|
||||
@@ -369,6 +371,10 @@ def _ensure_hermes_home_managed(home: Path):
|
||||
f"{d} does not exist. "
|
||||
"Run 'sudo nixos-rebuild switch' first."
|
||||
)
|
||||
# Curator reports dir is a sub-path of logs/; create it if missing.
|
||||
# In managed mode the activation script may not know about this subdir,
|
||||
# so we mkdir it ourselves (it's inside an already-secured logs/ dir).
|
||||
(home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
|
||||
# Inside umask(0o007) scope — SOUL.md will be created as 0660
|
||||
_ensure_default_soul_md(home)
|
||||
|
||||
@@ -499,7 +505,8 @@ DEFAULT_CONFIG = {
|
||||
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
|
||||
"vercel_runtime": "node24",
|
||||
# Container resource limits (docker, singularity, modal, daytona, vercel_sandbox — ignored for local/ssh)
|
||||
"container_cpu": 1,
|
||||
"container_memory": 5120, # MB (default 5GB)
|
||||
"container_disk": 51200, # MB (default 50GB)
|
||||
@@ -515,6 +522,16 @@ DEFAULT_CONFIG = {
|
||||
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
|
||||
# Default off because passing host directories into a sandbox weakens isolation.
|
||||
"docker_mount_cwd_to_workspace": False,
|
||||
# Explicit opt-in: run the Docker container as the host user's uid:gid
|
||||
# (via `--user`). When enabled, files written into bind-mounted dirs
|
||||
# (docker_volumes, the persistent workspace, or the auto-mounted cwd)
|
||||
# are owned by your host user instead of root, which avoids needing
|
||||
# `sudo chown` after container runs. Default off to preserve behavior
|
||||
# for images whose entrypoints expect to start as root (e.g. the
|
||||
# bundled Hermes image, which drops to the `hermes` user via gosu).
|
||||
# When on, SETUID/SETGID caps are omitted from the container since
|
||||
# no privilege drop is needed.
|
||||
"docker_run_as_host_user": False,
|
||||
# Persistent shell — keep a long-lived bash shell across execute() calls
|
||||
# so cwd/env vars/shell variables survive between commands.
|
||||
# Enabled by default for non-local backends (SSH); local is always opt-in
|
||||
@@ -696,6 +713,19 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Curator — skill-usage review fork. Timeout is generous because the
|
||||
# review pass can take several minutes on reasoning models (umbrella
|
||||
# building over hundreds of candidate skills). "auto" = use main chat
|
||||
# model; override via `hermes model` → auxiliary → Curator to route
|
||||
# to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
|
||||
"curator": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 600,
|
||||
"extra_body": {},
|
||||
},
|
||||
},
|
||||
|
||||
"display": {
|
||||
@@ -753,7 +783,7 @@ DEFAULT_CONFIG = {
|
||||
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
|
||||
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
"tts": {
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "neutts" (local)
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
|
||||
"edge": {
|
||||
"voice": "en-US-AriaNeural",
|
||||
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
|
||||
@@ -783,6 +813,19 @@ DEFAULT_CONFIG = {
|
||||
"model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo
|
||||
"device": "cpu", # cpu, cuda, or mps
|
||||
},
|
||||
"piper": {
|
||||
# Voice name (e.g. "en_US-lessac-medium") downloaded on first
|
||||
# use, OR an absolute path to a pre-downloaded .onnx file.
|
||||
# Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md
|
||||
"voice": "en_US-lessac-medium",
|
||||
# "voices_dir": "", # Override voice cache dir; default = ~/.hermes/cache/piper-voices/
|
||||
# "use_cuda": False, # Requires onnxruntime-gpu
|
||||
# "length_scale": 1.0, # 2.0 = twice as slow
|
||||
# "noise_scale": 0.667,
|
||||
# "noise_w_scale": 0.8,
|
||||
# "volume": 1.0,
|
||||
# "normalize_audio": True,
|
||||
},
|
||||
},
|
||||
|
||||
"stt": {
|
||||
@@ -915,6 +958,29 @@ DEFAULT_CONFIG = {
|
||||
"guard_agent_created": False,
|
||||
},
|
||||
|
||||
# Curator — background skill maintenance.
|
||||
#
|
||||
# Periodically reviews AGENT-CREATED skills (never bundled or
|
||||
# hub-installed) and keeps the collection tidy: marks long-unused skills
|
||||
# as stale, archives genuinely obsolete ones (archive only, never
|
||||
# deletes), and spawns a forked aux-model agent to consolidate overlaps
|
||||
# and patch drift. Runs inactivity-triggered from session start — no
|
||||
# cron daemon.
|
||||
#
|
||||
# See `hermes curator status` for the last run summary.
|
||||
"curator": {
|
||||
"enabled": True,
|
||||
# How long to wait between curator runs (hours). Default: 7 days.
|
||||
"interval_hours": 24 * 7,
|
||||
# Only run when the agent has been idle at least this long (hours).
|
||||
"min_idle_hours": 2,
|
||||
# Mark a skill as "stale" after this many days without use.
|
||||
"stale_after_days": 30,
|
||||
# Archive a skill (move to skills/.archive/) after this many days
|
||||
# without use. Archived skills are recoverable — no auto-deletion.
|
||||
"archive_after_days": 90,
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
# This section is only needed for hermes-specific overrides; everything else
|
||||
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
|
||||
@@ -978,6 +1044,14 @@ DEFAULT_CONFIG = {
|
||||
"mode": "manual",
|
||||
"timeout": 60,
|
||||
"cron_mode": "deny",
|
||||
# When true, /reload-mcp asks the user to confirm before rebuilding
|
||||
# the MCP tool set for the active session. Reloading invalidates
|
||||
# the provider prompt cache (tool schemas are baked into the system
|
||||
# prompt), so the next message re-sends full input tokens — this can
|
||||
# be expensive on long-context or high-reasoning models. Users click
|
||||
# "Always Approve" to silence the prompt permanently; that flips
|
||||
# this key to false.
|
||||
"mcp_reload_confirm": True,
|
||||
},
|
||||
|
||||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||||
@@ -1131,7 +1205,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 22,
|
||||
"_config_version": 23,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -2036,6 +2110,43 @@ OPTIONAL_ENV_VARS = {
|
||||
"prompt": "QQ Sandbox Mode",
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_SERVER": {
|
||||
"description": "IRC server hostname (e.g. irc.libera.chat)",
|
||||
"prompt": "IRC server",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_CHANNEL": {
|
||||
"description": "IRC channel to join (e.g. #hermes)",
|
||||
"prompt": "IRC channel",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_NICKNAME": {
|
||||
"description": "Bot nickname on IRC (default: hermes-bot)",
|
||||
"prompt": "IRC nickname",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_SERVER_PASSWORD": {
|
||||
"description": "IRC server password (if required)",
|
||||
"prompt": "IRC server password",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"IRC_NICKSERV_PASSWORD": {
|
||||
"description": "NickServ password for nick identification",
|
||||
"prompt": "NickServ password",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"GATEWAY_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
|
||||
"prompt": "Allow all users (true/false)",
|
||||
@@ -2198,19 +2309,55 @@ def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
||||
return missing
|
||||
|
||||
|
||||
def _set_nested(config: dict, dotted_key: str, value):
|
||||
def _set_nested(config, dotted_key: str, value):
|
||||
"""Set a value at an arbitrarily nested dotted key path.
|
||||
|
||||
Creates intermediate dicts as needed, e.g. ``_set_nested(c, "a.b.c", 1)``
|
||||
ensures ``c["a"]["b"]["c"] == 1``.
|
||||
Supports both dict and list navigation:
|
||||
_set_nested(c, "a.b.c", 1) → c["a"]["b"]["c"] = 1
|
||||
_set_nested(c, "a.0.b", 1) → c["a"][0]["b"] = 1
|
||||
_set_nested(c, "providers.1", "x") → c["providers"][1] = "x"
|
||||
|
||||
Intermediate dicts are created on demand. List indices are parsed
|
||||
from numeric path segments; the referenced index must already exist
|
||||
(we do not grow lists — the user is navigating into structure they
|
||||
wrote themselves). If a segment targets a non-container leaf
|
||||
(scalar), the leaf is replaced with a fresh dict so the write can
|
||||
proceed — this preserves the pre-existing behavior for bare scalar
|
||||
overrides (e.g. setting ``a.b.c`` where ``a.b`` was previously a
|
||||
string).
|
||||
|
||||
Guards against #17876: before this fix the code unconditionally
|
||||
replaced any non-dict value (including lists) with ``{}``, silently
|
||||
destroying list-typed config like ``custom_providers`` whenever a
|
||||
caller used an indexed path.
|
||||
"""
|
||||
parts = dotted_key.split(".")
|
||||
current = config
|
||||
for part in parts[:-1]:
|
||||
if part not in current or not isinstance(current.get(part), dict):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
if isinstance(current, list):
|
||||
try:
|
||||
idx = int(part)
|
||||
except (TypeError, ValueError):
|
||||
raise TypeError(
|
||||
f"Cannot navigate into list at key {dotted_key!r}: "
|
||||
f"segment {part!r} is not a numeric index"
|
||||
)
|
||||
current = current[idx]
|
||||
elif isinstance(current, dict):
|
||||
existing = current.get(part)
|
||||
# Preserve dicts and lists; replace missing/scalar with a fresh dict.
|
||||
if part not in current or not isinstance(existing, (dict, list)):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}"
|
||||
)
|
||||
last = parts[-1]
|
||||
if isinstance(current, list):
|
||||
current[int(last)] = value
|
||||
else:
|
||||
current[last] = value
|
||||
|
||||
|
||||
def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||||
@@ -3131,6 +3278,90 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
"Use `hermes plugins enable <name>` to activate."
|
||||
)
|
||||
|
||||
# ── Version 22 → 23: seed curator defaults + create logs/curator/ ──
|
||||
# The curator (background skill maintenance) was added in PR #16049, but
|
||||
# existing configs from before that PR (or before the April 2026
|
||||
# unification under `auxiliary.curator`) never wrote the curator section
|
||||
# to disk. The runtime deep-merge in `load_config()` fills defaults at
|
||||
# read time, so the curator *functions*; but users can't see/edit the
|
||||
# settings in their `config.yaml`, and `hermes curator status` has no
|
||||
# stable logs dir to point at until the first run mkdir's it.
|
||||
#
|
||||
# This migration:
|
||||
# 1. Writes the `curator` top-level section to config.yaml (enabled,
|
||||
# interval_hours, min_idle_hours, stale_after_days, archive_after_days)
|
||||
# — only keys the user hasn't already overridden.
|
||||
# 2. Writes the `auxiliary.curator` aux-task slot (provider, model,
|
||||
# base_url, api_key, timeout, extra_body) — canonical slot for
|
||||
# routing the curator fork to a cheaper aux model.
|
||||
# 3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders
|
||||
# on top of ensure_hermes_home() — old profiles that predate this
|
||||
# migration still benefit).
|
||||
if current_ver < 23:
|
||||
try:
|
||||
curator_dir = get_hermes_home() / "logs" / "curator"
|
||||
curator_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
results["warnings"].append(f"Could not create {curator_dir}: {e}")
|
||||
|
||||
config = read_raw_config()
|
||||
touched = False
|
||||
|
||||
# (1) Top-level curator section — only add missing keys
|
||||
_curator_defaults = DEFAULT_CONFIG.get("curator", {})
|
||||
raw_curator = config.get("curator")
|
||||
if not isinstance(raw_curator, dict):
|
||||
raw_curator = {}
|
||||
added_curator: List[str] = []
|
||||
for k, v in _curator_defaults.items():
|
||||
if k not in raw_curator:
|
||||
raw_curator[k] = copy.deepcopy(v)
|
||||
added_curator.append(k)
|
||||
if added_curator:
|
||||
config["curator"] = raw_curator
|
||||
touched = True
|
||||
|
||||
# (2) auxiliary.curator task slot
|
||||
_aux_curator_defaults = (
|
||||
DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {})
|
||||
)
|
||||
raw_aux = config.get("auxiliary")
|
||||
if not isinstance(raw_aux, dict):
|
||||
raw_aux = {}
|
||||
raw_aux_curator = raw_aux.get("curator")
|
||||
if not isinstance(raw_aux_curator, dict):
|
||||
raw_aux_curator = {}
|
||||
added_aux: List[str] = []
|
||||
for k, v in _aux_curator_defaults.items():
|
||||
if k not in raw_aux_curator:
|
||||
raw_aux_curator[k] = copy.deepcopy(v)
|
||||
added_aux.append(k)
|
||||
if added_aux:
|
||||
raw_aux["curator"] = raw_aux_curator
|
||||
config["auxiliary"] = raw_aux
|
||||
touched = True
|
||||
|
||||
if touched:
|
||||
save_config(config)
|
||||
if added_curator:
|
||||
results["config_added"].append(
|
||||
f"curator ({len(added_curator)} default key(s))"
|
||||
)
|
||||
if not quiet:
|
||||
print(
|
||||
" ✓ Seeded curator defaults in config.yaml: "
|
||||
f"{', '.join(added_curator)}"
|
||||
)
|
||||
if added_aux:
|
||||
results["config_added"].append(
|
||||
f"auxiliary.curator ({len(added_aux)} default key(s))"
|
||||
)
|
||||
if not quiet:
|
||||
print(
|
||||
" ✓ Seeded auxiliary.curator defaults in config.yaml: "
|
||||
f"{', '.join(added_aux)}"
|
||||
)
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
@@ -3403,17 +3634,17 @@ def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
|
||||
|
||||
|
||||
def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Move stale root-level provider/base_url into model section.
|
||||
"""Move stale root-level provider/base_url/context_length into model section.
|
||||
|
||||
Some users (or older code) placed ``provider:`` and ``base_url:`` at the
|
||||
config root instead of inside ``model:``. These root-level keys are only
|
||||
used as a fallback when the corresponding ``model.*`` key is empty — they
|
||||
never override an existing ``model.provider`` or ``model.base_url``.
|
||||
Some users (or older code) placed ``provider:``, ``base_url:``, or
|
||||
``context_length:`` at the config root instead of inside ``model:``.
|
||||
These root-level keys are only used as a fallback when the corresponding
|
||||
``model.*`` key is empty — they never override an existing value.
|
||||
After migration the root-level keys are removed so they can't cause
|
||||
confusion on subsequent loads.
|
||||
"""
|
||||
# Only act if there are root-level keys to migrate
|
||||
has_root = any(config.get(k) for k in ("provider", "base_url"))
|
||||
has_root = any(config.get(k) for k in ("provider", "base_url", "context_length"))
|
||||
if not has_root:
|
||||
return config
|
||||
|
||||
@@ -3423,7 +3654,7 @@ def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model = {"default": model} if model else {}
|
||||
config["model"] = model
|
||||
|
||||
for key in ("provider", "base_url"):
|
||||
for key in ("provider", "base_url", "context_length"):
|
||||
root_val = config.get(key)
|
||||
if root_val and not model.get(key):
|
||||
model[key] = root_val
|
||||
@@ -3448,6 +3679,52 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return config
|
||||
|
||||
|
||||
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
|
||||
"""Traverse nested dict keys safely, returning ``default`` on any miss.
|
||||
|
||||
Canonical helper for the ``cfg.get("X", {}).get("Y", default)`` pattern
|
||||
that appears 50+ times across the codebase. Handles three common gotchas
|
||||
in one place:
|
||||
|
||||
1. Missing intermediate keys (returns ``default``, no KeyError).
|
||||
2. An intermediate value that's not a dict (e.g. a user wrote a string
|
||||
where a section was expected). Returns ``default`` instead of
|
||||
AttributeError on ``.get()``.
|
||||
3. ``cfg is None`` (callers sometimes pass ``load_config() or None``).
|
||||
|
||||
Named ``cfg_get`` rather than ``cfg_path`` to avoid shadowing the
|
||||
ubiquitous ``cfg_path = _hermes_home / "config.yaml"`` local variable
|
||||
that appears in gateway/run.py, cron/scheduler.py, main.py, etc.
|
||||
|
||||
Explicit ``None`` values are returned as-is (matches ``dict.get(key,
|
||||
default)`` semantics — ``default`` is only returned when the key is
|
||||
*absent*, not when it's present but set to ``None``).
|
||||
|
||||
Examples:
|
||||
>>> cfg_get({"agent": {"reasoning_effort": "high"}}, "agent", "reasoning_effort")
|
||||
'high'
|
||||
>>> cfg_get({}, "agent", "reasoning_effort", default="medium")
|
||||
'medium'
|
||||
>>> cfg_get({"agent": "oops_a_string"}, "agent", "reasoning_effort", default="low")
|
||||
'low'
|
||||
>>> cfg_get(None, "anything", default=42)
|
||||
42
|
||||
>>> cfg_get({"a": {"b": None}}, "a", "b", default="def") # explicit None preserved
|
||||
>>> cfg_get({"a": {"b": False}}, "a", "b", default=True) # falsy values preserved
|
||||
False
|
||||
"""
|
||||
if not isinstance(cfg, dict):
|
||||
return default
|
||||
node: Any = cfg
|
||||
for key in keys:
|
||||
if not isinstance(node, dict):
|
||||
return default
|
||||
if key not in node:
|
||||
return default
|
||||
node = node[key]
|
||||
return node
|
||||
|
||||
|
||||
|
||||
def read_raw_config() -> Dict[str, Any]:
|
||||
"""Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
|
||||
@@ -3710,18 +3987,27 @@ def _sanitize_env_lines(lines: list) -> list:
|
||||
|
||||
# Detect concatenated KEY=VALUE pairs on one line.
|
||||
# Search for known KEY= patterns at any position in the line.
|
||||
split_positions = []
|
||||
# We collect full needle ranges so we can drop matches that are
|
||||
# fully contained within a longer overlapping needle. Without this,
|
||||
# suffix collisions corrupt the file: e.g. LM_API_KEY= inside
|
||||
# GLM_API_KEY= would otherwise split the line into "G\nLM_API_KEY=...".
|
||||
match_ranges: list[tuple[int, int]] = []
|
||||
for key_name in known_keys:
|
||||
needle = key_name + "="
|
||||
idx = stripped.find(needle)
|
||||
while idx >= 0:
|
||||
split_positions.append(idx)
|
||||
match_ranges.append((idx, idx + len(needle)))
|
||||
idx = stripped.find(needle, idx + len(needle))
|
||||
|
||||
split_positions = sorted({
|
||||
s for s, e in match_ranges
|
||||
if not any(
|
||||
s2 <= s and e2 >= e and (s2, e2) != (s, e)
|
||||
for s2, e2 in match_ranges
|
||||
)
|
||||
})
|
||||
|
||||
if len(split_positions) > 1:
|
||||
split_positions.sort()
|
||||
# Deduplicate (shouldn't happen, but be safe)
|
||||
split_positions = sorted(set(split_positions))
|
||||
for i, pos in enumerate(split_positions):
|
||||
end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
|
||||
part = stripped[pos:end].strip()
|
||||
@@ -4099,6 +4385,9 @@ def show_config():
|
||||
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||||
daytona_key = get_env_value('DAYTONA_API_KEY')
|
||||
print(f" API key: {'configured' if daytona_key else '(not set)'}")
|
||||
elif terminal.get('backend') == 'vercel_sandbox':
|
||||
print(f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}")
|
||||
print(f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}")
|
||||
elif terminal.get('backend') == 'ssh':
|
||||
ssh_host = get_env_value('TERMINAL_SSH_HOST')
|
||||
ssh_user = get_env_value('TERMINAL_SSH_USER')
|
||||
@@ -4256,15 +4545,11 @@ def set_config_value(key: str, value: str):
|
||||
except Exception:
|
||||
user_config = {}
|
||||
|
||||
# Handle nested keys (e.g., "tts.provider")
|
||||
parts = key.split('.')
|
||||
current = user_config
|
||||
|
||||
for part in parts[:-1]:
|
||||
if part not in current or not isinstance(current.get(part), dict):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Handle nested keys (e.g., "tts.provider") including numeric list
|
||||
# indices (e.g., "custom_providers.0.api_key"). Delegates to
|
||||
# _set_nested which preserves list-typed nodes; before #17876 the
|
||||
# inline navigation here silently overwrote lists with dicts.
|
||||
|
||||
# Convert value to appropriate type
|
||||
if value.lower() in ('true', 'yes', 'on'):
|
||||
value = True
|
||||
@@ -4274,8 +4559,8 @@ def set_config_value(key: str, value: str):
|
||||
value = int(value)
|
||||
elif value.replace('.', '', 1).isdigit():
|
||||
value = float(value)
|
||||
|
||||
current[parts[-1]] = value
|
||||
|
||||
_set_nested(user_config, key, value)
|
||||
|
||||
# Write only user config back (not the full merged defaults)
|
||||
ensure_hermes_home()
|
||||
@@ -4291,7 +4576,9 @@ def set_config_value(key: str, value: str):
|
||||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
|
||||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"terminal.cwd": "TERMINAL_CWD",
|
||||
"terminal.timeout": "TERMINAL_TIMEOUT",
|
||||
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""CLI subcommand: `hermes curator <subcommand>`.
|
||||
|
||||
Thin shell around agent/curator.py and tools/skill_usage.py. Renders a status
|
||||
table, triggers a run, pauses/resumes, and pins/unpins skills.
|
||||
|
||||
This module intentionally has no side effects at import time — main.py wires
|
||||
the argparse subparsers on demand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _fmt_ts(ts: Optional[str]) -> str:
|
||||
if not ts:
|
||||
return "never"
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return str(ts)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
delta = datetime.now(timezone.utc) - dt
|
||||
secs = int(delta.total_seconds())
|
||||
if secs < 60:
|
||||
return f"{secs}s ago"
|
||||
if secs < 3600:
|
||||
return f"{secs // 60}m ago"
|
||||
if secs < 86400:
|
||||
return f"{secs // 3600}h ago"
|
||||
return f"{secs // 86400}d ago"
|
||||
|
||||
|
||||
def _cmd_status(args) -> int:
|
||||
from agent import curator
|
||||
from tools import skill_usage
|
||||
|
||||
state = curator.load_state()
|
||||
enabled = curator.is_enabled()
|
||||
paused = state.get("paused", False)
|
||||
last_run = state.get("last_run_at")
|
||||
summary = state.get("last_run_summary") or "(none)"
|
||||
runs = state.get("run_count", 0)
|
||||
|
||||
status_line = (
|
||||
"ENABLED" if enabled and not paused else
|
||||
"PAUSED" if paused else
|
||||
"DISABLED"
|
||||
)
|
||||
print(f"curator: {status_line}")
|
||||
print(f" runs: {runs}")
|
||||
print(f" last run: {_fmt_ts(last_run)}")
|
||||
print(f" last summary: {summary}")
|
||||
_report = state.get("last_report_path")
|
||||
if _report:
|
||||
print(f" last report: {_report}")
|
||||
_ih = curator.get_interval_hours()
|
||||
_interval_label = (
|
||||
f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24
|
||||
else f"{_ih}h"
|
||||
)
|
||||
print(f" interval: every {_interval_label}")
|
||||
print(f" stale after: {curator.get_stale_after_days()}d unused")
|
||||
print(f" archive after: {curator.get_archive_after_days()}d unused")
|
||||
|
||||
rows = skill_usage.agent_created_report()
|
||||
if not rows:
|
||||
print("\nno agent-created skills")
|
||||
return 0
|
||||
|
||||
by_state = {"active": [], "stale": [], "archived": []}
|
||||
pinned = []
|
||||
for r in rows:
|
||||
state_name = r.get("state", "active")
|
||||
by_state.setdefault(state_name, []).append(r)
|
||||
if r.get("pinned"):
|
||||
pinned.append(r["name"])
|
||||
|
||||
print(f"\nagent-created skills: {len(rows)} total")
|
||||
for state_name in ("active", "stale", "archived"):
|
||||
bucket = by_state.get(state_name, [])
|
||||
print(f" {state_name:10s} {len(bucket)}")
|
||||
|
||||
if pinned:
|
||||
print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
|
||||
|
||||
# Show top 5 least-recently-active skills. Views and edits are activity too:
|
||||
# curator should not report a skill as "never used" right after skill_view()
|
||||
# or skill_manage() touched it.
|
||||
active = sorted(
|
||||
by_state.get("active", []),
|
||||
key=lambda r: r.get("last_activity_at") or r.get("created_at") or "",
|
||||
)[:5]
|
||||
if active:
|
||||
print("\nleast recently active (top 5):")
|
||||
for r in active:
|
||||
last = _fmt_ts(r.get("last_activity_at"))
|
||||
print(
|
||||
f" {r['name']:40s} "
|
||||
f"activity={r.get('activity_count', 0):3d} "
|
||||
f"use={r.get('use_count', 0):3d} "
|
||||
f"view={r.get('view_count', 0):3d} "
|
||||
f"patches={r.get('patch_count', 0):3d} "
|
||||
f"last_activity={last}"
|
||||
)
|
||||
|
||||
# Show top 5 most-active and least-active skills by activity_count
|
||||
# (use + view + patch). This is a different signal from
|
||||
# least-recently-active: activity_count reflects frequency,
|
||||
# last_activity_at reflects recency. A skill touched 30 times a year
|
||||
# ago is high-frequency but stale; a skill touched once yesterday is
|
||||
# recent but low-frequency. Both can matter.
|
||||
active_all = by_state.get("active", [])
|
||||
if active_all:
|
||||
most_active = sorted(
|
||||
active_all,
|
||||
key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
if most_active and (most_active[0].get("activity_count") or 0) > 0:
|
||||
print("\nmost active (top 5):")
|
||||
for r in most_active:
|
||||
last = _fmt_ts(r.get("last_activity_at"))
|
||||
print(
|
||||
f" {r['name']:40s} "
|
||||
f"activity={r.get('activity_count', 0):3d} "
|
||||
f"use={r.get('use_count', 0):3d} "
|
||||
f"view={r.get('view_count', 0):3d} "
|
||||
f"patches={r.get('patch_count', 0):3d} "
|
||||
f"last_activity={last}"
|
||||
)
|
||||
|
||||
least_active = sorted(
|
||||
active_all,
|
||||
key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""),
|
||||
)[:5]
|
||||
if least_active:
|
||||
print("\nleast active (top 5):")
|
||||
for r in least_active:
|
||||
last = _fmt_ts(r.get("last_activity_at"))
|
||||
print(
|
||||
f" {r['name']:40s} "
|
||||
f"activity={r.get('activity_count', 0):3d} "
|
||||
f"use={r.get('use_count', 0):3d} "
|
||||
f"view={r.get('view_count', 0):3d} "
|
||||
f"patches={r.get('patch_count', 0):3d} "
|
||||
f"last_activity={last}"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_run(args) -> int:
|
||||
from agent import curator
|
||||
if not curator.is_enabled():
|
||||
print("curator: disabled via config; enable with `curator.enabled: true`")
|
||||
return 1
|
||||
|
||||
print("curator: running review pass...")
|
||||
|
||||
def _on_summary(msg: str) -> None:
|
||||
print(msg)
|
||||
|
||||
result = curator.run_curator_review(
|
||||
on_summary=_on_summary,
|
||||
synchronous=bool(args.synchronous),
|
||||
)
|
||||
auto = result.get("auto_transitions", {})
|
||||
if auto:
|
||||
print(
|
||||
f"auto: checked={auto.get('checked', 0)} "
|
||||
f"stale={auto.get('marked_stale', 0)} "
|
||||
f"archived={auto.get('archived', 0)} "
|
||||
f"reactivated={auto.get('reactivated', 0)}"
|
||||
)
|
||||
if not args.synchronous:
|
||||
print("llm pass running in background — check `hermes curator status` later")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_pause(args) -> int:
|
||||
from agent import curator
|
||||
curator.set_paused(True)
|
||||
print("curator: paused")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_resume(args) -> int:
|
||||
from agent import curator
|
||||
curator.set_paused(False)
|
||||
print("curator: resumed")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_pin(args) -> int:
|
||||
from tools import skill_usage
|
||||
if not skill_usage.is_agent_created(args.skill):
|
||||
print(
|
||||
f"curator: '{args.skill}' is bundled or hub-installed — cannot pin "
|
||||
"(only agent-created skills participate in curation)"
|
||||
)
|
||||
return 1
|
||||
skill_usage.set_pinned(args.skill, True)
|
||||
print(f"curator: pinned '{args.skill}' (will bypass auto-transitions)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_unpin(args) -> int:
|
||||
from tools import skill_usage
|
||||
if not skill_usage.is_agent_created(args.skill):
|
||||
print(
|
||||
f"curator: '{args.skill}' is bundled or hub-installed — "
|
||||
"there's nothing to unpin (curator only tracks agent-created skills)"
|
||||
)
|
||||
return 1
|
||||
skill_usage.set_pinned(args.skill, False)
|
||||
print(f"curator: unpinned '{args.skill}'")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_restore(args) -> int:
|
||||
from tools import skill_usage
|
||||
ok, msg = skill_usage.restore_skill(args.skill)
|
||||
print(f"curator: {msg}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring (called from hermes_cli.main)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
"""Attach `curator` subcommands to *parent*.
|
||||
|
||||
main.py calls this with the ArgumentParser returned by
|
||||
``subparsers.add_parser("curator", ...)``.
|
||||
"""
|
||||
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
|
||||
subs = parent.add_subparsers(dest="curator_command")
|
||||
|
||||
p_status = subs.add_parser("status", help="Show curator status and skill stats")
|
||||
p_status.set_defaults(func=_cmd_status)
|
||||
|
||||
p_run = subs.add_parser("run", help="Trigger a curator review now")
|
||||
p_run.add_argument(
|
||||
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
||||
help="Wait for the LLM review pass to finish (default: background thread)",
|
||||
)
|
||||
p_run.set_defaults(func=_cmd_run)
|
||||
|
||||
p_pause = subs.add_parser("pause", help="Pause the curator until resumed")
|
||||
p_pause.set_defaults(func=_cmd_pause)
|
||||
|
||||
p_resume = subs.add_parser("resume", help="Resume a paused curator")
|
||||
p_resume.set_defaults(func=_cmd_resume)
|
||||
|
||||
p_pin = subs.add_parser("pin", help="Pin a skill so the curator never auto-transitions it")
|
||||
p_pin.add_argument("skill", help="Skill name")
|
||||
p_pin.set_defaults(func=_cmd_pin)
|
||||
|
||||
p_unpin = subs.add_parser("unpin", help="Unpin a skill")
|
||||
p_unpin.add_argument("skill", help="Skill name")
|
||||
p_unpin.set_defaults(func=_cmd_unpin)
|
||||
|
||||
p_restore = subs.add_parser("restore", help="Restore an archived skill")
|
||||
p_restore.add_argument("skill", help="Skill name")
|
||||
p_restore.set_defaults(func=_cmd_restore)
|
||||
|
||||
|
||||
def cli_main(argv=None) -> int:
|
||||
"""Standalone entry (also usable by hermes_cli.main fallthrough)."""
|
||||
parser = argparse.ArgumentParser(prog="hermes curator")
|
||||
register_cli(parser)
|
||||
args = parser.parse_args(argv)
|
||||
fn = getattr(args, "func", None)
|
||||
if fn is None:
|
||||
parser.print_help()
|
||||
return 0
|
||||
return int(fn(args) or 0)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(cli_main())
|
||||
+96
-9
@@ -8,6 +8,7 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
@@ -30,6 +31,7 @@ load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from utils import base_url_host_matches
|
||||
|
||||
@@ -76,6 +78,14 @@ def _system_package_install_cmd(pkg: str) -> str:
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def _safe_which(cmd: str) -> str | None:
|
||||
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
|
||||
try:
|
||||
return shutil.which(cmd)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps: list[str] = []
|
||||
step = 1
|
||||
@@ -537,6 +547,7 @@ def run_doctor(args):
|
||||
get_nous_auth_status,
|
||||
get_codex_auth_status,
|
||||
get_gemini_oauth_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
|
||||
nous_status = get_nous_auth_status()
|
||||
@@ -566,10 +577,17 @@ def run_doctor(args):
|
||||
check_ok("Google Gemini OAuth", f"(logged in{suffix})")
|
||||
else:
|
||||
check_warn("Google Gemini OAuth", "(not logged in)")
|
||||
|
||||
minimax_status = get_minimax_oauth_auth_status()
|
||||
if minimax_status.get("logged_in"):
|
||||
region = minimax_status.get("region", "global")
|
||||
check_ok("MiniMax OAuth", f"(logged in, region={region})")
|
||||
else:
|
||||
check_warn("MiniMax OAuth", "(not logged in)")
|
||||
except Exception as e:
|
||||
check_warn("Auth provider status", f"(could not check: {e})")
|
||||
|
||||
if shutil.which("codex"):
|
||||
if _safe_which("codex"):
|
||||
check_ok("codex CLI")
|
||||
else:
|
||||
# Native OAuth uses Hermes' own device-code flow — the Codex CLI is
|
||||
@@ -787,13 +805,13 @@ def run_doctor(args):
|
||||
print(color("◆ External Tools", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
# Git
|
||||
if shutil.which("git"):
|
||||
if _safe_which("git"):
|
||||
check_ok("git")
|
||||
else:
|
||||
check_warn("git not found", "(optional)")
|
||||
|
||||
# ripgrep (optional, for faster file search)
|
||||
if shutil.which("rg"):
|
||||
if _safe_which("rg"):
|
||||
check_ok("ripgrep (rg)", "(faster file search)")
|
||||
else:
|
||||
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
|
||||
@@ -802,7 +820,7 @@ def run_doctor(args):
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
if terminal_env == "docker":
|
||||
if shutil.which("docker"):
|
||||
if _safe_which("docker"):
|
||||
# Check if docker daemon is running
|
||||
try:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
@@ -817,7 +835,7 @@ def run_doctor(args):
|
||||
check_fail("docker not found", "(required for TERMINAL_ENV=docker)")
|
||||
issues.append("Install Docker or change TERMINAL_ENV")
|
||||
else:
|
||||
if shutil.which("docker"):
|
||||
if _safe_which("docker"):
|
||||
check_ok("docker", "(optional)")
|
||||
else:
|
||||
if _is_termux():
|
||||
@@ -863,8 +881,52 @@ def run_doctor(args):
|
||||
check_fail("daytona SDK not installed", "(pip install daytona)")
|
||||
issues.append("Install daytona SDK: pip install daytona")
|
||||
|
||||
# Vercel Sandbox (if using vercel_sandbox backend)
|
||||
if terminal_env == "vercel_sandbox":
|
||||
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
|
||||
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
|
||||
if runtime in _SUPPORTED_VERCEL_RUNTIMES:
|
||||
check_ok("Vercel runtime", f"({runtime})")
|
||||
else:
|
||||
supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
|
||||
check_fail("Vercel runtime unsupported", f"({runtime}; use {supported})")
|
||||
issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}")
|
||||
|
||||
disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip()
|
||||
if disk in ("", "0", "51200"):
|
||||
check_ok("Vercel disk setting", "(uses platform default)")
|
||||
else:
|
||||
check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)")
|
||||
issues.append("Vercel Sandbox does not support custom container_disk; use the shared default 51200")
|
||||
|
||||
if importlib.util.find_spec("vercel") is not None:
|
||||
check_ok("vercel SDK", "(installed)")
|
||||
else:
|
||||
check_fail("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')")
|
||||
issues.append("Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'")
|
||||
|
||||
auth_status = describe_vercel_auth()
|
||||
if auth_status.ok:
|
||||
check_ok("Vercel auth", f"({auth_status.label})")
|
||||
elif auth_status.label.startswith("partial"):
|
||||
check_fail("Vercel auth incomplete", f"({auth_status.label})")
|
||||
issues.append("Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together")
|
||||
else:
|
||||
check_fail("Vercel auth not configured", f"({auth_status.label})")
|
||||
issues.append(
|
||||
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID"
|
||||
)
|
||||
for line in auth_status.detail_lines:
|
||||
check_info(f"Vercel auth {line}")
|
||||
|
||||
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("1", "true", "yes", "on")
|
||||
if persistent:
|
||||
check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
|
||||
else:
|
||||
check_info("Vercel persistence: ephemeral filesystem")
|
||||
|
||||
# Node.js + agent-browser (for browser automation tools)
|
||||
if shutil.which("node"):
|
||||
if _safe_which("node"):
|
||||
check_ok("Node.js")
|
||||
# Check if agent-browser is installed
|
||||
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||
@@ -890,7 +952,7 @@ def run_doctor(args):
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
if shutil.which("npm"):
|
||||
if _safe_which("npm"):
|
||||
npm_dirs = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)"),
|
||||
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
|
||||
@@ -969,10 +1031,16 @@ def run_doctor(args):
|
||||
print(" Checking Anthropic API...", end="", flush=True)
|
||||
try:
|
||||
import httpx
|
||||
from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
_COMMON_BETAS,
|
||||
_OAUTH_ONLY_BETAS,
|
||||
_CONTEXT_1M_BETA,
|
||||
)
|
||||
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(anthropic_key):
|
||||
is_oauth = _is_oauth_token(anthropic_key)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {anthropic_key}"
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
@@ -982,6 +1050,25 @@ def run_doctor(args):
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
# Reactive recovery: OAuth subscriptions that don't include 1M
|
||||
# context reject the request with 400 "long context beta is not
|
||||
# yet available for this subscription". Retry once with that
|
||||
# beta stripped so the doctor check doesn't falsely report the
|
||||
# Anthropic API as unreachable for those users.
|
||||
if (
|
||||
is_oauth
|
||||
and response.status_code == 400
|
||||
and "long context beta" in response.text.lower()
|
||||
and "not yet available" in response.text.lower()
|
||||
):
|
||||
headers["anthropic-beta"] = ",".join(
|
||||
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
|
||||
)
|
||||
response = httpx.get(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f"\r {color('✓', Colors.GREEN)} Anthropic API ")
|
||||
elif response.status_code == 401:
|
||||
|
||||
+197
-48
@@ -279,9 +279,11 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
||||
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if result.returncode != 0 or result.stdout is None:
|
||||
return []
|
||||
current_cmd = ""
|
||||
for line in result.stdout.split("\n"):
|
||||
@@ -830,6 +832,22 @@ def _user_dbus_socket_path() -> Path:
|
||||
return Path(xdg) / "bus"
|
||||
|
||||
|
||||
def _user_systemd_private_socket_path() -> Path:
|
||||
"""Return the per-user systemd private socket path (regardless of existence)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
return Path(xdg) / "systemd" / "private"
|
||||
|
||||
|
||||
def _user_systemd_socket_ready() -> bool:
|
||||
"""Return True when user-scope systemd has a reachable control socket.
|
||||
|
||||
Some distros expose only the per-user systemd private socket even when the
|
||||
D-Bus session bus socket is absent. ``systemctl --user`` can still work in
|
||||
that configuration, so preflight checks must treat either socket as valid.
|
||||
"""
|
||||
return _user_dbus_socket_path().exists() or _user_systemd_private_socket_path().exists()
|
||||
|
||||
|
||||
def _ensure_user_systemd_env() -> None:
|
||||
"""Ensure DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR are set for systemctl --user.
|
||||
|
||||
@@ -853,28 +871,29 @@ def _ensure_user_systemd_env() -> None:
|
||||
|
||||
|
||||
def _wait_for_user_dbus_socket(timeout: float = 3.0) -> bool:
|
||||
"""Poll for the user D-Bus socket to appear, up to ``timeout`` seconds.
|
||||
"""Poll for the user systemd runtime socket(s), up to ``timeout`` seconds.
|
||||
|
||||
Linger-enabled user@.service can take a second or two to spawn the socket
|
||||
after ``loginctl enable-linger`` runs. Returns True once the socket exists.
|
||||
Linger-enabled user@.service can take a second or two to spawn its control
|
||||
socket(s) after ``loginctl enable-linger`` runs. Returns True once either
|
||||
the user D-Bus socket or the per-user systemd private socket exists.
|
||||
"""
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if _user_dbus_socket_path().exists():
|
||||
if _user_systemd_socket_ready():
|
||||
_ensure_user_systemd_env()
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return _user_dbus_socket_path().exists()
|
||||
return _user_systemd_socket_ready()
|
||||
|
||||
|
||||
def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
"""Ensure ``systemctl --user`` will reach the user D-Bus session bus.
|
||||
"""Ensure ``systemctl --user`` will reach the user-scope systemd instance.
|
||||
|
||||
No-op when the bus socket is already there (the common case on desktops
|
||||
and linger-enabled servers). On fresh SSH sessions where the socket is
|
||||
missing:
|
||||
No-op when the user D-Bus socket or per-user systemd private socket is
|
||||
already there (the common case on desktops and linger-enabled servers). On
|
||||
fresh SSH sessions where both are missing:
|
||||
|
||||
* If linger is already enabled, wait briefly for user@.service to spawn
|
||||
the socket.
|
||||
@@ -888,8 +907,7 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
systemd operations and surface the message to the user.
|
||||
"""
|
||||
_ensure_user_systemd_env()
|
||||
bus_path = _user_dbus_socket_path()
|
||||
if bus_path.exists():
|
||||
if _user_systemd_socket_ready():
|
||||
return
|
||||
|
||||
import getpass
|
||||
@@ -903,7 +921,7 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
# Linger is on but socket still missing — unusual; fall through to error.
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason="User D-Bus socket is missing even though linger is enabled.",
|
||||
reason="User systemd control sockets are missing even though linger is enabled.",
|
||||
fix_hint=(
|
||||
f" systemctl start user@{os.getuid()}.service\n"
|
||||
" (may require sudo; try again after the command succeeds)"
|
||||
@@ -2350,7 +2368,11 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
# Exit with code 1 if gateway fails to connect any platform,
|
||||
# so systemd Restart=on-failure will retry on transient errors
|
||||
verbosity = None if quiet else verbose
|
||||
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
|
||||
try:
|
||||
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
|
||||
except KeyboardInterrupt:
|
||||
print("\nGateway stopped.")
|
||||
return
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
@@ -2743,15 +2765,77 @@ _PLATFORMS = [
|
||||
],
|
||||
},
|
||||
]
|
||||
def _all_platforms() -> list[dict]:
|
||||
"""Return the full list of platforms for setup menus.
|
||||
|
||||
Combines the built-in ``_PLATFORMS`` with plugin platforms registered via
|
||||
``platform_registry``. Plugins are discovered on first call so bundled
|
||||
platforms (like IRC, which auto-load via ``kind: platform``) appear in
|
||||
``hermes setup gateway`` without needing the gateway to be running.
|
||||
Built-ins keep their dict shape; plugin entries are adapted to the same
|
||||
shape with ``_registry_entry`` holding the source.
|
||||
"""
|
||||
# Populate the registry so plugin platforms are visible. Idempotent.
|
||||
# Bundled platform plugins (``kind: platform``) auto-load unconditionally,
|
||||
# so every shipped messaging channel appears in the setup menu by default.
|
||||
# User-installed platform plugins under ~/.hermes/plugins/ still require
|
||||
# opt-in via ``plugins.enabled`` (untrusted code).
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
except Exception as e:
|
||||
logger.debug("plugin discovery failed during platform enumeration: %s", e)
|
||||
|
||||
platforms = [dict(p) for p in _PLATFORMS]
|
||||
by_key = {p["key"]: p for p in platforms}
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
except Exception:
|
||||
return platforms
|
||||
|
||||
for entry in platform_registry.all_entries():
|
||||
if entry.name in by_key:
|
||||
continue # built-in already covers it
|
||||
platforms.append({
|
||||
"key": entry.name,
|
||||
"label": entry.label,
|
||||
"emoji": entry.emoji,
|
||||
"token_var": entry.required_env[0] if entry.required_env else "",
|
||||
"install_hint": entry.install_hint,
|
||||
"_registry_entry": entry,
|
||||
})
|
||||
return platforms
|
||||
|
||||
|
||||
def _platform_status(platform: dict) -> str:
|
||||
"""Return a plain-text status string for a platform.
|
||||
|
||||
Returns uncolored text so it can safely be embedded in
|
||||
simple_term_menu items (ANSI codes break width calculation).
|
||||
curses menu items (ANSI codes break width calculation).
|
||||
"""
|
||||
token_var = platform["token_var"]
|
||||
entry = platform.get("_registry_entry")
|
||||
if entry is not None:
|
||||
configured = False
|
||||
# Prefer is_connected (checks both env and config.yaml) over
|
||||
# check_fn (typically just dependency / env presence).
|
||||
if entry.is_connected is not None:
|
||||
try:
|
||||
from gateway.config import PlatformConfig
|
||||
synthetic = PlatformConfig(enabled=True)
|
||||
configured = bool(entry.is_connected(synthetic))
|
||||
except Exception:
|
||||
configured = False
|
||||
if not configured:
|
||||
try:
|
||||
configured = bool(entry.check_fn())
|
||||
except Exception:
|
||||
configured = False
|
||||
return "configured" if configured else "not configured"
|
||||
|
||||
token_var = platform.get("token_var", "")
|
||||
if not token_var:
|
||||
return "not configured"
|
||||
val = get_env_value(token_var)
|
||||
if token_var == "WHATSAPP_ENABLED":
|
||||
if val and val.lower() == "true":
|
||||
@@ -3277,6 +3361,12 @@ def _setup_weixin():
|
||||
print_warning(" Direct messages disabled.")
|
||||
|
||||
print()
|
||||
print_info(" Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a")
|
||||
print_info(" scriptable personal WeChat account. Ordinary WeChat groups typically cannot")
|
||||
print_info(" invite an @im.bot identity, and iLink does not deliver ordinary-group events")
|
||||
print_info(" to most bot accounts. The settings below only apply when iLink actually")
|
||||
print_info(" delivers group events for your account type — otherwise DM remains the only")
|
||||
print_info(" working channel regardless of this choice.")
|
||||
group_choices = [
|
||||
"Disable group chats (recommended)",
|
||||
"Allow all group chats",
|
||||
@@ -3290,12 +3380,12 @@ def _setup_weixin():
|
||||
elif group_idx == 1:
|
||||
save_env_value("WEIXIN_GROUP_POLICY", "open")
|
||||
save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "")
|
||||
print_warning(" All group chats enabled.")
|
||||
print_warning(" All group chats enabled (only takes effect if iLink delivers group events).")
|
||||
else:
|
||||
allow_groups = prompt(" Allowed group chat IDs (comma-separated)", "", password=False).replace(" ", "")
|
||||
allow_groups = prompt(" Allowed group chat IDs (comma-separated, not member user IDs)", "", password=False).replace(" ", "")
|
||||
save_env_value("WEIXIN_GROUP_POLICY", "allowlist")
|
||||
save_env_value("WEIXIN_GROUP_ALLOWED_USERS", allow_groups)
|
||||
print_success(" Group allowlist saved.")
|
||||
print_success(" Group allowlist saved (only takes effect if iLink delivers group events).")
|
||||
|
||||
if user_id:
|
||||
print()
|
||||
@@ -3703,6 +3793,71 @@ def _setup_signal():
|
||||
print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
|
||||
|
||||
|
||||
def _builtin_setup_fn(key: str):
|
||||
"""Resolve the interactive setup function for a built-in platform key.
|
||||
|
||||
Late-bound to avoid a circular import with ``hermes_cli.setup`` (which
|
||||
imports from this module for the remaining bespoke flows).
|
||||
"""
|
||||
from hermes_cli import setup as _s
|
||||
return {
|
||||
"telegram": _s._setup_telegram,
|
||||
"discord": _s._setup_discord,
|
||||
"slack": _s._setup_slack,
|
||||
"matrix": _s._setup_matrix,
|
||||
"mattermost": _s._setup_mattermost,
|
||||
"bluebubbles": _s._setup_bluebubbles,
|
||||
"webhooks": _s._setup_webhooks,
|
||||
"signal": _setup_signal,
|
||||
"whatsapp": _setup_whatsapp,
|
||||
"weixin": _setup_weixin,
|
||||
"dingtalk": _setup_dingtalk,
|
||||
"feishu": _setup_feishu,
|
||||
"wecom": _setup_wecom,
|
||||
"qqbot": _setup_qqbot,
|
||||
}.get(key)
|
||||
def _configure_platform(platform: dict) -> None:
|
||||
"""Run the interactive setup flow for a single platform.
|
||||
|
||||
Dispatch order:
|
||||
1. Plugin-provided ``setup_fn`` on the registry entry.
|
||||
2. Built-in setup function matched by platform key.
|
||||
3. ``_setup_standard_platform`` when the entry has a ``vars`` schema.
|
||||
4. Env-var hint fallback for plugins that offer no setup helper.
|
||||
|
||||
Bundled platform plugins (e.g. IRC) auto-load, so no plugin enable step
|
||||
is needed here. User-installed platform plugins under ~/.hermes/plugins/
|
||||
must already be in ``plugins.enabled`` before they appear in this menu.
|
||||
"""
|
||||
entry = platform.get("_registry_entry")
|
||||
|
||||
if entry is not None and entry.setup_fn is not None:
|
||||
entry.setup_fn()
|
||||
return
|
||||
|
||||
fn = _builtin_setup_fn(platform["key"])
|
||||
if fn is not None:
|
||||
fn()
|
||||
return
|
||||
|
||||
if platform.get("vars"):
|
||||
_setup_standard_platform(platform)
|
||||
return
|
||||
|
||||
# Plugin with no setup helper — show env-var instructions.
|
||||
label = platform.get("label", platform["key"])
|
||||
emoji = platform.get("emoji", "🔌")
|
||||
print()
|
||||
print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN))
|
||||
required = entry.required_env if entry else []
|
||||
if required:
|
||||
print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}")
|
||||
else:
|
||||
print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}")
|
||||
if platform.get("install_hint"):
|
||||
print_info(f" {platform['install_hint']}")
|
||||
|
||||
|
||||
def gateway_setup():
|
||||
"""Interactive setup for messaging platforms + gateway service."""
|
||||
if is_managed():
|
||||
@@ -3755,42 +3910,36 @@ def gateway_setup():
|
||||
print()
|
||||
print_header("Messaging Platforms")
|
||||
|
||||
menu_items = []
|
||||
for plat in _PLATFORMS:
|
||||
status = _platform_status(plat)
|
||||
menu_items.append(f"{plat['label']} ({status})")
|
||||
platforms = _all_platforms()
|
||||
|
||||
menu_items = [
|
||||
f"{p['emoji']} {p['label']} ({_platform_status(p)})"
|
||||
for p in platforms
|
||||
]
|
||||
menu_items.append("Done")
|
||||
|
||||
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
|
||||
|
||||
if choice == len(_PLATFORMS):
|
||||
if choice == len(platforms):
|
||||
break
|
||||
|
||||
platform = _PLATFORMS[choice]
|
||||
|
||||
if platform["key"] == "whatsapp":
|
||||
_setup_whatsapp()
|
||||
elif platform["key"] == "signal":
|
||||
_setup_signal()
|
||||
elif platform["key"] == "weixin":
|
||||
_setup_weixin()
|
||||
elif platform["key"] == "dingtalk":
|
||||
_setup_dingtalk()
|
||||
elif platform["key"] == "feishu":
|
||||
_setup_feishu()
|
||||
elif platform["key"] == "qqbot":
|
||||
_setup_qqbot()
|
||||
elif platform["key"] == "wecom":
|
||||
_setup_wecom()
|
||||
else:
|
||||
_setup_standard_platform(platform)
|
||||
_configure_platform(platforms[choice])
|
||||
|
||||
# ── Post-setup: offer to install/restart gateway ──
|
||||
# Consider any platform (built-in or plugin) where the user has made
|
||||
# meaningful progress. ``_platform_status`` already handles plugin
|
||||
# entries via their check_fn and per-platform dual-states like
|
||||
# WhatsApp's "enabled, not paired".
|
||||
def _is_progress(status: str) -> bool:
|
||||
s = status.lower()
|
||||
return not (
|
||||
s == "not configured"
|
||||
or s.startswith("partially")
|
||||
or s.startswith("plugin disabled")
|
||||
)
|
||||
|
||||
any_configured = any(
|
||||
bool(get_env_value(p["token_var"]))
|
||||
for p in _PLATFORMS
|
||||
if p["key"] != "whatsapp"
|
||||
) or (get_env_value("WHATSAPP_ENABLED") or "").lower() == "true"
|
||||
_is_progress(_platform_status(p)) for p in _all_platforms()
|
||||
)
|
||||
|
||||
if any_configured:
|
||||
print()
|
||||
@@ -4228,4 +4377,4 @@ def _gateway_command_inner(args):
|
||||
if not supports_systemd_services() and not is_macos():
|
||||
print("Legacy unit migration only applies to systemd-based Linux hosts.")
|
||||
return
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
+307
-327
@@ -114,6 +114,12 @@ def _apply_profile_override() -> None:
|
||||
consume = 1
|
||||
break
|
||||
|
||||
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
|
||||
# This lets child processes (relaunch, subprocess) inherit the parent's
|
||||
# profile choice without having to pass --profile again.
|
||||
if profile_name is None and os.environ.get("HERMES_HOME"):
|
||||
return
|
||||
|
||||
# 2. If no flag, check active_profile in the hermes root
|
||||
if profile_name is None:
|
||||
try:
|
||||
@@ -1094,11 +1100,36 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
return [node, str(root / "dist" / "entry.js")], root
|
||||
|
||||
|
||||
def _normalize_tui_toolsets(toolsets: object) -> list[str]:
|
||||
"""Normalize argparse/Fire-style toolset input for the TUI subprocess."""
|
||||
try:
|
||||
from hermes_cli.oneshot import _normalize_toolsets
|
||||
|
||||
return _normalize_toolsets(toolsets) or []
|
||||
except (AttributeError, ImportError):
|
||||
if not toolsets:
|
||||
return []
|
||||
|
||||
raw_items = [toolsets] if isinstance(toolsets, str) else toolsets
|
||||
if not isinstance(raw_items, (list, tuple)):
|
||||
raw_items = [raw_items]
|
||||
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
normalized.extend(part.strip() for part in item.split(","))
|
||||
else:
|
||||
normalized.append(str(item).strip())
|
||||
|
||||
return [item for item in normalized if item]
|
||||
|
||||
|
||||
def _launch_tui(
|
||||
resume_session_id: Optional[str] = None,
|
||||
tui_dev: bool = False,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
):
|
||||
"""Replace current process with the TUI."""
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
@@ -1123,6 +1154,9 @@ def _launch_tui(
|
||||
if provider:
|
||||
env["HERMES_TUI_PROVIDER"] = provider
|
||||
env["HERMES_INFERENCE_PROVIDER"] = provider
|
||||
tui_toolsets = _normalize_tui_toolsets(toolsets)
|
||||
if tui_toolsets:
|
||||
env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets)
|
||||
# Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is
|
||||
# ~1.5–4GB depending on version and can fatal-OOM on long sessions with
|
||||
# large transcripts / reasoning blobs. Token-level merge: respect any
|
||||
@@ -1270,6 +1304,7 @@ def cmd_chat(args):
|
||||
tui_dev=getattr(args, "tui_dev", False),
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
toolsets=getattr(args, "toolsets", None),
|
||||
)
|
||||
|
||||
# Import and run the CLI
|
||||
@@ -1770,6 +1805,8 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_openai_codex(config, current_model)
|
||||
elif selected_provider == "qwen-oauth":
|
||||
_model_flow_qwen_oauth(config, current_model)
|
||||
elif selected_provider == "minimax-oauth":
|
||||
_model_flow_minimax_oauth(config, current_model, args=args)
|
||||
elif selected_provider == "google-gemini-cli":
|
||||
_model_flow_google_gemini_cli(config, current_model)
|
||||
elif selected_provider == "copilot-acp":
|
||||
@@ -1890,6 +1927,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("title_generation", "Title generation", "session titles"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
("curator", "Curator", "skill-usage review pass"),
|
||||
]
|
||||
|
||||
|
||||
@@ -2658,6 +2696,53 @@ def _model_flow_qwen_oauth(_config, current_model=""):
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_minimax_oauth(config, current_model="", args=None):
|
||||
"""MiniMax OAuth provider: ensure logged in, then pick model."""
|
||||
from hermes_cli.auth import (
|
||||
get_provider_auth_state,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
_update_config_for_provider,
|
||||
resolve_minimax_oauth_runtime_credentials,
|
||||
AuthError,
|
||||
format_auth_error,
|
||||
_login_minimax_oauth,
|
||||
PROVIDER_REGISTRY,
|
||||
)
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
print("Not logged into MiniMax. Starting OAuth login...")
|
||||
print()
|
||||
try:
|
||||
mock_args = argparse.Namespace(
|
||||
region=getattr(args, "region", None) or "global",
|
||||
no_browser=bool(getattr(args, "no_browser", False)),
|
||||
timeout=getattr(args, "timeout", None) or 15.0,
|
||||
)
|
||||
_login_minimax_oauth(mock_args, PROVIDER_REGISTRY["minimax-oauth"])
|
||||
except SystemExit:
|
||||
print("Login cancelled or failed.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print(f"Login failed: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
except AuthError as exc:
|
||||
print(format_auth_error(exc))
|
||||
return
|
||||
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
|
||||
selected = _prompt_model_selection(model_ids, current_model)
|
||||
if not selected:
|
||||
return
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("minimax-oauth", creds["base_url"])
|
||||
print(f"\u2713 Using MiniMax model: {selected}")
|
||||
|
||||
|
||||
def _model_flow_google_gemini_cli(_config, current_model=""):
|
||||
"""Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers.
|
||||
|
||||
@@ -5251,8 +5336,8 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _warn_stale_dashboard_processes() -> None:
|
||||
"""Warn about running dashboard processes that still hold pre-update code.
|
||||
def _find_stale_dashboard_pids() -> list[int]:
|
||||
"""Return PIDs of ``hermes dashboard`` processes other than ourselves.
|
||||
|
||||
``hermes dashboard`` is a long-lived server process commonly started and
|
||||
forgotten. When ``hermes update`` replaces files on disk, the running
|
||||
@@ -5260,9 +5345,13 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
disk is updated, causing a silent frontend/backend mismatch (e.g. new
|
||||
auth headers the old backend doesn't recognise → every API call 401s).
|
||||
|
||||
Unlike the gateway, the dashboard has no service manager (systemd /
|
||||
launchd), so we can only warn — we don't auto-kill user-managed
|
||||
background processes.
|
||||
The dashboard has no service manager (systemd / launchd), no PID file,
|
||||
and we can't know the original launch args — so the only sane action
|
||||
after an update is to kill the stale process and let the user restart
|
||||
it. This helper is just the detection step; see
|
||||
``_kill_stale_dashboard_processes`` for the kill.
|
||||
|
||||
Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
|
||||
"""
|
||||
patterns = [
|
||||
"hermes dashboard",
|
||||
@@ -5274,13 +5363,21 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# wmic may emit text in the system code page (for example cp936
|
||||
# on zh-CN systems), not UTF-8. In text mode, subprocess output
|
||||
# decoding depends on Python's configuration (locale-dependent
|
||||
# by default, or UTF-8 in UTF-8 mode). The important protection
|
||||
# here is errors="ignore": it prevents a reader-thread
|
||||
# UnicodeDecodeError from leaving result.stdout=None and turning
|
||||
# the later .split() into an AttributeError (#17049).
|
||||
result = subprocess.run(
|
||||
["wmic", "process", "get", "ProcessId,CommandLine",
|
||||
"/FORMAT:LIST"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
encoding="utf-8", errors="ignore",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
if result.returncode != 0 or result.stdout is None:
|
||||
return []
|
||||
current_cmd = ""
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
@@ -5306,7 +5403,7 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split("\n"):
|
||||
for line in getattr(result, "stdout", "").split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped or "grep" in stripped:
|
||||
continue
|
||||
@@ -5322,20 +5419,112 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
and pid != self_pid):
|
||||
dashboard_pids.append(pid)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return
|
||||
return []
|
||||
|
||||
if not dashboard_pids:
|
||||
return dashboard_pids
|
||||
|
||||
|
||||
def _kill_stale_dashboard_processes(
|
||||
reason: str = "the running backend no longer matches the updated frontend",
|
||||
) -> None:
|
||||
"""Kill running ``hermes dashboard`` processes.
|
||||
|
||||
Called at the end of ``hermes update`` (default ``reason``) and also
|
||||
from ``hermes dashboard --stop`` (which overrides ``reason``). The
|
||||
dashboard has no service manager, so after a code update the running
|
||||
process is guaranteed to be serving stale Python against a
|
||||
freshly-updated JS bundle. Leaving it alive produces silent
|
||||
frontend/backend mismatches (new auth headers the old backend doesn't
|
||||
recognise → every API call 401s).
|
||||
|
||||
POSIX: SIGTERM, wait up to ~3s for graceful exit, SIGKILL any survivors.
|
||||
Windows: ``taskkill /PID <pid> /F`` since there's no clean SIGTERM
|
||||
equivalent for background console apps.
|
||||
|
||||
The dashboard isn't auto-restarted because we don't know the original
|
||||
launch args (--host, --port, --insecure, --tui, --no-open). The user
|
||||
restarts it manually; a hint is printed.
|
||||
"""
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
return
|
||||
|
||||
print()
|
||||
print(f"⚠ {len(dashboard_pids)} dashboard process(es) still running "
|
||||
f"with the previous version:")
|
||||
for pid in dashboard_pids:
|
||||
print(f" PID {pid}")
|
||||
print(" The running backend may not match the updated frontend,")
|
||||
print(" causing silent auth failures or empty data.")
|
||||
print(" Restart them to pick up the changes:")
|
||||
print(" kill <pid> && hermes dashboard --port <port> ...")
|
||||
print(f"⟲ Stopping {len(pids)} dashboard process(es) ({reason})")
|
||||
|
||||
killed: list[int] = []
|
||||
failed: list[tuple[int, str]] = []
|
||||
|
||||
if sys.platform == "win32":
|
||||
for pid in pids:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/F"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
killed.append(pid)
|
||||
else:
|
||||
failed.append((pid, (result.stderr or result.stdout or "").strip()))
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
else:
|
||||
import signal as _signal
|
||||
import time as _time
|
||||
|
||||
# SIGTERM first — give each process a chance to shut down cleanly
|
||||
# (uvicorn closes its socket, flushes logs, etc.).
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
# Already gone — count as killed.
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
|
||||
# Poll for exit up to ~3s total.
|
||||
deadline = _time.monotonic() + 3.0
|
||||
pending = [p for p in pids if p not in killed
|
||||
and p not in {f[0] for f in failed}]
|
||||
while pending and _time.monotonic() < deadline:
|
||||
_time.sleep(0.1)
|
||||
still_pending = []
|
||||
for pid in pending:
|
||||
try:
|
||||
os.kill(pid, 0) # probe
|
||||
except ProcessLookupError:
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError):
|
||||
# Can't probe — assume still there.
|
||||
still_pending.append(pid)
|
||||
else:
|
||||
still_pending.append(pid)
|
||||
pending = still_pending
|
||||
|
||||
# SIGKILL any survivors.
|
||||
for pid in pending:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGKILL)
|
||||
killed.append(pid)
|
||||
except ProcessLookupError:
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
|
||||
for pid in killed:
|
||||
print(f" ✓ stopped PID {pid}")
|
||||
for pid, reason in failed:
|
||||
print(f" ✗ failed to stop PID {pid}: {reason}")
|
||||
|
||||
if killed:
|
||||
print(" Restart the dashboard when you're ready:")
|
||||
print(" hermes dashboard --port <port>")
|
||||
|
||||
|
||||
# Back-compat alias: some tests and any external callers may import the old
|
||||
# warn-only name. The new behaviour (kill stale processes) replaces it.
|
||||
_warn_stale_dashboard_processes = _kill_stale_dashboard_processes
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
@@ -5472,7 +5661,7 @@ def _update_via_zip(args):
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
_warn_stale_dashboard_processes()
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
|
||||
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
||||
@@ -7289,9 +7478,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception as e:
|
||||
logger.debug("Legacy unit check during update failed: %s", e)
|
||||
|
||||
# Warn about stale dashboard processes — the dashboard has no
|
||||
# service manager, so we can only tell the user to restart them.
|
||||
_warn_stale_dashboard_processes()
|
||||
# Kill stale dashboard processes — the dashboard has no service
|
||||
# manager, so leaving it alive after a code update produces a
|
||||
# silent frontend/backend mismatch. We can't auto-restart it
|
||||
# (no saved launch args) but we can stop it, and a hint is
|
||||
# printed for the user to re-launch.
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
print()
|
||||
print("Tip: You can now select a provider and model:")
|
||||
@@ -7682,8 +7874,59 @@ def cmd_profile(args):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _report_dashboard_status() -> int:
|
||||
"""Print ``hermes dashboard`` PIDs and return the count.
|
||||
|
||||
Uses the same detection logic as ``_find_stale_dashboard_pids`` (the
|
||||
current process is excluded, but since ``hermes dashboard --status``
|
||||
runs in a short-lived CLI process that never matches the pattern,
|
||||
the exclusion is irrelevant here).
|
||||
"""
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
print("No hermes dashboard processes running.")
|
||||
return 0
|
||||
|
||||
print(f"{len(pids)} hermes dashboard process(es) running:")
|
||||
for pid in pids:
|
||||
# Best-effort: show the full cmdline so users can tell profiles apart.
|
||||
cmdline = ""
|
||||
try:
|
||||
if sys.platform != "win32":
|
||||
cmdline_path = f"/proc/{pid}/cmdline"
|
||||
if os.path.exists(cmdline_path):
|
||||
with open(cmdline_path, "rb") as f:
|
||||
cmdline = f.read().replace(b"\x00", b" ").decode(
|
||||
"utf-8", errors="replace").strip()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if cmdline:
|
||||
print(f" PID {pid}: {cmdline}")
|
||||
else:
|
||||
print(f" PID {pid}")
|
||||
return len(pids)
|
||||
|
||||
|
||||
def cmd_dashboard(args):
|
||||
"""Start the web UI server."""
|
||||
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
||||
# --status: report running dashboards and exit, no deps needed.
|
||||
if getattr(args, "status", False):
|
||||
count = _report_dashboard_status()
|
||||
sys.exit(0 if count == 0 else 0) # status is informational, always 0
|
||||
|
||||
# --stop: kill any running dashboards and exit, no deps needed.
|
||||
if getattr(args, "stop", False):
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
print("No hermes dashboard processes running.")
|
||||
sys.exit(0)
|
||||
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`.
|
||||
_kill_stale_dashboard_processes(reason="requested via --stop")
|
||||
# _kill_stale_dashboard_processes prints outcomes itself. Exit 0 if
|
||||
# we killed at least one, 1 if they were all unkillable.
|
||||
remaining = _find_stale_dashboard_pids()
|
||||
sys.exit(1 if remaining else 0)
|
||||
|
||||
try:
|
||||
import fastapi # noqa: F401
|
||||
import uvicorn # noqa: F401
|
||||
@@ -7750,302 +7993,9 @@ def cmd_logs(args):
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes",
|
||||
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
hermes gateway Run messaging gateway
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# =========================================================================
|
||||
# chat command
|
||||
# =========================================================================
|
||||
chat_parser = subparsers.add_parser(
|
||||
"chat",
|
||||
help="Interactive chat with the agent",
|
||||
description="Start an interactive chat session with Hermes Agent",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-q", "--query", help="Single query (non-interactive mode)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-t", "--toolsets", help="Comma-separated toolsets to enable"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-Q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
|
||||
# =========================================================================
|
||||
@@ -9230,6 +9180,26 @@ Examples:
|
||||
except Exception as _exc:
|
||||
logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc)
|
||||
|
||||
# =========================================================================
|
||||
# curator command — background skill maintenance
|
||||
# =========================================================================
|
||||
curator_parser = subparsers.add_parser(
|
||||
"curator",
|
||||
help="Background skill maintenance (curator) — status, run, pause, pin",
|
||||
description=(
|
||||
"The curator is an auxiliary-model background task that "
|
||||
"periodically reviews agent-created skills, prunes stale ones, "
|
||||
"consolidates overlaps, and archives obsolete skills. "
|
||||
"Bundled and hub-installed skills are never touched. "
|
||||
"Archives are recoverable; auto-deletion never happens."
|
||||
),
|
||||
)
|
||||
try:
|
||||
from hermes_cli.curator import register_cli as _register_curator_cli
|
||||
_register_curator_cli(curator_parser)
|
||||
except Exception as _exc:
|
||||
logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc)
|
||||
|
||||
# =========================================================================
|
||||
# memory command
|
||||
# =========================================================================
|
||||
@@ -9695,15 +9665,8 @@ Examples:
|
||||
|
||||
# Launch hermes --resume <id> by replacing the current process
|
||||
print(f"Resuming session: {selected_id}")
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
|
||||
else:
|
||||
# Fallback: re-invoke via python -m
|
||||
os.execvp(
|
||||
sys.executable,
|
||||
[sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
|
||||
)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["--resume", selected_id])
|
||||
return # won't reach here after execvp
|
||||
|
||||
elif action == "stats":
|
||||
@@ -10061,6 +10024,22 @@ Examples:
|
||||
"Alternatively set HERMES_DASHBOARD_TUI=1."
|
||||
),
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
# no service manager and no PID file, so these scan the process table
|
||||
# for `hermes dashboard` cmdlines and SIGTERM them directly — the same
|
||||
# path `hermes update` uses to clean up stale dashboards.
|
||||
dashboard_parser.add_argument(
|
||||
"--stop",
|
||||
action="store_true",
|
||||
help="Stop all running hermes dashboard processes and exit",
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="List running hermes dashboard processes and exit",
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# =========================================================================
|
||||
@@ -10250,6 +10229,7 @@ Examples:
|
||||
args.oneshot,
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
toolsets=getattr(args, "toolsets", None),
|
||||
))
|
||||
|
||||
# Handle top-level --resume / --continue as shortcut to chat
|
||||
|
||||
@@ -16,6 +16,7 @@ import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
load_config,
|
||||
save_config,
|
||||
get_env_value,
|
||||
@@ -716,7 +717,7 @@ def cmd_mcp_configure(args):
|
||||
|
||||
# Update config
|
||||
config = load_config()
|
||||
server_entry = config.get("mcp_servers", {}).get(name, {})
|
||||
server_entry = cfg_get(config, "mcp_servers", name, default={})
|
||||
|
||||
if len(chosen) == total:
|
||||
# All selected → remove include/exclude (register all)
|
||||
|
||||
@@ -96,6 +96,7 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
|
||||
"kimi-coding",
|
||||
"kimi-coding-cn",
|
||||
"minimax",
|
||||
"minimax-oauth",
|
||||
"minimax-cn",
|
||||
"alibaba",
|
||||
"qwen-oauth",
|
||||
|
||||
@@ -539,6 +539,7 @@ def resolve_display_context_length(
|
||||
api_key: str = "",
|
||||
model_info: Optional[ModelInfo] = None,
|
||||
custom_providers: list | None = None,
|
||||
config_context_length: int | None = None,
|
||||
) -> Optional[int]:
|
||||
"""Resolve the context length to show in /model output.
|
||||
|
||||
@@ -565,6 +566,7 @@ def resolve_display_context_length(
|
||||
api_key=api_key or "",
|
||||
provider=provider or None,
|
||||
custom_providers=custom_providers,
|
||||
config_context_length=config_context_length,
|
||||
)
|
||||
if ctx:
|
||||
return int(ctx)
|
||||
@@ -1018,6 +1020,37 @@ def list_authenticated_providers(
|
||||
results: List[dict] = []
|
||||
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
|
||||
seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn)
|
||||
# Effective base URLs of every built-in row we emit (normalized lower+rstrip).
|
||||
# Section 4 uses this to hide ``custom_providers`` entries that point at the
|
||||
# same endpoint as a built-in (e.g. a user-defined "my-dashscope" on
|
||||
# https://coding-intl.dashscope.aliyuncs.com/v1 collides with the built-in
|
||||
# alibaba-coding-plan row when DASHSCOPE_API_KEY is present). Fixes #16970.
|
||||
_builtin_endpoints: set = set()
|
||||
|
||||
def _norm_url(url: str) -> str:
|
||||
return str(url or "").strip().rstrip("/").lower()
|
||||
|
||||
def _record_builtin_endpoint(slug: str) -> None:
|
||||
"""Record the effective base URL for a built-in provider row.
|
||||
|
||||
Prefers the live env-override (e.g. DASHSCOPE_BASE_URL) over the
|
||||
static inference_base_url so the dedup matches what a user typing
|
||||
that URL into custom_providers would actually hit."""
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY as _reg
|
||||
except Exception:
|
||||
return
|
||||
pcfg = _reg.get(slug)
|
||||
if not pcfg:
|
||||
return
|
||||
url = ""
|
||||
if getattr(pcfg, "base_url_env_var", ""):
|
||||
url = os.environ.get(pcfg.base_url_env_var, "") or ""
|
||||
if not url:
|
||||
url = getattr(pcfg, "inference_base_url", "") or ""
|
||||
normed = _norm_url(url)
|
||||
if normed:
|
||||
_builtin_endpoints.add(normed)
|
||||
|
||||
data = fetch_models_dev()
|
||||
|
||||
@@ -1124,6 +1157,7 @@ def list_authenticated_providers(
|
||||
})
|
||||
seen_slugs.add(slug.lower())
|
||||
seen_mdev_ids.add(mdev_id)
|
||||
_record_builtin_endpoint(slug)
|
||||
|
||||
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
@@ -1238,6 +1272,7 @@ def list_authenticated_providers(
|
||||
})
|
||||
seen_slugs.add(pid.lower())
|
||||
seen_slugs.add(hermes_slug.lower())
|
||||
_record_builtin_endpoint(hermes_slug)
|
||||
|
||||
# --- 2b. Cross-check canonical provider list ---
|
||||
# Catches providers that are in CANONICAL_PROVIDERS but weren't found
|
||||
@@ -1317,6 +1352,7 @@ def list_authenticated_providers(
|
||||
"source": "canonical",
|
||||
})
|
||||
seen_slugs.add(_cp.slug.lower())
|
||||
_record_builtin_endpoint(_cp.slug)
|
||||
|
||||
# --- 3. User-defined endpoints from config ---
|
||||
# Track (name, base_url) of what section 3 emits so section 4 can skip
|
||||
@@ -1467,7 +1503,14 @@ def list_authenticated_providers(
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
slug = current_provider or custom_provider_slug(display_name)
|
||||
# Guard against bare "custom" slug left by a prior
|
||||
# failed switch — always resolve to the canonical
|
||||
# custom:<name> form. (GH #17478)
|
||||
slug = (
|
||||
current_provider
|
||||
if current_provider and current_provider != "custom"
|
||||
else custom_provider_slug(display_name)
|
||||
)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
@@ -1526,6 +1569,15 @@ def list_authenticated_providers(
|
||||
)
|
||||
if _pair_key[0] and _pair_key[1] and _pair_key in _section3_emitted_pairs:
|
||||
continue
|
||||
# Skip if a built-in row (sections 1/2/2b) already represents this
|
||||
# endpoint. Fixes #16970: a user-defined "my-dashscope" pointing at
|
||||
# https://coding-intl.dashscope.aliyuncs.com/v1 duplicates the
|
||||
# built-in alibaba-coding-plan row whenever DASHSCOPE_API_KEY is
|
||||
# set. The built-in row carries the curated model list, correct
|
||||
# auth wiring, and canonical slug — keep it and hide the shadow.
|
||||
_grp_url_norm = _pair_key[1]
|
||||
if _grp_url_norm and _grp_url_norm in _builtin_endpoints:
|
||||
continue
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
|
||||
+52
-16
@@ -288,6 +288,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2",
|
||||
],
|
||||
"minimax-oauth": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
],
|
||||
"minimax-cn": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.5",
|
||||
@@ -788,6 +792,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
|
||||
ProviderEntry("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
|
||||
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"),
|
||||
@@ -831,6 +836,9 @@ _PROVIDER_ALIASES = {
|
||||
"gmicloud": "gmi",
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
"minimax-portal": "minimax-oauth",
|
||||
"minimax-global": "minimax-oauth",
|
||||
"minimax_oauth": "minimax-oauth",
|
||||
"claude": "anthropic",
|
||||
"claude-code": "anthropic",
|
||||
"deep-seek": "deepseek",
|
||||
@@ -2026,28 +2034,56 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
return None
|
||||
|
||||
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(token):
|
||||
is_oauth = _is_oauth_token(token)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
headers["x-api-key"] = token
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
def _do_request(h: dict[str, str]):
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=h,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
||||
return sorted(models, key=lambda m: (
|
||||
"opus" not in m, # opus first
|
||||
"sonnet" not in m, # then sonnet
|
||||
"haiku" not in m, # then haiku
|
||||
m, # alphabetical within tier
|
||||
))
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
try:
|
||||
try:
|
||||
data = _do_request(headers)
|
||||
except urllib.error.HTTPError as http_err:
|
||||
# Reactive recovery for OAuth subscriptions that reject the 1M
|
||||
# context beta with 400 "long context beta is not yet available
|
||||
# for this subscription". Retry once without the beta; re-raise
|
||||
# anything else so the outer except logs it.
|
||||
if (
|
||||
is_oauth
|
||||
and http_err.code == 400
|
||||
):
|
||||
try:
|
||||
body_text = http_err.read().decode(errors="ignore").lower()
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if "long context beta" in body_text and "not yet available" in body_text:
|
||||
headers["anthropic-beta"] = ",".join(
|
||||
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
|
||||
+ list(_OAUTH_ONLY_BETAS)
|
||||
)
|
||||
data = _do_request(headers)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
||||
return sorted(models, key=lambda m: (
|
||||
"opus" not in m, # opus first
|
||||
"sonnet" not in m, # then sonnet
|
||||
"haiku" not in m, # then haiku
|
||||
m, # alphabetical within tier
|
||||
))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
|
||||
|
||||
+117
-5
@@ -3,7 +3,8 @@
|
||||
Bypasses cli.py entirely. No banner, no spinner, no session_id line,
|
||||
no stderr chatter. Just the agent's final text to stdout.
|
||||
|
||||
Toolsets = whatever the user has configured for "cli" in `hermes tools`.
|
||||
Toolsets = explicit --toolsets when provided, otherwise whatever the user has
|
||||
configured for "cli" in `hermes tools`.
|
||||
Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn.
|
||||
Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call).
|
||||
Working directory = the user's CWD (AGENTS.md etc. resolve from there as usual).
|
||||
@@ -28,10 +29,103 @@ from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _normalize_toolsets(toolsets: object = None) -> list[str] | None:
|
||||
if not toolsets:
|
||||
return None
|
||||
|
||||
raw_items = [toolsets] if isinstance(toolsets, str) else toolsets
|
||||
if not isinstance(raw_items, (list, tuple)):
|
||||
raw_items = [raw_items]
|
||||
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
normalized.extend(part.strip() for part in item.split(","))
|
||||
else:
|
||||
normalized.append(str(item).strip())
|
||||
|
||||
return [item for item in normalized if item] or None
|
||||
|
||||
|
||||
def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | None, str | None]:
|
||||
normalized = _normalize_toolsets(toolsets)
|
||||
if normalized is None:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
from toolsets import validate_toolset
|
||||
except Exception as exc:
|
||||
return None, f"hermes -z: failed to validate --toolsets: {exc}\n"
|
||||
|
||||
built_in = [name for name in normalized if validate_toolset(name)]
|
||||
unresolved = [name for name in normalized if name not in built_in]
|
||||
|
||||
if unresolved:
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
plugin_valid = [name for name in unresolved if validate_toolset(name)]
|
||||
except Exception:
|
||||
plugin_valid = []
|
||||
|
||||
if plugin_valid:
|
||||
built_in.extend(plugin_valid)
|
||||
unresolved = [name for name in unresolved if name not in plugin_valid]
|
||||
|
||||
if any(name in {"all", "*"} for name in built_in):
|
||||
ignored = [name for name in normalized if name not in {"all", "*"}]
|
||||
if ignored:
|
||||
sys.stderr.write(
|
||||
"hermes -z: --toolsets all enables every toolset; "
|
||||
f"ignoring additional entries: {', '.join(ignored)}\n"
|
||||
)
|
||||
return None, None
|
||||
|
||||
mcp_names: set[str] = set()
|
||||
mcp_disabled: set[str] = set()
|
||||
if unresolved:
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
cfg = read_raw_config()
|
||||
mcp_servers = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {}
|
||||
for name, server_cfg in mcp_servers.items():
|
||||
if not isinstance(server_cfg, dict):
|
||||
continue
|
||||
if _parse_enabled_flag(server_cfg.get("enabled", True), default=True):
|
||||
mcp_names.add(str(name))
|
||||
else:
|
||||
mcp_disabled.add(str(name))
|
||||
except Exception:
|
||||
mcp_names = set()
|
||||
mcp_disabled = set()
|
||||
|
||||
mcp_valid = [name for name in unresolved if name in mcp_names]
|
||||
disabled = [name for name in unresolved if name in mcp_disabled]
|
||||
unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
|
||||
valid = built_in + mcp_valid
|
||||
|
||||
if unknown:
|
||||
sys.stderr.write(f"hermes -z: ignoring unknown --toolsets entries: {', '.join(unknown)}\n")
|
||||
if disabled:
|
||||
sys.stderr.write(
|
||||
"hermes -z: ignoring disabled MCP servers (set enabled: true in config.yaml to use): "
|
||||
f"{', '.join(disabled)}\n"
|
||||
)
|
||||
|
||||
if not valid:
|
||||
return None, "hermes -z: --toolsets did not contain any valid toolsets.\n"
|
||||
|
||||
return valid, None
|
||||
|
||||
|
||||
def run_oneshot(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
) -> int:
|
||||
"""Execute a single prompt and print only the final content block.
|
||||
|
||||
@@ -42,6 +136,7 @@ def run_oneshot(
|
||||
provider: Optional provider override. Falls back to
|
||||
HERMES_INFERENCE_PROVIDER env var, then config.yaml's model.provider,
|
||||
then "auto".
|
||||
toolsets: Optional comma-separated string or iterable of toolsets.
|
||||
|
||||
Returns the exit code. Caller should sys.exit() with the return.
|
||||
"""
|
||||
@@ -65,6 +160,12 @@ def run_oneshot(
|
||||
)
|
||||
return 2
|
||||
|
||||
explicit_toolsets, toolsets_error = _validate_explicit_toolsets(toolsets)
|
||||
if toolsets_error:
|
||||
sys.stderr.write(toolsets_error)
|
||||
return 2
|
||||
use_config_toolsets = _normalize_toolsets(toolsets) is None
|
||||
|
||||
# Auto-approve any shell / tool approvals. Non-interactive by
|
||||
# definition — a prompt would hang forever.
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
@@ -77,7 +178,13 @@ def run_oneshot(
|
||||
|
||||
try:
|
||||
with redirect_stdout(devnull), redirect_stderr(devnull):
|
||||
response = _run_agent(prompt, model=model, provider=provider)
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
devnull.close()
|
||||
@@ -96,6 +203,8 @@ def _run_agent(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
use_config_toolsets: bool = True,
|
||||
) -> str:
|
||||
"""Build an AIAgent exactly like a normal CLI chat turn would, then
|
||||
run a single conversation. Returns the final response string."""
|
||||
@@ -168,9 +277,12 @@ def _run_agent(
|
||||
explicit_base_url=explicit_base_url_from_alias,
|
||||
)
|
||||
|
||||
# Pull in whatever toolsets the user has enabled for "cli".
|
||||
# sorted() gives stable ordering; set→list for AIAgent's signature.
|
||||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
# Pull in explicit toolsets when provided; otherwise use whatever the user
|
||||
# has enabled for "cli". sorted() gives stable ordering for config-derived
|
||||
# sets; explicit values preserve user order.
|
||||
toolsets_list = _normalize_toolsets(toolsets)
|
||||
if toolsets_list is None and use_config_toolsets:
|
||||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
|
||||
agent = AIAgent(
|
||||
api_key=runtime.get("api_key"),
|
||||
|
||||
+36
-2
@@ -44,6 +44,40 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||
|
||||
|
||||
def platform_label(key: str, default: str = "") -> str:
|
||||
"""Return the display label for a platform key, or *default*."""
|
||||
"""Return the display label for a platform key, or *default*.
|
||||
|
||||
Checks the static PLATFORMS dict first, then the plugin platform
|
||||
registry for dynamically registered platforms.
|
||||
"""
|
||||
info = PLATFORMS.get(key)
|
||||
return info.label if info is not None else default
|
||||
if info is not None:
|
||||
return info.label
|
||||
# Check plugin registry
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(key)
|
||||
if entry:
|
||||
return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def get_all_platforms() -> "OrderedDict[str, PlatformInfo]":
|
||||
"""Return PLATFORMS merged with any plugin-registered platforms.
|
||||
|
||||
Plugin platforms are appended after builtins. This is the function
|
||||
that tools_config and skills_config should use for platform menus.
|
||||
"""
|
||||
merged = OrderedDict(PLATFORMS)
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if entry.name not in merged:
|
||||
merged[entry.name] = PlatformInfo(
|
||||
label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label,
|
||||
default_toolset=f"hermes-{entry.name}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return merged
|
||||
|
||||
+91
-7
@@ -37,6 +37,7 @@ import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
@@ -45,6 +46,20 @@ from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import env_var_enabled
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
def get_bundled_plugins_dir() -> Path:
|
||||
"""Locate the bundled ``plugins/`` directory.
|
||||
|
||||
Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged
|
||||
installs) so read-only store paths are consulted first. Falls back to
|
||||
the in-repo path used during development.
|
||||
"""
|
||||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return Path(__file__).resolve().parent.parent / "plugins"
|
||||
|
||||
try:
|
||||
import yaml
|
||||
@@ -115,7 +130,7 @@ def _get_disabled_plugins() -> set:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
disabled = cfg_get(config, "plugins", "disabled", default=[])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -155,7 +170,7 @@ def _get_enabled_plugins() -> Optional[set]:
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"}
|
||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -181,6 +196,11 @@ class PluginManifest:
|
||||
# Selection via ``<category>.provider`` config key; the
|
||||
# category's own discovery system handles loading and the
|
||||
# general scanner skips these.
|
||||
# ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled
|
||||
# platform plugins auto-load so every shipped platform is
|
||||
# available out of the box; user-installed platform plugins
|
||||
# in ~/.hermes/plugins/ still gated by ``plugins.enabled``
|
||||
# (untrusted code).
|
||||
kind: str = "standalone"
|
||||
# Registry key — path-derived, used by ``plugins.enabled``/``disabled``
|
||||
# lookups and by ``hermes plugins list``. For a flat plugin at
|
||||
@@ -444,6 +464,62 @@ class PluginContext:
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- platform adapter registration ---------------------------------------
|
||||
|
||||
def register_platform(
|
||||
self,
|
||||
name: str,
|
||||
label: str,
|
||||
adapter_factory: Callable,
|
||||
check_fn: Callable,
|
||||
validate_config: Callable | None = None,
|
||||
required_env: list | None = None,
|
||||
install_hint: str = "",
|
||||
**entry_kwargs: Any,
|
||||
) -> None:
|
||||
"""Register a gateway platform adapter.
|
||||
|
||||
The adapter_factory receives a ``PlatformConfig`` and returns a
|
||||
``BasePlatformAdapter`` subclass instance. The gateway calls
|
||||
``check_fn()`` before instantiation to verify dependencies.
|
||||
|
||||
Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g.
|
||||
``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``).
|
||||
Unknown keys raise TypeError from the dataclass constructor.
|
||||
|
||||
Example::
|
||||
|
||||
ctx.register_platform(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=lambda: True,
|
||||
emoji="💬",
|
||||
setup_fn=irc_interactive_setup,
|
||||
)
|
||||
"""
|
||||
from gateway.platform_registry import platform_registry, PlatformEntry
|
||||
|
||||
entry_kwargs.setdefault("plugin_name", self.manifest.name)
|
||||
entry = PlatformEntry(
|
||||
name=name,
|
||||
label=label,
|
||||
adapter_factory=adapter_factory,
|
||||
check_fn=check_fn,
|
||||
validate_config=validate_config,
|
||||
required_env=required_env or [],
|
||||
install_hint=install_hint,
|
||||
source="plugin",
|
||||
**entry_kwargs,
|
||||
)
|
||||
platform_registry.register(entry)
|
||||
self._manager._plugin_platform_names.add(name)
|
||||
logger.debug(
|
||||
"Plugin %s registered platform: %s",
|
||||
self.manifest.name,
|
||||
name,
|
||||
)
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
@@ -522,6 +598,7 @@ class PluginManager:
|
||||
self._plugins: Dict[str, LoadedPlugin] = {}
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._plugin_platform_names: Set[str] = set()
|
||||
self._cli_commands: Dict[str, dict] = {}
|
||||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||||
self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins
|
||||
@@ -564,16 +641,19 @@ class PluginManager:
|
||||
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
|
||||
#
|
||||
# ``memory/`` and ``context_engine/`` are skipped at the top level —
|
||||
# they have their own discovery systems. Porting those to the
|
||||
# category-namespace ``kind: exclusive`` model is a future PR.
|
||||
repo_plugins = Path(__file__).resolve().parent.parent / "plugins"
|
||||
# they have their own discovery systems. ``platforms/`` is a category
|
||||
# holding platform adapters (scanned one level deeper below).
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
manifests.extend(
|
||||
self._scan_directory(
|
||||
repo_plugins,
|
||||
source="bundled",
|
||||
skip_names={"memory", "context_engine"},
|
||||
skip_names={"memory", "context_engine", "platforms"},
|
||||
)
|
||||
)
|
||||
manifests.extend(
|
||||
self._scan_directory(repo_plugins / "platforms", source="bundled")
|
||||
)
|
||||
|
||||
# 2. User plugins (~/.hermes/plugins/)
|
||||
user_dir = get_hermes_home() / "plugins"
|
||||
@@ -630,7 +710,11 @@ class PluginManager:
|
||||
# just work. Selection among them (e.g. which image_gen backend
|
||||
# services calls) is driven by ``<category>.provider`` config,
|
||||
# enforced by the tool wrapper.
|
||||
if manifest.kind == "backend" and manifest.source == "bundled":
|
||||
#
|
||||
# Bundled platform plugins (gateway adapters like IRC) auto-load
|
||||
# for the same reason: every platform Hermes ships must be
|
||||
# available out of the box without the user having to opt in.
|
||||
if manifest.source == "bundled" and manifest.kind in ("backend", "platform"):
|
||||
self._load_plugin(manifest)
|
||||
continue
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -519,7 +520,7 @@ def _get_disabled_set() -> set:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
disabled = cfg_get(config, "plugins", "disabled", default=[])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -629,10 +630,9 @@ def _plugin_exists(name: str) -> bool:
|
||||
manifest = _read_manifest(child)
|
||||
if manifest.get("name") == name:
|
||||
return True
|
||||
# Bundled: <repo>/plugins/<name>/
|
||||
from pathlib import Path as _P
|
||||
import hermes_cli
|
||||
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
if repo_plugins.is_dir():
|
||||
candidate = repo_plugins / name
|
||||
if candidate.is_dir() and (
|
||||
@@ -659,8 +659,8 @@ def _discover_all_plugins() -> list:
|
||||
seen: dict = {} # name -> (name, version, description, source, path)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
import hermes_cli
|
||||
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
@@ -763,7 +763,7 @@ def _get_current_memory_provider() -> str:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
return config.get("memory", {}).get("provider", "") or ""
|
||||
return cfg_get(config, "memory", "provider", default="") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@@ -773,7 +773,7 @@ def _get_current_context_engine() -> str:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
return config.get("context", {}).get("engine", "compressor") or "compressor"
|
||||
return cfg_get(config, "context", "engine", default="compressor") or "compressor"
|
||||
except Exception:
|
||||
return "compressor"
|
||||
|
||||
|
||||
+29
-2
@@ -71,6 +71,29 @@ _CLONE_ALL_STRIP = [
|
||||
"processes.json",
|
||||
]
|
||||
|
||||
|
||||
def _clone_all_copytree_ignore(source_dir: Path):
|
||||
"""Ignore ``profiles/`` at the root of *source_dir* only.
|
||||
|
||||
``~/.hermes`` contains ``profiles/<name>/`` for sibling named profiles.
|
||||
``shutil.copytree`` would otherwise duplicate that entire tree inside the
|
||||
new profile (recursive ``.../profiles/.../profiles/...``). Export already
|
||||
excludes ``profiles`` via ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` — match that
|
||||
behavior for ``--clone-all``.
|
||||
"""
|
||||
source_resolved = source_dir.resolve()
|
||||
|
||||
def _ignore(directory: str, names: List[str]) -> List[str]:
|
||||
try:
|
||||
if Path(directory).resolve() == source_resolved:
|
||||
return [n for n in names if n == "profiles"]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return []
|
||||
|
||||
return _ignore
|
||||
|
||||
|
||||
# Directories/files to exclude when exporting the default (~/.hermes) profile.
|
||||
# The default profile contains infrastructure (repo checkout, worktrees, DBs,
|
||||
# caches, binaries) that named profiles don't have. We exclude those so the
|
||||
@@ -424,8 +447,12 @@ def create_profile(
|
||||
)
|
||||
|
||||
if clone_all and source_dir:
|
||||
# Full copy of source profile
|
||||
shutil.copytree(source_dir, profile_dir)
|
||||
# Full copy of source profile (exclude sibling ~/.hermes/profiles/)
|
||||
shutil.copytree(
|
||||
source_dir,
|
||||
profile_dir,
|
||||
ignore=_clone_all_copytree_ignore(source_dir),
|
||||
)
|
||||
# Strip runtime files
|
||||
for stale in _CLONE_ALL_STRIP:
|
||||
(profile_dir / stale).unlink(missing_ok=True)
|
||||
|
||||
@@ -111,6 +111,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="anthropic_messages",
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
),
|
||||
"minimax-oauth": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
auth_type="oauth_external",
|
||||
base_url_override="https://api.minimax.io/anthropic",
|
||||
),
|
||||
"minimax-cn": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
base_url_env_var="MINIMAX_CN_BASE_URL",
|
||||
@@ -580,6 +585,12 @@ def resolve_custom_provider(
|
||||
if not requested:
|
||||
return None
|
||||
|
||||
# If the stored provider is the bare string "custom" (corrupt state
|
||||
# from a prior model-switch bug), fall back to the first custom
|
||||
# provider entry so existing configs self-heal. (GH #17478)
|
||||
bare_custom_fallback = requested == "custom"
|
||||
first_valid = None
|
||||
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -594,6 +605,10 @@ def resolve_custom_provider(
|
||||
if not display_name or not api_url:
|
||||
continue
|
||||
|
||||
# Stash the first valid entry for bare-"custom" fallback
|
||||
if first_valid is None:
|
||||
first_valid = (display_name, api_url)
|
||||
|
||||
slug = custom_provider_slug(display_name)
|
||||
if requested not in {display_name.lower(), slug}:
|
||||
continue
|
||||
@@ -609,6 +624,21 @@ def resolve_custom_provider(
|
||||
source="user-config",
|
||||
)
|
||||
|
||||
# Self-heal: bare "custom" matched nothing — return first valid entry
|
||||
if bare_custom_fallback and first_valid:
|
||||
dname, aurl = first_valid
|
||||
slug = custom_provider_slug(dname)
|
||||
return ProviderDef(
|
||||
id=slug,
|
||||
name=dname,
|
||||
transport="openai_chat",
|
||||
api_key_env_vars=(),
|
||||
base_url=aurl,
|
||||
is_aggregator=False,
|
||||
auth_type="api_key",
|
||||
source="user-config",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Unified self-relaunch for Hermes CLI.
|
||||
|
||||
Preserves critical flags (--tui, --dev, --profile, --model, etc.) across
|
||||
process replacement so that ``hermes sessions browse`` or post-setup relaunch
|
||||
doesn't silently drop the user's UI mode or other preferences.
|
||||
|
||||
Also works when ``hermes`` is not on PATH (e.g. ``nix run`` or ``python -m``).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_cli._parser import (
|
||||
PRE_ARGPARSE_INHERITED_FLAGS,
|
||||
build_top_level_parser,
|
||||
)
|
||||
|
||||
|
||||
def _build_inherited_flag_table() -> list[tuple[str, bool]]:
|
||||
"""Build the ``(option_string, takes_value)`` table of flags that must
|
||||
survive a self-relaunch, by introspecting the real parser used by
|
||||
``hermes`` itself.
|
||||
|
||||
A flag participates if its argparse Action carries
|
||||
``inherit_on_relaunch = True`` — set by ``_parser._inherited_flag``.
|
||||
"""
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
|
||||
table: list[tuple[str, bool]] = []
|
||||
seen: set[tuple[str, bool]] = set()
|
||||
for p in (parser, chat_parser):
|
||||
for action in p._actions:
|
||||
if not action.option_strings:
|
||||
continue # positional / no flag form
|
||||
if not getattr(action, "inherit_on_relaunch", False):
|
||||
continue
|
||||
takes_value = action.nargs != 0 # store_true/false set nargs=0
|
||||
for opt in action.option_strings:
|
||||
key = (opt, takes_value)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
table.append(key)
|
||||
|
||||
table.extend(PRE_ARGPARSE_INHERITED_FLAGS)
|
||||
return table
|
||||
|
||||
|
||||
_INHERITED_FLAGS_TABLE = _build_inherited_flag_table()
|
||||
|
||||
|
||||
def _extract_inherited_flags(argv: Sequence[str]) -> list[str]:
|
||||
"""Pull out flags that should carry over into a self-relaunched hermes."""
|
||||
flags: list[str] = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if "=" in arg:
|
||||
key = arg.split("=", 1)[0]
|
||||
for flag, _ in _INHERITED_FLAGS_TABLE:
|
||||
if key == flag:
|
||||
flags.append(arg)
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
|
||||
for flag, takes_value in _INHERITED_FLAGS_TABLE:
|
||||
if arg == flag:
|
||||
flags.append(arg)
|
||||
if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
|
||||
flags.append(argv[i + 1])
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
return flags
|
||||
|
||||
|
||||
def resolve_hermes_bin() -> Optional[str]:
|
||||
"""Find the hermes entry point.
|
||||
|
||||
Priority:
|
||||
1. ``sys.argv[0]`` if it resolves to a real executable.
|
||||
2. ``shutil.which("hermes")`` on PATH.
|
||||
3. ``None`` → caller should fall back to ``python -m hermes_cli.main``.
|
||||
"""
|
||||
argv0 = sys.argv[0]
|
||||
|
||||
# Absolute path to an executable (covers nix store, venv wrappers, etc.)
|
||||
if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK):
|
||||
return argv0
|
||||
|
||||
# Relative path — resolve against CWD
|
||||
if not argv0.startswith("-") and os.path.isfile(argv0):
|
||||
abs_path = os.path.abspath(argv0)
|
||||
if os.access(abs_path, os.X_OK):
|
||||
return abs_path
|
||||
|
||||
# PATH lookup
|
||||
path_bin = shutil.which("hermes")
|
||||
if path_bin:
|
||||
return path_bin
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_relaunch_argv(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> list[str]:
|
||||
"""Construct an argv list for replacing the current process with hermes.
|
||||
|
||||
Args:
|
||||
extra_args: Arguments to append (e.g. ``["--resume", id]``).
|
||||
preserve_inherited: Whether to carry over UI / behaviour flags
|
||||
tagged with ``inherit_on_relaunch`` in the parser.
|
||||
original_argv: The original argv to scan for flags (defaults to
|
||||
``sys.argv[1:]``).
|
||||
"""
|
||||
bin_path = resolve_hermes_bin()
|
||||
|
||||
if bin_path:
|
||||
argv = [bin_path]
|
||||
else:
|
||||
argv = [sys.executable, "-m", "hermes_cli.main"]
|
||||
|
||||
src = list(original_argv) if original_argv is not None else list(sys.argv[1:])
|
||||
|
||||
if preserve_inherited:
|
||||
argv.extend(_extract_inherited_flags(src))
|
||||
|
||||
argv.extend(extra_args)
|
||||
return argv
|
||||
|
||||
|
||||
def relaunch(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Replace the current process with a fresh hermes invocation."""
|
||||
new_argv = build_relaunch_argv(
|
||||
extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv
|
||||
)
|
||||
os.execvp(new_argv[0], new_argv)
|
||||
@@ -391,7 +391,14 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
"api_key": resolved_api_key,
|
||||
"model": entry.get("default_model", ""),
|
||||
}
|
||||
api_mode = _parse_api_mode(entry.get("api_mode"))
|
||||
# The v11→v12 migration writes the API mode under the new
|
||||
# ``transport`` field, but hand-edited configs may still
|
||||
# use the legacy ``api_mode`` spelling. Accept both —
|
||||
# the runtime normaliser ``_normalize_custom_provider_entry``
|
||||
# already does, so without this lift every migrated config
|
||||
# silently downgrades codex_responses / anthropic_messages
|
||||
# providers to chat_completions in the resolved runtime.
|
||||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
return result
|
||||
@@ -409,7 +416,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
"api_key": resolved_api_key,
|
||||
"model": entry.get("default_model", ""),
|
||||
}
|
||||
api_mode = _parse_api_mode(entry.get("api_mode"))
|
||||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
return result
|
||||
@@ -1070,6 +1077,20 @@ def resolve_runtime_provider(
|
||||
logger.info("Qwen OAuth credentials failed; "
|
||||
"falling through to next provider.")
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
if pconfig and pconfig.auth_type == "oauth_minimax":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": "anthropic_messages",
|
||||
"base_url": creds["base_url"],
|
||||
"api_key": creds["api_key"],
|
||||
"source": creds.get("source", "oauth"),
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
if provider == "google-gemini-cli":
|
||||
try:
|
||||
creds = resolve_gemini_oauth_runtime_credentials()
|
||||
|
||||
+189
-175
@@ -12,6 +12,7 @@ Config files are stored in ~/.hermes/ for easy access.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -131,6 +132,7 @@ def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None:
|
||||
|
||||
# Import config helpers
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
DEFAULT_CONFIG,
|
||||
get_hermes_home,
|
||||
get_config_path,
|
||||
@@ -138,6 +140,7 @@ from hermes_cli.config import (
|
||||
load_config,
|
||||
save_config,
|
||||
save_env_value,
|
||||
remove_env_value,
|
||||
get_env_value,
|
||||
ensure_hermes_home,
|
||||
)
|
||||
@@ -441,7 +444,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
tool_status.append(("Image Generation", False, "FAL_KEY or OPENAI_API_KEY"))
|
||||
|
||||
# TTS — show configured provider
|
||||
tts_provider = config.get("tts", {}).get("provider", "edge")
|
||||
tts_provider = cfg_get(config, "tts", "provider", default="edge")
|
||||
if subscription_features.tts.managed_by_nous:
|
||||
tool_status.append(("Text-to-Speech (OpenAI via Nous subscription)", True, None))
|
||||
elif tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
|
||||
@@ -480,7 +483,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
|
||||
if subscription_features.modal.managed_by_nous:
|
||||
tool_status.append(("Modal Execution (Nous subscription)", True, None))
|
||||
elif config.get("terminal", {}).get("backend") == "modal":
|
||||
elif cfg_get(config, "terminal", "backend") == "modal":
|
||||
if subscription_features.modal.direct_override:
|
||||
tool_status.append(("Modal Execution (direct Modal)", True, None))
|
||||
else:
|
||||
@@ -654,6 +657,102 @@ def _prompt_container_resources(config: dict):
|
||||
pass
|
||||
|
||||
|
||||
def _prompt_vercel_sandbox_settings(config: dict):
|
||||
"""Prompt for Vercel Sandbox settings without exposing unsupported disk sizing."""
|
||||
terminal = config.setdefault("terminal", {})
|
||||
|
||||
print()
|
||||
print_info("Vercel Sandbox settings:")
|
||||
print_info(" Filesystem persistence uses Vercel snapshots.")
|
||||
print_info(" Snapshots restore files only; live processes do not continue after sandbox recreation.")
|
||||
|
||||
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
|
||||
|
||||
current_runtime = terminal.get("vercel_runtime") or "node24"
|
||||
supported_label = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
|
||||
runtime = prompt(f" Runtime ({supported_label})", current_runtime).strip() or current_runtime
|
||||
if runtime not in _SUPPORTED_VERCEL_RUNTIMES:
|
||||
print_warning(f"Unsupported Vercel runtime '{runtime}', keeping {current_runtime}.")
|
||||
runtime = current_runtime if current_runtime in _SUPPORTED_VERCEL_RUNTIMES else "node24"
|
||||
terminal["vercel_runtime"] = runtime
|
||||
save_env_value("TERMINAL_VERCEL_RUNTIME", runtime)
|
||||
|
||||
current_persist = terminal.get("container_persistent", True)
|
||||
persist_label = "yes" if current_persist else "no"
|
||||
terminal["container_persistent"] = prompt(
|
||||
" Persist filesystem with snapshots? (yes/no)", persist_label
|
||||
).lower() in ("yes", "true", "y", "1")
|
||||
|
||||
current_cpu = terminal.get("container_cpu", 1)
|
||||
cpu_str = prompt(" CPU cores", str(current_cpu))
|
||||
try:
|
||||
terminal["container_cpu"] = float(cpu_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
current_mem = terminal.get("container_memory", 5120)
|
||||
mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem))
|
||||
try:
|
||||
terminal["container_memory"] = int(mem_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if terminal.get("container_disk", 51200) not in (0, 51200):
|
||||
print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.")
|
||||
terminal["container_disk"] = 51200
|
||||
|
||||
print()
|
||||
print_info("Vercel authentication:")
|
||||
print_info(" Use a long-lived Vercel access token plus project/team IDs.")
|
||||
linked_project = _read_nearest_vercel_project()
|
||||
if linked_project:
|
||||
print_info(" Found defaults in nearest .vercel/project.json.")
|
||||
|
||||
remove_env_value("VERCEL_OIDC_TOKEN")
|
||||
token = prompt(" Vercel access token", get_env_value("VERCEL_TOKEN") or "", password=True)
|
||||
project = prompt(
|
||||
" Vercel project ID",
|
||||
get_env_value("VERCEL_PROJECT_ID") or linked_project.get("projectId", ""),
|
||||
)
|
||||
team = prompt(
|
||||
" Vercel team ID",
|
||||
get_env_value("VERCEL_TEAM_ID") or linked_project.get("orgId", ""),
|
||||
)
|
||||
if token:
|
||||
save_env_value("VERCEL_TOKEN", token)
|
||||
if project:
|
||||
save_env_value("VERCEL_PROJECT_ID", project)
|
||||
if team:
|
||||
save_env_value("VERCEL_TEAM_ID", team)
|
||||
|
||||
|
||||
def _read_nearest_vercel_project(start: Path | None = None) -> dict[str, str]:
|
||||
"""Read project/team defaults from the nearest Vercel link file."""
|
||||
current = (start or Path.cwd()).resolve()
|
||||
if current.is_file():
|
||||
current = current.parent
|
||||
|
||||
for directory in (current, *current.parents):
|
||||
project_file = directory / ".vercel" / "project.json"
|
||||
if not project_file.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(project_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in {
|
||||
"projectId": data.get("projectId"),
|
||||
"orgId": data.get("orgId"),
|
||||
}.items()
|
||||
if isinstance(value, str) and value.strip()
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
# Tool categories and provider config are now in tools_config.py (shared
|
||||
# between `hermes tools` and `hermes setup tools`).
|
||||
|
||||
@@ -1179,7 +1278,7 @@ def setup_terminal_backend(config: dict):
|
||||
print_info(f" Guide: {_DOCS_BASE}/developer-guide/environments")
|
||||
print()
|
||||
|
||||
current_backend = config.get("terminal", {}).get("backend", "local")
|
||||
current_backend = cfg_get(config, "terminal", "backend", default="local")
|
||||
is_linux = _platform.system() == "Linux"
|
||||
|
||||
# Build backend choices with descriptions
|
||||
@@ -1189,11 +1288,12 @@ def setup_terminal_backend(config: dict):
|
||||
"Modal - serverless cloud sandbox",
|
||||
"SSH - run on a remote machine",
|
||||
"Daytona - persistent cloud development environment",
|
||||
"Vercel Sandbox - cloud microVM with snapshot filesystem persistence",
|
||||
]
|
||||
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
|
||||
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
|
||||
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox"}
|
||||
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5}
|
||||
|
||||
next_idx = 5
|
||||
next_idx = 6
|
||||
if is_linux:
|
||||
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
|
||||
idx_to_backend[next_idx] = "singularity"
|
||||
@@ -1228,7 +1328,7 @@ def setup_terminal_backend(config: dict):
|
||||
print_info(
|
||||
" the agent starts. CLI mode always starts in the current directory."
|
||||
)
|
||||
current_cwd = config.get("terminal", {}).get("cwd", "")
|
||||
current_cwd = cfg_get(config, "terminal", "cwd", default="")
|
||||
cwd = prompt(" Messaging working directory", current_cwd or str(Path.home()))
|
||||
if cwd:
|
||||
config["terminal"]["cwd"] = cwd
|
||||
@@ -1259,9 +1359,7 @@ def setup_terminal_backend(config: dict):
|
||||
print_info(f"Docker found: {docker_bin}")
|
||||
|
||||
# Docker image
|
||||
current_image = config.get("terminal", {}).get(
|
||||
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
current_image = cfg_get(config, "terminal", "docker_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Docker image", current_image)
|
||||
config["terminal"]["docker_image"] = image
|
||||
save_env_value("TERMINAL_DOCKER_IMAGE", image)
|
||||
@@ -1281,9 +1379,7 @@ def setup_terminal_backend(config: dict):
|
||||
else:
|
||||
print_info(f"Found: {sing_bin}")
|
||||
|
||||
current_image = config.get("terminal", {}).get(
|
||||
"singularity_image", "docker://nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
current_image = cfg_get(config, "terminal", "singularity_image", default="docker://nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Container image", current_image)
|
||||
config["terminal"]["singularity_image"] = image
|
||||
save_env_value("TERMINAL_SINGULARITY_IMAGE", image)
|
||||
@@ -1302,7 +1398,7 @@ def setup_terminal_backend(config: dict):
|
||||
get_nous_subscription_features(config).nous_auth_present
|
||||
and is_managed_tool_gateway_ready("modal")
|
||||
)
|
||||
modal_mode = normalize_modal_mode(config.get("terminal", {}).get("modal_mode"))
|
||||
modal_mode = normalize_modal_mode(cfg_get(config, "terminal", "modal_mode"))
|
||||
use_managed_modal = False
|
||||
if managed_modal_available:
|
||||
modal_choices = [
|
||||
@@ -1439,15 +1535,46 @@ def setup_terminal_backend(config: dict):
|
||||
print_success(" Configured")
|
||||
|
||||
# Daytona image
|
||||
current_image = config.get("terminal", {}).get(
|
||||
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
current_image = cfg_get(config, "terminal", "daytona_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Sandbox image", current_image)
|
||||
config["terminal"]["daytona_image"] = image
|
||||
save_env_value("TERMINAL_DAYTONA_IMAGE", image)
|
||||
|
||||
_prompt_container_resources(config)
|
||||
|
||||
elif selected_backend == "vercel_sandbox":
|
||||
print_success("Terminal backend: Vercel Sandbox")
|
||||
print_info("Cloud microVM sandboxes with snapshot-backed filesystem persistence.")
|
||||
print_info("Requires the optional SDK: pip install 'hermes-agent[vercel]'")
|
||||
|
||||
try:
|
||||
__import__("vercel")
|
||||
except ImportError:
|
||||
print_info("Installing vercel SDK...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, "vercel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "vercel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success("vercel SDK installed")
|
||||
else:
|
||||
print_warning("Install failed — run manually: pip install 'hermes-agent[vercel]'")
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
|
||||
_prompt_vercel_sandbox_settings(config)
|
||||
|
||||
elif selected_backend == "ssh":
|
||||
print_success("Terminal backend: SSH")
|
||||
print_info("Run commands on a remote machine via SSH.")
|
||||
@@ -1501,6 +1628,8 @@ def setup_terminal_backend(config: dict):
|
||||
save_env_value("TERMINAL_ENV", selected_backend)
|
||||
if selected_backend == "modal":
|
||||
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
|
||||
if selected_backend == "vercel_sandbox":
|
||||
save_env_value("TERMINAL_VERCEL_RUNTIME", config["terminal"].get("vercel_runtime", "node24"))
|
||||
save_config(config)
|
||||
print()
|
||||
print_success(f"Terminal backend set to: {selected_backend}")
|
||||
@@ -1545,7 +1674,7 @@ def setup_agent_settings(config: dict):
|
||||
|
||||
# ── Max Iterations ──
|
||||
current_max = get_env_value("HERMES_MAX_ITERATIONS") or str(
|
||||
config.get("agent", {}).get("max_turns", 90)
|
||||
cfg_get(config, "agent", "max_turns", default=90)
|
||||
)
|
||||
print_info("Maximum tool-calling iterations per conversation.")
|
||||
print_info("Higher = more complex tasks, but costs more tokens.")
|
||||
@@ -1573,7 +1702,7 @@ def setup_agent_settings(config: dict):
|
||||
print_info(" all — Show every tool call with a short preview")
|
||||
print_info(" verbose — Full args, results, and debug logs")
|
||||
|
||||
current_mode = config.get("display", {}).get("tool_progress", "all")
|
||||
current_mode = cfg_get(config, "display", "tool_progress", default="all")
|
||||
mode = prompt("Tool progress mode", current_mode)
|
||||
if mode.lower() in ("off", "new", "all", "verbose"):
|
||||
if "display" not in config:
|
||||
@@ -1593,7 +1722,7 @@ def setup_agent_settings(config: dict):
|
||||
|
||||
config.setdefault("compression", {})["enabled"] = True
|
||||
|
||||
current_threshold = config.get("compression", {}).get("threshold", 0.50)
|
||||
current_threshold = cfg_get(config, "compression", "threshold", default=0.50)
|
||||
threshold_str = prompt("Compression threshold (0.5-0.95)", str(current_threshold))
|
||||
try:
|
||||
threshold = float(threshold_str)
|
||||
@@ -2075,80 +2204,7 @@ def _setup_mattermost():
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _setup_whatsapp():
|
||||
"""Configure WhatsApp bridge."""
|
||||
print_header("WhatsApp")
|
||||
existing = get_env_value("WHATSAPP_ENABLED")
|
||||
if existing:
|
||||
print_info("WhatsApp: already enabled")
|
||||
return
|
||||
|
||||
print_info("WhatsApp connects via a built-in bridge (Baileys).")
|
||||
print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
|
||||
print()
|
||||
if prompt_yes_no("Enable WhatsApp now?", True):
|
||||
save_env_value("WHATSAPP_ENABLED", "true")
|
||||
print_success("WhatsApp enabled")
|
||||
print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
|
||||
print_info("or personal self-chat) and pair via QR code.")
|
||||
|
||||
|
||||
def _setup_weixin():
|
||||
"""Configure Weixin (personal WeChat) via iLink Bot API QR login."""
|
||||
from hermes_cli.gateway import _setup_weixin as _gateway_setup_weixin
|
||||
_gateway_setup_weixin()
|
||||
|
||||
|
||||
def _setup_signal():
|
||||
"""Configure Signal via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_signal as _gateway_setup_signal
|
||||
_gateway_setup_signal()
|
||||
|
||||
|
||||
def _setup_email():
|
||||
"""Configure Email via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_email as _gateway_setup_email
|
||||
_gateway_setup_email()
|
||||
|
||||
|
||||
def _setup_sms():
|
||||
"""Configure SMS (Twilio) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_sms as _gateway_setup_sms
|
||||
_gateway_setup_sms()
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_dingtalk as _gateway_setup_dingtalk
|
||||
_gateway_setup_dingtalk()
|
||||
|
||||
|
||||
def _setup_feishu():
|
||||
"""Configure Feishu / Lark via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_feishu as _gateway_setup_feishu
|
||||
_gateway_setup_feishu()
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao
|
||||
_gateway_setup_yuanbao()
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom
|
||||
_gateway_setup_wecom()
|
||||
|
||||
|
||||
def _setup_wecom_callback():
|
||||
"""Configure WeCom Callback (self-built app) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom_callback as _gw_setup
|
||||
_gw_setup()
|
||||
|
||||
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
@@ -2266,49 +2322,27 @@ def _setup_webhooks():
|
||||
print_info(" https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes")
|
||||
print()
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
# Platform registry for the gateway checklist
|
||||
_GATEWAY_PLATFORMS = [
|
||||
("Telegram", "TELEGRAM_BOT_TOKEN", _setup_telegram),
|
||||
("Discord", "DISCORD_BOT_TOKEN", _setup_discord),
|
||||
("Slack", "SLACK_BOT_TOKEN", _setup_slack),
|
||||
("Signal", "SIGNAL_HTTP_URL", _setup_signal),
|
||||
("Email", "EMAIL_ADDRESS", _setup_email),
|
||||
("SMS (Twilio)", "TWILIO_ACCOUNT_SID", _setup_sms),
|
||||
("Matrix", "MATRIX_ACCESS_TOKEN", _setup_matrix),
|
||||
("Mattermost", "MATTERMOST_TOKEN", _setup_mattermost),
|
||||
("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp),
|
||||
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
|
||||
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
|
||||
("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao),
|
||||
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
|
||||
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
|
||||
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
|
||||
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles),
|
||||
("QQ Bot", "QQ_APP_ID", _setup_qqbot),
|
||||
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
|
||||
]
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
def setup_gateway(config: dict):
|
||||
"""Configure messaging platform integrations."""
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status, _configure_platform
|
||||
|
||||
print_header("Messaging Platforms")
|
||||
print_info("Connect to messaging platforms to chat with Hermes from anywhere.")
|
||||
print_info("Toggle with Space, confirm with Enter.")
|
||||
print()
|
||||
|
||||
# Build checklist items, pre-selecting already-configured platforms
|
||||
platforms = _all_platforms()
|
||||
|
||||
# Build checklist, pre-selecting already-configured platforms.
|
||||
items = []
|
||||
pre_selected = []
|
||||
for i, (name, env_var, _func) in enumerate(_GATEWAY_PLATFORMS):
|
||||
# Matrix has two possible env vars
|
||||
is_configured = bool(get_env_value(env_var))
|
||||
if name == "Matrix" and not is_configured:
|
||||
is_configured = bool(get_env_value("MATRIX_PASSWORD"))
|
||||
label = f"{name} (configured)" if is_configured else name
|
||||
items.append(label)
|
||||
if is_configured:
|
||||
for i, plat in enumerate(platforms):
|
||||
status = _platform_status(plat)
|
||||
items.append(f"{plat['emoji']} {plat['label']} ({status})")
|
||||
if status == "configured":
|
||||
pre_selected.append(i)
|
||||
|
||||
selected = prompt_checklist("Select platforms to configure:", items, pre_selected)
|
||||
@@ -2318,28 +2352,22 @@ def setup_gateway(config: dict):
|
||||
return
|
||||
|
||||
for idx in selected:
|
||||
name, _env_var, setup_func = _GATEWAY_PLATFORMS[idx]
|
||||
setup_func()
|
||||
_configure_platform(platforms[idx])
|
||||
|
||||
# ── Gateway Service Setup ──
|
||||
any_messaging = (
|
||||
get_env_value("TELEGRAM_BOT_TOKEN")
|
||||
or get_env_value("DISCORD_BOT_TOKEN")
|
||||
or get_env_value("SLACK_BOT_TOKEN")
|
||||
or get_env_value("SIGNAL_HTTP_URL")
|
||||
or get_env_value("EMAIL_ADDRESS")
|
||||
or get_env_value("TWILIO_ACCOUNT_SID")
|
||||
or get_env_value("MATTERMOST_TOKEN")
|
||||
or get_env_value("MATRIX_ACCESS_TOKEN")
|
||||
or get_env_value("MATRIX_PASSWORD")
|
||||
or get_env_value("WHATSAPP_ENABLED")
|
||||
or get_env_value("DINGTALK_CLIENT_ID")
|
||||
or get_env_value("FEISHU_APP_ID")
|
||||
or get_env_value("WECOM_BOT_ID")
|
||||
or get_env_value("WEIXIN_ACCOUNT_ID")
|
||||
or get_env_value("BLUEBUBBLES_SERVER_URL")
|
||||
or get_env_value("QQ_APP_ID")
|
||||
or get_env_value("WEBHOOK_ENABLED")
|
||||
# Count any platform (built-in or plugin) the user configured during this
|
||||
# setup pass — reuses ``_platform_status`` so plugin platforms like IRC
|
||||
# are picked up without another hard-coded env-var list.
|
||||
def _is_progress(status: str) -> bool:
|
||||
s = status.lower()
|
||||
return not (
|
||||
s == "not configured"
|
||||
or s.startswith("partially")
|
||||
or s.startswith("plugin disabled")
|
||||
)
|
||||
|
||||
any_messaging = any(
|
||||
_is_progress(_platform_status(p)) for p in _all_platforms()
|
||||
)
|
||||
if any_messaging:
|
||||
print()
|
||||
@@ -2601,21 +2629,26 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]
|
||||
return "configured"
|
||||
|
||||
elif section_key == "terminal":
|
||||
backend = config.get("terminal", {}).get("backend", "local")
|
||||
backend = cfg_get(config, "terminal", "backend", default="local")
|
||||
return f"backend: {backend}"
|
||||
|
||||
elif section_key == "agent":
|
||||
max_turns = config.get("agent", {}).get("max_turns", 90)
|
||||
max_turns = cfg_get(config, "agent", "max_turns", default=90)
|
||||
return f"max turns: {max_turns}"
|
||||
|
||||
elif section_key == "gateway":
|
||||
platforms = [
|
||||
_gateway_platform_short_label(label)
|
||||
for label, env_var, _ in _GATEWAY_PLATFORMS
|
||||
if get_env_value(env_var)
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status
|
||||
# Count any non-empty status other than the "not configured" sentinel —
|
||||
# platforms like WhatsApp ("enabled, not paired"), Matrix ("configured
|
||||
# + E2EE"), and Signal ("partially configured") all indicate the user
|
||||
# has already started setup and we shouldn't force the section to rerun.
|
||||
configured = [
|
||||
_gateway_platform_short_label(plat["label"])
|
||||
for plat in _all_platforms()
|
||||
if _platform_status(plat) and _platform_status(plat) != "not configured"
|
||||
]
|
||||
if platforms:
|
||||
return ", ".join(platforms)
|
||||
if configured:
|
||||
return ", ".join(configured)
|
||||
return None # No platforms configured — section must run
|
||||
|
||||
elif section_key == "tools":
|
||||
@@ -3120,33 +3153,14 @@ def run_setup_wizard(args):
|
||||
_offer_launch_chat()
|
||||
|
||||
|
||||
def _resolve_hermes_chat_argv() -> Optional[list[str]]:
|
||||
"""Resolve argv for launching ``hermes chat`` in a fresh process."""
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
return [hermes_bin, "chat"]
|
||||
|
||||
try:
|
||||
if importlib.util.find_spec("hermes_cli") is not None:
|
||||
return [sys.executable, "-m", "hermes_cli.main", "chat"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _offer_launch_chat():
|
||||
"""Prompt the user to jump straight into chat after setup."""
|
||||
print()
|
||||
if not prompt_yes_no("Launch hermes chat now?", True):
|
||||
return
|
||||
|
||||
chat_argv = _resolve_hermes_chat_argv()
|
||||
if not chat_argv:
|
||||
print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.")
|
||||
return
|
||||
|
||||
os.execvp(chat_argv[0], chat_argv)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["chat"])
|
||||
|
||||
|
||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
|
||||
@@ -13,7 +13,7 @@ Config stored in ~/.hermes/config.yaml under:
|
||||
"""
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.config import cfg_get, load_config, save_config
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.platforms import PLATFORMS as _PLATFORMS
|
||||
|
||||
@@ -30,7 +30,7 @@ def get_disabled_skills(config: dict, platform: Optional[str] = None) -> Set[str
|
||||
global_disabled = set(skills_cfg.get("disabled", []))
|
||||
if platform is None:
|
||||
return global_disabled
|
||||
platform_disabled = skills_cfg.get("platform_disabled", {}).get(platform)
|
||||
platform_disabled = cfg_get(skills_cfg, "platform_disabled", platform)
|
||||
if platform_disabled is None:
|
||||
return global_disabled
|
||||
return set(platform_disabled)
|
||||
|
||||
@@ -103,10 +103,6 @@ BUILT-IN SKINS
|
||||
- ``slate`` — Cool blue developer-focused theme
|
||||
- ``daylight`` — Light background theme with dark text and blue accents
|
||||
- ``warm-lightmode`` — Warm brown/gold text for light terminal backgrounds
|
||||
- ``poseidon`` — Ocean-god theme (deep blue and seafoam)
|
||||
- ``sisyphus`` — Austere grayscale with boulder motif
|
||||
- ``charizard`` — Volcanic burnt-orange and ember
|
||||
- ``bunnny`` — Barbie-pink coquette theme (sparkles, hearts, bunnies)
|
||||
|
||||
USER SKINS
|
||||
==========
|
||||
@@ -640,83 +636,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
[#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[dim #7A3511]⠀⠀⠀⠀⠀⠀⠀tail flame lit⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||||
},
|
||||
"bunnny": {
|
||||
"name": "bunnny",
|
||||
"description": "Barbie-pink coquette theme — sparkles, bows, and bubblegum",
|
||||
"colors": {
|
||||
"banner_border": "#E91E63",
|
||||
"banner_title": "#FF3366",
|
||||
"banner_accent": "#FF69B4",
|
||||
"banner_dim": "#C2185B",
|
||||
"banner_text": "#FFF0F5",
|
||||
"ui_accent": "#FF3366",
|
||||
"ui_label": "#FF69B4",
|
||||
"ui_ok": "#FFB6C1",
|
||||
"ui_error": "#FF1744",
|
||||
"ui_warn": "#FFAB91",
|
||||
"prompt": "#FFF0F5",
|
||||
"input_rule": "#E91E63",
|
||||
"response_border": "#FF69B4",
|
||||
"status_bar_bg": "#2A0E1E",
|
||||
"status_bar_text": "#FFE4EC",
|
||||
"status_bar_strong": "#FF3366",
|
||||
"status_bar_dim": "#8E4B6B",
|
||||
"status_bar_good": "#FFB6C1",
|
||||
"status_bar_warn": "#FF69B4",
|
||||
"status_bar_bad": "#FF3366",
|
||||
"status_bar_critical": "#FF1744",
|
||||
"session_label": "#FF69B4",
|
||||
"session_border": "#8E4B6B",
|
||||
"voice_status_bg": "#2A0E1E",
|
||||
"completion_menu_bg": "#2A0E1E",
|
||||
"completion_menu_current_bg": "#5A1D3A",
|
||||
"completion_menu_meta_bg": "#2A0E1E",
|
||||
"completion_menu_meta_current_bg": "#5A1D3A",
|
||||
},
|
||||
"spinner": {
|
||||
"waiting_faces": ["(♡)", "(✿)", "(✧)", "(❀)", "(ෆ)", "(˘ᵕ˘)", "(⑅)"],
|
||||
"thinking_faces": ["(♡)", "(✧)", "(❀)", "(✿)", "(ෆ)", "(˘ᵕ˘)"],
|
||||
"thinking_verbs": [
|
||||
"sparkling", "twirling", "glittering", "frosting",
|
||||
"bedazzling", "bowtying", "sprinkling sugar", "picking ribbons",
|
||||
"glossing up", "curating the vibe", "dusting pink",
|
||||
"tying a little bow", "making it cute",
|
||||
],
|
||||
"wings": [
|
||||
["⟪♡", "♡⟫"],
|
||||
["⟪✧", "✧⟫"],
|
||||
["⟪✿", "✿⟫"],
|
||||
["⟪❀", "❀⟫"],
|
||||
["⟪ෆ", "ෆ⟫"],
|
||||
],
|
||||
},
|
||||
"branding": {
|
||||
"agent_name": "Hermes Agent",
|
||||
"welcome": "hi bestie ♡ welcome to Hermes Agent! type your message or /help for commands (ノ◕ヮ◕)ノ*:・゚✧",
|
||||
"goodbye": "bye bestie ♡ ✧",
|
||||
"response_label": " ♡ Hermes ",
|
||||
"prompt_symbol": "♡",
|
||||
"help_header": "(ノ◕ヮ◕)ノ*:・゚✧ Commands",
|
||||
},
|
||||
"tool_prefix": "♡",
|
||||
"banner_logo": """[bold #FFB6C1]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ ██╗ ██╗ [/]
|
||||
[bold #FF69B4]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ████████╗[/]
|
||||
[#FF3C7F]███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗ ╚██████╔╝[/]
|
||||
[#FF3366]██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║ ╚████╔╝ [/]
|
||||
[#E91E63]██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ╚██╔╝ [/]
|
||||
[#C2185B]╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ [/]""",
|
||||
"banner_hero": """[#FF69B4]⠀⠀✧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✧⠀⠀[/]
|
||||
[#FFB6C1]⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀[/]
|
||||
[#FF69B4]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣯⢬⣷⡀⠀⠀⣴⡯⢌⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#FF3366]⠀✿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿♡⠹⣷⠀⢸⡝♡⢸⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✿⠀[/]
|
||||
[#FF3C7F]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⣧⣀⣿⣦⣼⡁⣠⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#FF3366]⠀⠀⠀⠀✧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡾⠋⠀⠀⠀⠈⣙⣯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✧[/]
|
||||
[#FF3366]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⠀⠀⠀⠀⠀⠀⠀⠸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#E91E63]⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀⠀⠀⠀⢰⡧⢄⢰⡆⠀⢰⡆⡠⢄⣧⠀⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀[/]
|
||||
[#C2185B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠳⣼⣤⣤⣤⣤⣤⣧⠾⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||||
[#FF69B4]⠀⠀⠀⠀⠀✿⠀⠀⠀⠀⠀⠀❀⠀⠀⠀⠀⠀❀⠀⠀❀⠀⠀⠀⠀⠀❀⠀⠀⠀⠀⠀⠀✿⠀⠀⠀⠀⠀[/]
|
||||
[dim #C2185B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀xoxo⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+73
-27
@@ -7,6 +7,7 @@ Shows the status of all Hermes Agent components.
|
||||
import os
|
||||
import sys
|
||||
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
@@ -17,6 +18,7 @@ from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load
|
||||
from hermes_cli.models import provider_label
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
@@ -89,12 +91,12 @@ def show_status(args):
|
||||
"""Show status of all Hermes Agent components."""
|
||||
show_all = getattr(args, 'all', False)
|
||||
deep = getattr(args, 'deep', False)
|
||||
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Environment
|
||||
# =========================================================================
|
||||
@@ -102,7 +104,7 @@ def show_status(args):
|
||||
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Project: {PROJECT_ROOT}")
|
||||
print(f" Python: {sys.version.split()[0]}")
|
||||
|
||||
|
||||
env_path = get_env_path()
|
||||
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
|
||||
|
||||
@@ -113,13 +115,13 @@ def show_status(args):
|
||||
|
||||
print(f" Model: {_configured_model_label(config)}")
|
||||
print(f" Provider: {_effective_provider_label()}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API Keys
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
keys = {
|
||||
"OpenRouter": "OPENROUTER_API_KEY",
|
||||
"OpenAI": "OPENAI_API_KEY",
|
||||
@@ -138,7 +140,7 @@ def show_status(args):
|
||||
"ElevenLabs": "ELEVENLABS_API_KEY",
|
||||
"GitHub": "GITHUB_TOKEN",
|
||||
}
|
||||
|
||||
|
||||
for name, env_var in keys.items():
|
||||
value = get_env_value(env_var) or ""
|
||||
has_key = bool(value)
|
||||
@@ -157,14 +159,21 @@ def show_status(args):
|
||||
print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status, get_codex_auth_status, get_qwen_auth_status
|
||||
from hermes_cli.auth import (
|
||||
get_nous_auth_status,
|
||||
get_codex_auth_status,
|
||||
get_qwen_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
nous_status = get_nous_auth_status()
|
||||
codex_status = get_codex_auth_status()
|
||||
qwen_status = get_qwen_auth_status()
|
||||
minimax_status = get_minimax_oauth_auth_status()
|
||||
except Exception:
|
||||
nous_status = {}
|
||||
codex_status = {}
|
||||
qwen_status = {}
|
||||
minimax_status = {}
|
||||
|
||||
nous_logged_in = bool(nous_status.get("logged_in"))
|
||||
nous_error = nous_status.get("error")
|
||||
@@ -217,6 +226,20 @@ def show_status(args):
|
||||
if qwen_status.get("error") and not qwen_logged_in:
|
||||
print(f" Error: {qwen_status.get('error')}")
|
||||
|
||||
minimax_logged_in = bool(minimax_status.get("logged_in"))
|
||||
print(
|
||||
f" {'MiniMax OAuth':<12} {check_mark(minimax_logged_in)} "
|
||||
f"{'logged in' if minimax_logged_in else 'not logged in (run: hermes auth add minimax-oauth)'}"
|
||||
)
|
||||
minimax_region = minimax_status.get("region")
|
||||
if minimax_logged_in and minimax_region:
|
||||
print(f" Region: {minimax_region}")
|
||||
minimax_exp = minimax_status.get("expires_at")
|
||||
if minimax_exp:
|
||||
print(f" Access exp: {minimax_exp}")
|
||||
if minimax_status.get("error") and not minimax_logged_in:
|
||||
print(f" Error: {minimax_status.get('error')}")
|
||||
|
||||
# =========================================================================
|
||||
# Nous Subscription Features
|
||||
# =========================================================================
|
||||
@@ -299,18 +322,13 @@ def show_status(args):
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "")
|
||||
if not terminal_env:
|
||||
# Fall back to config file value when env var isn't set
|
||||
# (hermes status doesn't go through cli.py's config loading)
|
||||
try:
|
||||
_cfg = load_config()
|
||||
terminal_env = _cfg.get("terminal", {}).get("backend", "local")
|
||||
except Exception:
|
||||
terminal_env = "local"
|
||||
terminal_env = terminal_cfg.get("backend", "local")
|
||||
print(f" Backend: {terminal_env}")
|
||||
|
||||
|
||||
if terminal_env == "ssh":
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
|
||||
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
|
||||
@@ -322,16 +340,33 @@ def show_status(args):
|
||||
elif terminal_env == "daytona":
|
||||
daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
print(f" Daytona Image: {daytona_image}")
|
||||
|
||||
elif terminal_env == "vercel_sandbox":
|
||||
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24"
|
||||
persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT")
|
||||
if persist is None:
|
||||
persist_enabled = bool(terminal_cfg.get("container_persistent", True))
|
||||
else:
|
||||
persist_enabled = persist.lower() in ("1", "true", "yes", "on")
|
||||
auth_status = describe_vercel_auth()
|
||||
sdk_ok = importlib.util.find_spec("vercel") is not None
|
||||
sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')"
|
||||
print(f" Runtime: {runtime}")
|
||||
print(f" SDK: {check_mark(sdk_ok)} {sdk_label}")
|
||||
print(f" Auth: {check_mark(auth_status.ok)} {auth_status.label}")
|
||||
for line in auth_status.detail_lines:
|
||||
print(f" Auth detail: {line}")
|
||||
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
|
||||
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
|
||||
|
||||
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
||||
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Messaging Platforms
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
platforms = {
|
||||
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
||||
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
|
||||
@@ -349,7 +384,7 @@ def show_status(args):
|
||||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
token = os.getenv(token_var, "")
|
||||
has_token = bool(token)
|
||||
@@ -366,7 +401,18 @@ def show_status(args):
|
||||
status += f" (home: {home_channel})"
|
||||
|
||||
print(f" {name:<12} {check_mark(has_token)} {status}")
|
||||
|
||||
|
||||
# Plugin-registered platforms
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
configured = entry.check_fn()
|
||||
status_str = "configured" if configured else "not configured"
|
||||
label = entry.label
|
||||
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# Gateway Status
|
||||
# =========================================================================
|
||||
@@ -402,13 +448,13 @@ def show_status(args):
|
||||
else:
|
||||
print(f" Status: {color('N/A', Colors.DIM)}")
|
||||
print(" Manager: (not supported on this platform)")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cron Jobs
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||
if jobs_file.exists():
|
||||
import json
|
||||
@@ -422,13 +468,13 @@ def show_status(args):
|
||||
print(" Jobs: (error reading jobs file)")
|
||||
else:
|
||||
print(" Jobs: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sessions
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
||||
if sessions_file.exists():
|
||||
import json
|
||||
@@ -440,7 +486,7 @@ def show_status(args):
|
||||
print(" Active: (error reading sessions file)")
|
||||
else:
|
||||
print(" Active: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Deep checks
|
||||
# =========================================================================
|
||||
@@ -476,7 +522,7 @@ def show_status(args):
|
||||
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
print()
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
|
||||
|
||||
@@ -100,6 +100,9 @@ TIPS = [
|
||||
"hermes gateway install sets up Hermes as a system service (systemd/launchd).",
|
||||
"hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).",
|
||||
"hermes webhook subscribe creates event-driven webhook routes with HMAC validation.",
|
||||
"Save money: hermes tools disables unused tools, hermes skills config trims skills down.",
|
||||
"/reasoning low or /reasoning minimal cuts thinking depth below the default (medium) — faster, cheaper responses.",
|
||||
"hermes models routes vision, compression, and aux tasks to cheaper models — cuts background token cost 85%+ without downgrading your main chat model.",
|
||||
|
||||
# --- Configuration ---
|
||||
"Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.",
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Dict, List, Optional, Set
|
||||
|
||||
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
load_config, save_config, get_env_value, save_env_value,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -226,6 +227,14 @@ TOOL_CATEGORIES = {
|
||||
"tts_provider": "kittentts",
|
||||
"post_setup": "kittentts",
|
||||
},
|
||||
{
|
||||
"name": "Piper",
|
||||
"badge": "local · free",
|
||||
"tag": "Local neural TTS, 44 languages (voices ~20-90MB)",
|
||||
"env_vars": [],
|
||||
"tts_provider": "piper",
|
||||
"post_setup": "piper",
|
||||
},
|
||||
],
|
||||
},
|
||||
"web": {
|
||||
@@ -623,6 +632,33 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_warning(" kittentts install timed out (>5min)")
|
||||
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile")
|
||||
|
||||
elif post_setup_key == "piper":
|
||||
try:
|
||||
__import__("piper")
|
||||
_print_success(" piper-tts is already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" piper-tts installed")
|
||||
else:
|
||||
_print_warning(" piper-tts install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(" Run manually: python -m pip install -U piper-tts")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" piper-tts install timed out (>5min)")
|
||||
_print_info(" Run manually: python -m pip install -U piper-tts")
|
||||
return
|
||||
_print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)")
|
||||
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
|
||||
_print_info(" Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml")
|
||||
|
||||
elif post_setup_key == "spotify":
|
||||
# Run the full `hermes auth spotify` flow — if the user has no
|
||||
# client_id yet, this drops them into the interactive wizard
|
||||
@@ -780,7 +816,12 @@ def _get_platform_tools(
|
||||
toolset_names = platform_toolsets.get(platform)
|
||||
|
||||
if toolset_names is None or not isinstance(toolset_names, list):
|
||||
default_ts = PLATFORMS[platform]["default_toolset"]
|
||||
plat_info = PLATFORMS.get(platform)
|
||||
if plat_info:
|
||||
default_ts = plat_info["default_toolset"]
|
||||
else:
|
||||
# Plugin platform — derive toolset name from platform key
|
||||
default_ts = f"hermes-{platform}"
|
||||
toolset_names = [default_ts]
|
||||
|
||||
# YAML may parse bare numeric names (e.g. ``12306:``) as int.
|
||||
@@ -843,7 +884,9 @@ def _get_platform_tools(
|
||||
# checklist or in a user-saved config. Must run in BOTH branches —
|
||||
# otherwise saving via `hermes tools` (which flips has_explicit_config
|
||||
# to True) silently drops them.
|
||||
platform_tool_universe = set(resolve_toolset(PLATFORMS[platform]["default_toolset"]))
|
||||
_plat_info = PLATFORMS.get(platform)
|
||||
_default_ts = _plat_info["default_toolset"] if _plat_info else f"hermes-{platform}"
|
||||
platform_tool_universe = set(resolve_toolset(_default_ts))
|
||||
configurable_tool_universe = set()
|
||||
for ck in configurable_keys:
|
||||
configurable_tool_universe.update(resolve_toolset(ck))
|
||||
@@ -965,7 +1008,7 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
|
||||
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
|
||||
|
||||
# Get existing toolsets for this platform
|
||||
existing_toolsets = config.get("platform_toolsets", {}).get(platform, [])
|
||||
existing_toolsets = cfg_get(config, "platform_toolsets", platform, default=[])
|
||||
if not isinstance(existing_toolsets, list):
|
||||
existing_toolsets = []
|
||||
existing_toolsets = [str(ts) for ts in existing_toolsets]
|
||||
@@ -1352,23 +1395,23 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
if provider.get("tts_provider"):
|
||||
return (
|
||||
feature.managed_by_nous
|
||||
and config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||
and cfg_get(config, "tts", "provider") == provider["tts_provider"]
|
||||
)
|
||||
if "browser_provider" in provider:
|
||||
current = config.get("browser", {}).get("cloud_provider")
|
||||
current = cfg_get(config, "browser", "cloud_provider")
|
||||
return feature.managed_by_nous and provider["browser_provider"] == current
|
||||
if provider.get("web_backend"):
|
||||
current = config.get("web", {}).get("backend")
|
||||
current = cfg_get(config, "web", "backend")
|
||||
return feature.managed_by_nous and current == provider["web_backend"]
|
||||
return feature.managed_by_nous
|
||||
|
||||
if provider.get("tts_provider"):
|
||||
return config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||
return cfg_get(config, "tts", "provider") == provider["tts_provider"]
|
||||
if "browser_provider" in provider:
|
||||
current = config.get("browser", {}).get("cloud_provider")
|
||||
current = cfg_get(config, "browser", "cloud_provider")
|
||||
return provider["browser_provider"] == current
|
||||
if provider.get("web_backend"):
|
||||
current = config.get("web", {}).get("backend")
|
||||
current = cfg_get(config, "web", "backend")
|
||||
return current == provider["web_backend"]
|
||||
if provider.get("imagegen_backend"):
|
||||
image_cfg = config.get("image_gen", {})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Helpers for reporting Vercel Sandbox authentication state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
_TOKEN_TUPLE_VARS = ("VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VercelAuthStatus:
|
||||
ok: bool
|
||||
label: str
|
||||
detail_lines: tuple[str, ...]
|
||||
|
||||
|
||||
def _present(name: str) -> bool:
|
||||
return bool(os.getenv(name))
|
||||
|
||||
|
||||
def describe_vercel_auth() -> VercelAuthStatus:
|
||||
"""Return Vercel auth status without exposing secret values."""
|
||||
|
||||
has_oidc = _present("VERCEL_OIDC_TOKEN")
|
||||
token_states = {name: _present(name) for name in _TOKEN_TUPLE_VARS}
|
||||
present_token_vars = tuple(name for name, present in token_states.items() if present)
|
||||
missing_token_vars = tuple(name for name, present in token_states.items() if not present)
|
||||
|
||||
if has_oidc:
|
||||
details = [
|
||||
"mode: OIDC",
|
||||
"active env: VERCEL_OIDC_TOKEN",
|
||||
"note: OIDC tokens are development-only; use access-token auth for deployments and long-running processes",
|
||||
]
|
||||
if present_token_vars:
|
||||
details.append(f"also present: {', '.join(present_token_vars)}")
|
||||
return VercelAuthStatus(True, "OIDC token via VERCEL_OIDC_TOKEN", tuple(details))
|
||||
|
||||
if not missing_token_vars:
|
||||
return VercelAuthStatus(
|
||||
True,
|
||||
"access token + project/team via VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
|
||||
(
|
||||
"mode: access token",
|
||||
"active env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
|
||||
),
|
||||
)
|
||||
|
||||
if present_token_vars:
|
||||
return VercelAuthStatus(
|
||||
False,
|
||||
f"partial access-token auth (missing {', '.join(missing_token_vars)})",
|
||||
(
|
||||
"mode: incomplete access token",
|
||||
f"present env: {', '.join(present_token_vars)}",
|
||||
f"missing env: {', '.join(missing_token_vars)}",
|
||||
"recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together",
|
||||
),
|
||||
)
|
||||
|
||||
return VercelAuthStatus(
|
||||
False,
|
||||
"not configured",
|
||||
(
|
||||
"recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID",
|
||||
"development-only alternative: set VERCEL_OIDC_TOKEN",
|
||||
),
|
||||
)
|
||||
+358
-9
@@ -23,7 +23,7 @@ import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -33,6 +33,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
DEFAULT_CONFIG,
|
||||
OPTIONAL_ENV_VARS,
|
||||
get_config_path,
|
||||
@@ -252,7 +253,12 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"terminal.backend": {
|
||||
"type": "select",
|
||||
"description": "Terminal execution backend",
|
||||
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
|
||||
"options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"],
|
||||
},
|
||||
"terminal.vercel_runtime": {
|
||||
"type": "select",
|
||||
"description": "Vercel Sandbox runtime",
|
||||
"options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py
|
||||
},
|
||||
"terminal.modal_mode": {
|
||||
"type": "select",
|
||||
@@ -338,6 +344,11 @@ _CATEGORY_MERGE: Dict[str, str] = {
|
||||
"human_delay": "display",
|
||||
"dashboard": "display",
|
||||
"code_execution": "agent",
|
||||
"prompt_caching": "agent",
|
||||
# Only `telegram.reactions` currently lives under telegram — fold it in
|
||||
# with the other messaging-platform config (discord) so it isn't an
|
||||
# orphan tab of one field.
|
||||
"telegram": "discord",
|
||||
}
|
||||
|
||||
# Display order for tabs — unlisted categories sort alphabetically after these.
|
||||
@@ -434,6 +445,20 @@ class EnvVarReveal(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
scope="main" → writes model.provider + model.default
|
||||
scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
|
||||
scope="auxiliary" with task="" → applied to every auxiliary.* slot
|
||||
scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
|
||||
"""
|
||||
scope: str
|
||||
provider: str
|
||||
model: str
|
||||
task: str = ""
|
||||
|
||||
|
||||
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
|
||||
try:
|
||||
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
|
||||
@@ -910,6 +935,207 @@ def get_model_info():
|
||||
return dict(_EMPTY_MODEL_INFO)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model assignment — pick provider+model for main slot or auxiliary slots.
|
||||
# Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the
|
||||
# Models page (which has no chat PTY open) can drive it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"]
|
||||
# in hermes_cli/config.py — listed here for deterministic ordering in the UI.
|
||||
_AUX_TASK_SLOTS: Tuple[str, ...] = (
|
||||
"vision",
|
||||
"web_extract",
|
||||
"compression",
|
||||
"session_search",
|
||||
"skills_hub",
|
||||
"approval",
|
||||
"mcp",
|
||||
"title_generation",
|
||||
"curator",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/model/options")
|
||||
def get_model_options():
|
||||
"""Return authenticated providers + their curated model lists.
|
||||
|
||||
REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
|
||||
dashboard Models page can render the picker without a live chat session.
|
||||
The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog``
|
||||
can share the same types.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
current_model = model_cfg.get("default", model_cfg.get("name", "")) or ""
|
||||
current_provider = model_cfg.get("provider", "") or ""
|
||||
current_base_url = model_cfg.get("base_url", "") or ""
|
||||
else:
|
||||
current_model = str(model_cfg) if model_cfg else ""
|
||||
current_provider = ""
|
||||
current_base_url = ""
|
||||
|
||||
user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
|
||||
custom_providers = (
|
||||
cfg.get("custom_providers")
|
||||
if isinstance(cfg.get("custom_providers"), list)
|
||||
else []
|
||||
)
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
current_model=current_model,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
return {
|
||||
"providers": providers,
|
||||
"model": current_model,
|
||||
"provider": current_provider,
|
||||
}
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/options failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to list model options")
|
||||
|
||||
|
||||
@app.get("/api/model/auxiliary")
|
||||
def get_auxiliary_models():
|
||||
"""Return current auxiliary task assignments.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"tasks": [
|
||||
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
|
||||
...
|
||||
],
|
||||
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
|
||||
}
|
||||
"""
|
||||
try:
|
||||
cfg = load_config()
|
||||
aux_cfg = cfg.get("auxiliary", {})
|
||||
if not isinstance(aux_cfg, dict):
|
||||
aux_cfg = {}
|
||||
|
||||
tasks = []
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {}
|
||||
tasks.append({
|
||||
"task": slot,
|
||||
"provider": str(slot_cfg.get("provider", "auto") or "auto"),
|
||||
"model": str(slot_cfg.get("model", "") or ""),
|
||||
"base_url": str(slot_cfg.get("base_url", "") or ""),
|
||||
})
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
main = {
|
||||
"provider": str(model_cfg.get("provider", "") or ""),
|
||||
"model": str(model_cfg.get("default", model_cfg.get("name", "")) or ""),
|
||||
}
|
||||
else:
|
||||
main = {"provider": "", "model": str(model_cfg) if model_cfg else ""}
|
||||
|
||||
return {"tasks": tasks, "main": main}
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/auxiliary failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to read auxiliary config")
|
||||
|
||||
|
||||
@app.post("/api/model/set")
|
||||
async def set_model_assignment(body: ModelAssignment):
|
||||
"""Assign a model to the main slot or an auxiliary task slot.
|
||||
|
||||
Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only.
|
||||
The currently running chat PTY (if any) is not affected; use the
|
||||
``/model`` slash command inside a chat to hot-swap that specific session.
|
||||
"""
|
||||
scope = (body.scope or "").strip().lower()
|
||||
provider = (body.provider or "").strip()
|
||||
model = (body.model or "").strip()
|
||||
task = (body.task or "").strip().lower()
|
||||
|
||||
if scope not in ("main", "auxiliary"):
|
||||
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
# Clear stale base_url so the resolver picks the provider's own default.
|
||||
if "base_url" in model_cfg and model_cfg.get("base_url"):
|
||||
model_cfg["base_url"] = ""
|
||||
# Also clear hardcoded context_length override — new model may have
|
||||
# a different context window.
|
||||
if "context_length" in model_cfg:
|
||||
model_cfg.pop("context_length", None)
|
||||
cfg["model"] = model_cfg
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "main", "provider": provider, "model": model}
|
||||
|
||||
# scope == "auxiliary"
|
||||
aux = cfg.get("auxiliary")
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
|
||||
if task == "__reset__":
|
||||
# Reset every slot to provider="auto", model="" — keeps other fields intact.
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = "auto"
|
||||
slot_cfg["model"] = ""
|
||||
aux[slot] = slot_cfg
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "auxiliary", "reset": True}
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="provider required for auxiliary")
|
||||
|
||||
targets = [task] if task else list(_AUX_TASK_SLOTS)
|
||||
for slot in targets:
|
||||
if slot not in _AUX_TASK_SLOTS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = provider
|
||||
slot_cfg["model"] = model
|
||||
aux[slot] = slot_cfg
|
||||
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {
|
||||
"ok": True,
|
||||
"scope": "auxiliary",
|
||||
"tasks": targets,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("POST /api/model/set failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to save model assignment")
|
||||
|
||||
|
||||
|
||||
|
||||
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reverse _normalize_config_for_web before saving.
|
||||
|
||||
@@ -1214,6 +1440,14 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
"docs_url": "https://github.com/QwenLM/qwen-code",
|
||||
"status_fn": None, # dispatched via auth.get_qwen_auth_status
|
||||
},
|
||||
{
|
||||
"id": "minimax-oauth",
|
||||
"name": "MiniMax (OAuth)",
|
||||
"flow": "pkce",
|
||||
"cli_command": "hermes auth add minimax-oauth",
|
||||
"docs_url": "https://www.minimax.io",
|
||||
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1257,6 +1491,16 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": bool(raw.get("has_refresh_token")),
|
||||
}
|
||||
if provider_id == "minimax-oauth":
|
||||
raw = hauth.get_minimax_oauth_auth_status()
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": "minimax_oauth",
|
||||
"source_label": f"MiniMax ({raw.get('region', 'global')})",
|
||||
"token_preview": None,
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
@@ -2270,6 +2514,99 @@ async def get_usage_analytics(days: int = 30):
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/analytics/models")
|
||||
async def get_models_analytics(days: int = 30):
|
||||
"""Rich per-model analytics for the Models dashboard page.
|
||||
|
||||
Returns token/cost/session breakdown per model plus capability metadata
|
||||
from models.dev (context window, vision, tools, reasoning, etc.).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
cutoff = time.time() - (days * 86400)
|
||||
|
||||
cur = db._conn.execute("""
|
||||
SELECT model,
|
||||
billing_provider,
|
||||
SUM(input_tokens) as input_tokens,
|
||||
SUM(output_tokens) as output_tokens,
|
||||
SUM(cache_read_tokens) as cache_read_tokens,
|
||||
SUM(reasoning_tokens) as reasoning_tokens,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
|
||||
COUNT(*) as sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as api_calls,
|
||||
SUM(tool_call_count) as tool_calls,
|
||||
MAX(started_at) as last_used_at,
|
||||
AVG(input_tokens + output_tokens) as avg_tokens_per_session
|
||||
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
|
||||
GROUP BY model, billing_provider
|
||||
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
|
||||
""", (cutoff,))
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
models = []
|
||||
for row in rows:
|
||||
provider = row.get("billing_provider") or ""
|
||||
model_name = row["model"]
|
||||
caps = {}
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
mc = get_model_capabilities(provider=provider, model=model_name)
|
||||
if mc is not None:
|
||||
caps = {
|
||||
"supports_tools": mc.supports_tools,
|
||||
"supports_vision": mc.supports_vision,
|
||||
"supports_reasoning": mc.supports_reasoning,
|
||||
"context_window": mc.context_window,
|
||||
"max_output_tokens": mc.max_output_tokens,
|
||||
"model_family": mc.model_family,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
models.append({
|
||||
"model": model_name,
|
||||
"provider": provider,
|
||||
"input_tokens": row["input_tokens"],
|
||||
"output_tokens": row["output_tokens"],
|
||||
"cache_read_tokens": row["cache_read_tokens"],
|
||||
"reasoning_tokens": row["reasoning_tokens"],
|
||||
"estimated_cost": row["estimated_cost"],
|
||||
"actual_cost": row["actual_cost"],
|
||||
"sessions": row["sessions"],
|
||||
"api_calls": row["api_calls"],
|
||||
"tool_calls": row["tool_calls"],
|
||||
"last_used_at": row["last_used_at"],
|
||||
"avg_tokens_per_session": row["avg_tokens_per_session"],
|
||||
"capabilities": caps,
|
||||
})
|
||||
|
||||
totals_cur = db._conn.execute("""
|
||||
SELECT COUNT(DISTINCT model) as distinct_models,
|
||||
SUM(input_tokens) as total_input,
|
||||
SUM(output_tokens) as total_output,
|
||||
SUM(cache_read_tokens) as total_cache_read,
|
||||
SUM(reasoning_tokens) as total_reasoning,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
|
||||
COUNT(*) as total_sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as total_api_calls
|
||||
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
|
||||
""", (cutoff,))
|
||||
totals = dict(totals_cur.fetchone())
|
||||
|
||||
return {
|
||||
"models": models,
|
||||
"totals": totals,
|
||||
"period_days": days,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab.
|
||||
#
|
||||
@@ -2902,7 +3239,7 @@ async def get_dashboard_themes():
|
||||
them without a stub.
|
||||
"""
|
||||
config = load_config()
|
||||
active = config.get("dashboard", {}).get("theme", "default")
|
||||
active = cfg_get(config, "dashboard", "theme", default="default")
|
||||
user_themes = _discover_user_themes()
|
||||
seen = set()
|
||||
themes = []
|
||||
@@ -2952,10 +3289,12 @@ def _discover_dashboard_plugins() -> list:
|
||||
plugins = []
|
||||
seen_names: set = set()
|
||||
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
bundled_root = get_bundled_plugins_dir()
|
||||
search_dirs = [
|
||||
(get_hermes_home() / "plugins", "user"),
|
||||
(PROJECT_ROOT / "plugins" / "memory", "bundled"),
|
||||
(PROJECT_ROOT / "plugins", "bundled"),
|
||||
(bundled_root / "memory", "bundled"),
|
||||
(bundled_root, "bundled"),
|
||||
]
|
||||
if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||||
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
|
||||
@@ -3100,13 +3439,23 @@ def _mount_plugin_api_routes():
|
||||
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
|
||||
continue
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hermes_dashboard_plugin_{plugin['name']}", api_path,
|
||||
)
|
||||
module_name = f"hermes_dashboard_plugin_{plugin['name']}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, api_path)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
# Register in sys.modules BEFORE exec_module so pydantic/FastAPI
|
||||
# can resolve forward references (e.g. models defined in a file
|
||||
# that uses `from __future__ import annotations`). Without this,
|
||||
# TypeAdapter lazy-build fails at first request with
|
||||
# "is not fully defined" because the module namespace isn't
|
||||
# reachable by name for string-annotation resolution.
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
router = getattr(mod, "router", None)
|
||||
if router is None:
|
||||
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Dict
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
from utils import atomic_replace
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
|
||||
@@ -60,7 +61,7 @@ def _get_webhook_config() -> dict:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
return cfg.get("platforms", {}).get("webhook", {})
|
||||
return cfg_get(cfg, "platforms", "webhook", default={})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
+61
-20
@@ -107,17 +107,58 @@ def _run_async(coro):
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# Inside an async context (gateway, RL env) — run in a fresh thread.
|
||||
# Inside an async context (gateway, RL env) — run in a fresh thread
|
||||
# with its own event loop we own a reference to, so on timeout we
|
||||
# can cancel the task inside that loop (ThreadPoolExecutor.cancel()
|
||||
# only works on not-yet-started futures — it's a no-op on a running
|
||||
# worker, which previously leaked the thread on every 300 s timeout).
|
||||
import concurrent.futures
|
||||
|
||||
worker_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
loop_ready = threading.Event()
|
||||
|
||||
def _run_in_worker():
|
||||
nonlocal worker_loop
|
||||
worker_loop = asyncio.new_event_loop()
|
||||
loop_ready.set()
|
||||
try:
|
||||
asyncio.set_event_loop(worker_loop)
|
||||
return worker_loop.run_until_complete(coro)
|
||||
finally:
|
||||
try:
|
||||
# Cancel anything still pending (e.g. task cancelled
|
||||
# externally via call_soon_threadsafe on timeout).
|
||||
pending = asyncio.all_tasks(worker_loop)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
worker_loop.run_until_complete(
|
||||
asyncio.gather(*pending, return_exceptions=True)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
worker_loop.close()
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(asyncio.run, coro)
|
||||
future = pool.submit(_run_in_worker)
|
||||
try:
|
||||
return future.result(timeout=300)
|
||||
except concurrent.futures.TimeoutError:
|
||||
future.cancel()
|
||||
# Cancel the coroutine inside its own loop so the worker thread
|
||||
# can wind down instead of running forever.
|
||||
if loop_ready.wait(timeout=1.0) and worker_loop is not None:
|
||||
try:
|
||||
for t in asyncio.all_tasks(worker_loop):
|
||||
worker_loop.call_soon_threadsafe(t.cancel)
|
||||
except RuntimeError:
|
||||
# Loop already closed — nothing to cancel.
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
# wait=False: don't block the caller on a stuck coroutine. We've
|
||||
# already requested cancellation above; the worker will exit
|
||||
# once the coroutine observes it (usually at the next await).
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
# If we're on a worker thread (e.g., parallel tool execution in
|
||||
# delegate_task), use a per-thread persistent loop. This avoids
|
||||
@@ -279,7 +320,15 @@ def get_tool_definitions(
|
||||
|
||||
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)
|
||||
if quiet_mode:
|
||||
# Cache the freshly-computed list, but hand callers a shallow copy so
|
||||
# downstream mutations (e.g. run_agent appending memory/LCM tool
|
||||
# schemas to self.tools) don't poison the cache. Without this, a
|
||||
# long-lived Gateway process accumulates duplicate tool names across
|
||||
# agent inits and providers that enforce unique tool names
|
||||
# (DeepSeek, Xiaomi MiMo, Moonshot Kimi) reject the request with
|
||||
# HTTP 400. Mirrors the cache-hit path above. (issue #17335)
|
||||
_tool_defs_cache[cache_key] = result
|
||||
return list(result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -627,6 +676,13 @@ def handle_function_call(
|
||||
# Check plugin hooks for a block directive (unless caller already
|
||||
# checked — e.g. run_agent._invoke_tool passes skip=True to
|
||||
# avoid double-firing the hook).
|
||||
#
|
||||
# Single-fire contract: pre_tool_call fires exactly once per tool
|
||||
# execution. get_pre_tool_call_block_message() internally calls
|
||||
# invoke_hook("pre_tool_call", ...) and returns the first block
|
||||
# directive (if any), so observer plugins see the hook on that same
|
||||
# pass. When skip=True, the caller already fired it — do nothing
|
||||
# here.
|
||||
if not skip_pre_tool_call_hook:
|
||||
block_message: Optional[str] = None
|
||||
try:
|
||||
@@ -643,21 +699,6 @@ def handle_function_call(
|
||||
|
||||
if block_message is not None:
|
||||
return json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
else:
|
||||
# Still fire the hook for observers — just don't check for blocking
|
||||
# (the caller already did that).
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook
|
||||
invoke_hook(
|
||||
"pre_tool_call",
|
||||
tool_name=function_name,
|
||||
args=function_args,
|
||||
task_id=task_id or "",
|
||||
session_id=session_id or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Notify the read-loop tracker when a non-read/search tool runs,
|
||||
# so the *consecutive* counter resets (reads after other work are fine).
|
||||
@@ -737,7 +778,7 @@ def handle_function_call(
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing {function_name}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
logger.exception(error_msg)
|
||||
return json.dumps({"error": error_msg}, ensure_ascii=False)
|
||||
|
||||
|
||||
|
||||
+23
-3
@@ -4,9 +4,9 @@
|
||||
# transitive deps like onnxruntime that lack compatible wheels on
|
||||
# aarch64-darwin. The package and devShell still work on macOS.
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, lib, ... }:
|
||||
perSystem = { pkgs, lib, self', ... }:
|
||||
let
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
hermes-agent = self'.packages.default;
|
||||
hermesVenv = hermes-agent.hermesVenv;
|
||||
|
||||
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
|
||||
@@ -51,7 +51,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
failMsg = lib.concatMapStringsSep "\n" (r: " - ${r.sys}") failures;
|
||||
in pkgs.runCommand "hermes-cross-eval" { } (
|
||||
if failures != [] then
|
||||
builtins.throw "Package fails to evaluate on:\n${failMsg}"
|
||||
throw "Package fails to evaluate on:\n${failMsg}"
|
||||
else ''
|
||||
echo "PASS: package evaluates on all ${toString (builtins.length targetSystems)} platforms"
|
||||
mkdir -p $out
|
||||
@@ -124,6 +124,26 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify bundled plugins (platforms, memory, context_engine) are present
|
||||
bundled-plugins = pkgs.runCommand "hermes-bundled-plugins" { } ''
|
||||
set -e
|
||||
echo "=== Checking bundled plugins ==="
|
||||
test -d ${hermes-agent}/share/hermes-agent/plugins || (echo "FAIL: plugins directory missing"; exit 1)
|
||||
echo "PASS: plugins directory exists"
|
||||
|
||||
test -f ${hermes-agent}/share/hermes-agent/plugins/platforms/irc/plugin.yaml || \
|
||||
(echo "FAIL: irc plugin manifest missing"; exit 1)
|
||||
echo "PASS: irc plugin manifest present"
|
||||
|
||||
grep -q "HERMES_BUNDLED_PLUGINS" ${hermes-agent}/bin/hermes || \
|
||||
(echo "FAIL: HERMES_BUNDLED_PLUGINS not in wrapper"; exit 1)
|
||||
echo "PASS: HERMES_BUNDLED_PLUGINS set in wrapper"
|
||||
|
||||
echo "=== All bundled plugins checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify bundled TUI is present and compiled
|
||||
bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } ''
|
||||
set -e
|
||||
|
||||
+19
-18
@@ -1,29 +1,30 @@
|
||||
# nix/devShell.nix — Dev shell that delegates setup to each package
|
||||
#
|
||||
# Each package in inputsFrom exposes passthru.devShellHook — a bash snippet
|
||||
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
|
||||
# with stamp-checked setup logic. This file collects and runs them all.
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, ... }:
|
||||
{ ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, self', ... }:
|
||||
let
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
hermes-tui = inputs.self.packages.${system}.tui;
|
||||
hermes-web = inputs.self.packages.${system}.web;
|
||||
packages = [ hermes-agent hermes-tui hermes-web ];
|
||||
in {
|
||||
packages = builtins.attrValues self'.packages;
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = packages;
|
||||
packages = with pkgs; [
|
||||
python312 uv nodejs_22 ripgrep git openssh ffmpeg
|
||||
uv
|
||||
];
|
||||
|
||||
shellHook = let
|
||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
||||
in ''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combined}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
shellHook =
|
||||
let
|
||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
||||
in
|
||||
''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combined}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+66
-44
@@ -19,16 +19,21 @@
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
npm-lockfile-fix,
|
||||
# Locked git revision of the flake source — embedded so banner.py can
|
||||
# check for updates without needing a local .git directory. Null for
|
||||
# impure / dirty builds where flakes can't determine a rev.
|
||||
rev ? null,
|
||||
# Overridable parameters
|
||||
extraPythonPackages ? [ ],
|
||||
}:
|
||||
let
|
||||
nodejs = nodejs_22;
|
||||
hermesVenv = callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
hermesNpmLib = callPackage ./lib.nix {
|
||||
inherit npm-lockfile-fix;
|
||||
inherit npm-lockfile-fix nodejs;
|
||||
};
|
||||
|
||||
hermesTui = callPackage ./tui.nix {
|
||||
@@ -44,8 +49,16 @@ let
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path);
|
||||
};
|
||||
|
||||
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
|
||||
# them out of the Python site-packages keeps import semantics identical
|
||||
# to a dev checkout — the loader reads them from HERMES_BUNDLED_PLUGINS.
|
||||
bundledPlugins = lib.cleanSourceWith {
|
||||
src = ../plugins;
|
||||
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
runtimeDeps = [
|
||||
nodejs_22
|
||||
nodejs
|
||||
ripgrep
|
||||
git
|
||||
openssh
|
||||
@@ -70,10 +83,49 @@ let
|
||||
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
|
||||
else
|
||||
"none";
|
||||
checkPackageCollisions = ''
|
||||
import pathlib, sys, re
|
||||
|
||||
def canonical(name):
|
||||
return re.sub(r'[-_.]+', '-', name).lower()
|
||||
|
||||
# Collect core venv package names
|
||||
core = set()
|
||||
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
|
||||
for di in venv_sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if meta.exists():
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
core.add(canonical(line.split(':', 1)[1].strip()))
|
||||
break
|
||||
|
||||
# Check each extra package for collisions
|
||||
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
|
||||
for edir in extras_dirs:
|
||||
sp = pathlib.Path(edir) / '${sitePackagesPath}'
|
||||
if not sp.exists():
|
||||
continue
|
||||
for di in sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if not meta.exists():
|
||||
continue
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
pkg = canonical(line.split(':', 1)[1].strip())
|
||||
if pkg in core:
|
||||
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
|
||||
print(f' from: {di}', file=sys.stderr)
|
||||
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
break
|
||||
|
||||
print('No collisions found.')
|
||||
'';
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
@@ -84,6 +136,7 @@ stdenv.mkDerivation {
|
||||
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
cp -r ${bundledPlugins} $out/share/hermes-agent/plugins
|
||||
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
|
||||
mkdir -p $out/ui-tui
|
||||
@@ -94,10 +147,12 @@ stdenv.mkDerivation {
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--suffix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
|
||||
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
|
||||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${nodejs_22}/bin/node \
|
||||
--set HERMES_NODE ${lib.getExe nodejs} \
|
||||
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
|
||||
'')
|
||||
[
|
||||
@@ -109,45 +164,7 @@ stdenv.mkDerivation {
|
||||
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''
|
||||
echo "=== Checking for plugin/core package collisions ==="
|
||||
${hermesVenv}/bin/python3 -c "
|
||||
import pathlib, sys, re
|
||||
|
||||
def canonical(name):
|
||||
return re.sub(r'[-_.]+', '-', name).lower()
|
||||
|
||||
# Collect core venv package names
|
||||
core = set()
|
||||
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
|
||||
for di in venv_sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if meta.exists():
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
core.add(canonical(line.split(':', 1)[1].strip()))
|
||||
break
|
||||
|
||||
# Check each extra package for collisions
|
||||
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
|
||||
for edir in extras_dirs:
|
||||
sp = pathlib.Path(edir) / '${sitePackagesPath}'
|
||||
if not sp.exists():
|
||||
continue
|
||||
for di in sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if not meta.exists():
|
||||
continue
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
pkg = canonical(line.split(':', 1)[1].strip())
|
||||
if pkg in core:
|
||||
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
|
||||
print(f' from: {di}', file=sys.stderr)
|
||||
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
break
|
||||
|
||||
print('No collisions found.')
|
||||
"
|
||||
${hermesVenv}/bin/python3 -c "${checkPackageCollisions}"
|
||||
echo "=== No collisions ==="
|
||||
''}
|
||||
|
||||
@@ -155,7 +172,12 @@ print('No collisions found.')
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit hermesTui hermesWeb hermesNpmLib hermesVenv;
|
||||
inherit
|
||||
hermesTui
|
||||
hermesWeb
|
||||
hermesNpmLib
|
||||
hermesVenv
|
||||
;
|
||||
|
||||
devShellHook = ''
|
||||
STAMP=".nix-stamps/hermes-agent"
|
||||
|
||||
+16
-9
@@ -1,11 +1,16 @@
|
||||
# nix/lib.nix — Shared helpers for nix stuff
|
||||
{ pkgs, npm-lockfile-fix }:
|
||||
{
|
||||
pkgs,
|
||||
npm-lockfile-fix,
|
||||
nodejs,
|
||||
}:
|
||||
{
|
||||
# Returns a buildNpmPackage-compatible attrs set that provides:
|
||||
# patchPhase — ensures lockfile has exactly one trailing newline
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# patchPhase — ensures lockfile has exactly one trailing newline
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# passthru.devShellHook — stamp-checked npm install + hash auto-update
|
||||
# passthru.npmLockfile — metadata for mkFixLockfiles
|
||||
# nodejs — fixed nodejs version for all packages we use in the repo
|
||||
#
|
||||
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
|
||||
# npm-deps cache lockfile. fetchNpmDeps preserves whatever trailing
|
||||
@@ -24,6 +29,7 @@
|
||||
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
|
||||
}:
|
||||
{
|
||||
inherit nodejs;
|
||||
patchPhase = ''
|
||||
runHook prePatch
|
||||
# Normalize trailing newlines so source and npm-deps always match,
|
||||
@@ -56,8 +62,8 @@
|
||||
|
||||
cd "$REPO_ROOT/${folder}"
|
||||
rm -rf node_modules/
|
||||
npm cache clean --force
|
||||
CI=true npm install
|
||||
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
NIX_FILE="$REPO_ROOT/${nixFile}"
|
||||
@@ -83,7 +89,7 @@
|
||||
STAMP_VALUE="$(_hermes_npm_stamp)"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "${pname}: installing npm dependencies..."
|
||||
( cd ${folder} && CI=true npm install --silent --no-fund --no-audit 2>/dev/null )
|
||||
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
|
||||
|
||||
# Auto-update the nix hash so it stays in sync with the lockfile
|
||||
echo "${pname}: prefetching npm deps..."
|
||||
@@ -92,7 +98,7 @@
|
||||
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
|
||||
echo "${pname}: updated hash to $NEW_HASH"
|
||||
else
|
||||
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles -- --apply' manually" >&2
|
||||
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
|
||||
fi
|
||||
|
||||
mkdir -p .nix-stamps
|
||||
@@ -112,6 +118,7 @@
|
||||
# Invocations:
|
||||
# fix-lockfiles --check # exit 1 if any hash is stale
|
||||
# fix-lockfiles --apply # rewrite stale hashes in place
|
||||
# fix-lockfiles # alias of --apply
|
||||
# Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT
|
||||
# when set, so CI workflows can post a sticky PR comment directly.
|
||||
mkFixLockfiles =
|
||||
@@ -124,7 +131,7 @@
|
||||
in
|
||||
pkgs.writeShellScriptBin "fix-lockfiles" ''
|
||||
set -uox pipefail
|
||||
MODE="''${1:---check}"
|
||||
MODE="''${1:---apply}"
|
||||
case "$MODE" in
|
||||
--check|--apply) ;;
|
||||
-h|--help)
|
||||
@@ -222,7 +229,7 @@
|
||||
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
|
||||
echo
|
||||
echo "Stale lockfile hashes detected. Run:"
|
||||
echo " nix run .#fix-lockfiles -- --apply"
|
||||
echo " nix run .#fix-lockfiles"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+18
-8
@@ -647,6 +647,16 @@
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Assertions ─────────────────────────────────────────────────────
|
||||
{
|
||||
assertions = let
|
||||
names = map lib.getName cfg.extraPlugins;
|
||||
in [{
|
||||
assertion = (lib.length names) == (lib.length (lib.unique names));
|
||||
message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate.";
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Warnings ──────────────────────────────────────────────────────
|
||||
# ── Per-user profile for extraPackages ───────────────────────────
|
||||
# Wire extraPackages into the hermes user's per-user profile so the
|
||||
@@ -730,12 +740,12 @@
|
||||
# is disabled so the host CLI falls back to native execution.
|
||||
${if cfg.container.enable then ''
|
||||
cat > ${cfg.stateDir}/.hermes/.container-mode <<'HERMES_CONTAINER_MODE_EOF'
|
||||
# Written by NixOS activation script. Do not edit manually.
|
||||
backend=${cfg.container.backend}
|
||||
container_name=${containerName}
|
||||
exec_user=${cfg.user}
|
||||
hermes_bin=${containerDataDir}/current-package/bin/hermes
|
||||
HERMES_CONTAINER_MODE_EOF
|
||||
# Written by NixOS activation script. Do not edit manually.
|
||||
backend=${cfg.container.backend}
|
||||
container_name=${containerName}
|
||||
exec_user=${cfg.user}
|
||||
hermes_bin=${containerDataDir}/current-package/bin/hermes
|
||||
HERMES_CONTAINER_MODE_EOF
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.container-mode
|
||||
chmod 0644 ${cfg.stateDir}/.hermes/.container-mode
|
||||
'' else ''
|
||||
@@ -796,8 +806,8 @@ HERMES_CONTAINER_MODE_EOF
|
||||
ENV_FILE="${cfg.stateDir}/.hermes/.env"
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 /dev/null "$ENV_FILE"
|
||||
cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF'
|
||||
${envFileContent}
|
||||
HERMES_NIX_ENV_EOF
|
||||
${envFileContent}
|
||||
HERMES_NIX_ENV_EOF
|
||||
${lib.concatStringsSep "\n" (map (f: ''
|
||||
if [ -f "${f}" ]; then
|
||||
echo "" >> "$ENV_FILE"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
hermes-agent = final.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default;
|
||||
rev = inputs.self.rev or null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
# Only embed clean revs — dirtyRev doesn't represent any upstream
|
||||
# commit, so comparing it would always claim "update available".
|
||||
rev = inputs.self.rev or null;
|
||||
};
|
||||
in
|
||||
{
|
||||
|
||||
+5
-3
@@ -4,15 +4,17 @@ let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE=";
|
||||
hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
|
||||
version = packageJson.version;
|
||||
in
|
||||
pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-web";
|
||||
version = "0.0.0";
|
||||
inherit src npmDeps;
|
||||
inherit src npmDeps version;
|
||||
|
||||
doCheck = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hermes Achievements contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Hermes Achievements
|
||||
|
||||
> **Bundled with Hermes Agent.** Originally authored by [@PCinkusz](https://github.com/PCinkusz) at https://github.com/PCinkusz/hermes-achievements — vendored into `plugins/hermes-achievements/` so it ships with the dashboard out-of-the-box and stays in lockstep with Hermes feature changes. Upstream repo remains the staging ground for new badges and UI iteration.
|
||||
>
|
||||
> When Hermes is installed via `pip install hermes-agent` or cloned from source, this plugin auto-registers as a dashboard tab on first `hermes dashboard` launch. No separate install step. See [Built-in Plugins → hermes-achievements](../../website/docs/user-guide/features/built-in-plugins.md) in the main docs.
|
||||
|
||||
Achievement system for the Hermes Dashboard: collectible, tiered badges generated from real local Hermes session history.
|
||||
|
||||

|
||||
|
||||
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
|
||||
|
||||
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
|
||||
|
||||
## What it does
|
||||
|
||||
Hermes Achievements scans local Hermes sessions and unlocks badges based on real agent behavior:
|
||||
|
||||
- autonomous tool chains
|
||||
- debugging and recovery patterns
|
||||
- vibe-coding file edits
|
||||
- Hermes-native skills, memory, cron, and plugin usage
|
||||
- web research and browser automation
|
||||
- model/provider workflows
|
||||
- lifestyle patterns such as weekend or night sessions
|
||||
|
||||
Achievements have three visible states:
|
||||
|
||||
- **Unlocked** — earned at least one tier
|
||||
- **Discovered** — known achievement, progress visible, not earned yet
|
||||
- **Secret** — hidden until Hermes detects the first related signal
|
||||
|
||||
Most achievements level through:
|
||||
|
||||
```text
|
||||
Copper → Silver → Gold → Diamond → Olympian
|
||||
```
|
||||
|
||||
Each card has a collapsible **What counts** section showing the exact tracked metric or requirement once the user wants details.
|
||||
|
||||
Version `0.2.x` expands the catalog to 60+ achievements, including model/provider badges such as **Five-Model Flight**, **Provider Polyglot**, **Claude Confidant**, **Gemini Cartographer**, and **Open Weights Pilgrim**.
|
||||
|
||||
## Examples
|
||||
|
||||
- Let Him Cook
|
||||
- Toolchain Maxxer
|
||||
- Red Text Connoisseur
|
||||
- Port 3000 Is Taken
|
||||
- This Was Supposed To Be Quick
|
||||
- One More Small Change
|
||||
- Skillsmith
|
||||
- Memory Keeper
|
||||
- Context Dragon
|
||||
- Plugin Goblin
|
||||
- Rabbit Hole Certified
|
||||
|
||||
## Install
|
||||
|
||||
Clone into your Hermes plugins directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
For local development, keep the repo elsewhere and symlink it:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/hermes-achievements
|
||||
ln -s ~/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
Then rescan dashboard plugins:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup.
|
||||
|
||||
## Updating
|
||||
|
||||
If you installed with git:
|
||||
|
||||
```bash
|
||||
cd ~/.hermes/plugins/hermes-achievements
|
||||
git pull --ff-only
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling.
|
||||
|
||||
As of 2026-04-29, updating is strongly recommended because scan performance changed significantly:
|
||||
- removed duplicate `/overview` scan path
|
||||
- added cached `/achievements` snapshot
|
||||
- added incremental checkpoint reuse for unchanged sessions
|
||||
|
||||
Achievement unlock state is stored locally in `state.json` and is not overwritten by git updates. New achievements are evaluated from your existing Hermes session history. Achievement IDs are stable and should not be renamed casually because they are the unlock-state keys.
|
||||
|
||||
Releases are tagged in git, for example:
|
||||
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v0.2.0
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
dashboard/
|
||||
├── manifest.json
|
||||
├── plugin_api.py
|
||||
└── dist/
|
||||
├── index.js
|
||||
└── style.css
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Routes are mounted under:
|
||||
|
||||
```text
|
||||
/api/plugins/hermes-achievements/
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
```text
|
||||
GET /achievements
|
||||
GET /scan-status
|
||||
GET /recent-unlocks
|
||||
GET /sessions/{session_id}/badges
|
||||
POST /rescan
|
||||
POST /reset-state
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run checks:
|
||||
|
||||
```bash
|
||||
node --check dashboard/dist/index.js
|
||||
python3 -m py_compile dashboard/plugin_api.py
|
||||
python3 -m unittest tests/test_achievement_engine.py -v
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+351
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
|
||||
/* hermes-achievements dashboard styles
|
||||
* Originally authored by @PCinkusz — https://github.com/PCinkusz/hermes-achievements (MIT).
|
||||
* Bundled into hermes-agent. The in-progress scan banner rules at the bottom
|
||||
* (.ha-scan-banner*) are a small addition layered on top of the original bundle.
|
||||
*/
|
||||
.ha-page { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.ha-hero { position: relative; overflow: hidden; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border: 1px solid var(--color-border); background: radial-gradient(circle at 12% 0, rgba(103,232,249,.13), transparent 30%), linear-gradient(135deg, color-mix(in srgb, var(--color-card) 88%, transparent), color-mix(in srgb, var(--color-primary) 10%, transparent)); padding: 1.25rem; }
|
||||
.ha-hero:before { content: ""; position: absolute; inset: auto -10% -80% -10%; height: 180%; pointer-events: none; background: radial-gradient(circle, rgba(242,201,76,.12), transparent 55%); }
|
||||
.ha-hero h1 { position: relative; margin: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .9; letter-spacing: -0.06em; }
|
||||
.ha-hero p { position: relative; max-width: 52rem; margin: .65rem 0 0; color: var(--color-muted-foreground); }
|
||||
.ha-kicker { position: relative; color: var(--color-muted-foreground); text-transform: uppercase; letter-spacing: .18em; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-refresh { position: relative; white-space: nowrap; }
|
||||
.ha-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; }
|
||||
.ha-stat-content { padding: 1rem !important; }
|
||||
.ha-stat-label { color: var(--color-muted-foreground); font-size: .75rem; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.ha-stat-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 750; letter-spacing: -0.035em; }
|
||||
.ha-stat-hint { margin-top: .2rem; color: var(--color-muted-foreground); font-size: .75rem; }
|
||||
.ha-toolbar { display: flex; justify-content: space-between; gap: .75rem; align-items: center; flex-wrap: wrap; }
|
||||
.ha-pills { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
.ha-pills button { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-muted-foreground); padding: .35rem .6rem; font-size: .78rem; cursor: pointer; }
|
||||
.ha-pills button.active, .ha-pills button:hover { color: var(--color-foreground); border-color: var(--ha-tier, var(--color-ring)); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
|
||||
.ha-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: .9rem; }
|
||||
.ha-card { --ha-tier: var(--color-border); position: relative; overflow: hidden; min-height: 214px; border: 1px solid color-mix(in srgb, var(--ha-tier) 46%, var(--color-border)); background: radial-gradient(circle at 2.6rem 2.2rem, color-mix(in srgb, var(--ha-tier) 16%, transparent), transparent 34%), linear-gradient(180deg, rgba(255,255,255,.04), transparent), color-mix(in srgb, var(--color-card) 92%, #000); transition: transform .16s ease, border-color .16s ease, opacity .16s ease, box-shadow .16s ease; }
|
||||
.ha-card:hover { transform: translateY(-2px); border-color: var(--ha-tier); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ha-tier) 16%, transparent); }
|
||||
.ha-card-content { position: relative; z-index: 1; padding: 1rem !important; display: flex; flex-direction: column; gap: .75rem; height: 100%; }
|
||||
.ha-card-head { display: grid; grid-template-columns: 3.1rem minmax(0, 1fr) auto; gap: .85rem; align-items: start; }
|
||||
.ha-icon { display: grid; place-items: center; width: 2.9rem; height: 2.9rem; color: var(--ha-tier); }
|
||||
.ha-lucide { width: 1.78rem; height: 1.78rem; stroke: currentColor; stroke-width: 2.15; filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ha-tier) 24%, transparent)); }
|
||||
.ha-card-title { font-weight: 780; line-height: 1.05; letter-spacing: -0.025em; }
|
||||
.ha-card-category { margin-top: .28rem; color: var(--color-muted-foreground); font-size: .76rem; }
|
||||
.ha-badges { display: flex; flex-direction: column; align-items: flex-end; gap: .25rem; }
|
||||
.ha-tier-badge, .ha-state-badge { border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .16rem .38rem; font-size: .67rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-description { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; min-height: 2.4em; }
|
||||
.ha-criteria { border: 1px solid color-mix(in srgb, var(--ha-tier) 28%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 5%, transparent); }
|
||||
.ha-criteria summary { cursor: pointer; padding: .5rem .65rem; color: var(--ha-tier); text-transform: uppercase; letter-spacing: .1em; font-size: .66rem; font-family: var(--font-mono, ui-monospace, monospace); user-select: none; }
|
||||
.ha-criteria summary:hover { background: color-mix(in srgb, var(--ha-tier) 8%, transparent); }
|
||||
.ha-criteria p { margin: 0; border-top: 1px solid color-mix(in srgb, var(--ha-tier) 18%, var(--color-border)); padding: .55rem .65rem .65rem; color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-muted-foreground)); font-size: .76rem; line-height: 1.38; }
|
||||
.ha-progress-row { display: flex; align-items: center; gap: .55rem; margin-top: 0; }
|
||||
.ha-progress-track { flex: 1; height: .48rem; border: 1px solid color-mix(in srgb, var(--ha-tier) 34%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
|
||||
.ha-progress-fill { height: 100%; background: linear-gradient(90deg, var(--ha-tier), color-mix(in srgb, var(--ha-tier) 48%, white)); }
|
||||
.ha-progress-text { min-width: 5.4rem; text-align: right; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-muted-foreground); font-size: .72rem; }
|
||||
.ha-evidence-slot { min-height: 1.65rem; margin-top: auto; display: flex; align-items: flex-end; }
|
||||
.ha-evidence { width: 100%; display: flex; align-items: center; gap: .4rem; color: var(--color-muted-foreground); font-size: .72rem; min-width: 0; }
|
||||
.ha-evidence-label { text-transform: uppercase; letter-spacing: .09em; font-family: var(--font-mono, ui-monospace, monospace); flex: 0 0 auto; }
|
||||
.ha-evidence-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--color-foreground) 84%, var(--color-muted-foreground)); }
|
||||
.ha-evidence-empty { visibility: hidden; }
|
||||
.ha-latest h2 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||
.ha-latest-row { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.ha-chip { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .35rem .55rem; font-size: .8rem; }
|
||||
.ha-chip-icon .ha-lucide { width: .95rem; height: .95rem; }
|
||||
.ha-slot { border-style: dashed; }
|
||||
.ha-slot-content { display: flex; gap: .6rem; align-items: center; padding: .65rem .8rem !important; font-size: .82rem; }
|
||||
.ha-slot-star { color: #67e8f9; }
|
||||
.ha-slot-muted { color: var(--color-muted-foreground); margin-left: auto; }
|
||||
.ha-error { border-color: #ef4444; color: #fecaca; }
|
||||
.ha-loading { color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); padding: 2rem; border: 1px dashed var(--color-border); }
|
||||
.ha-guide { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: .75rem; }
|
||||
.ha-guide > div { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 82%, transparent); padding: .85rem 1rem; }
|
||||
.ha-guide strong { display: block; margin-bottom: .45rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-guide p { margin: 0; color: var(--color-muted-foreground); font-size: .84rem; line-height: 1.45; }
|
||||
.ha-tier-legend { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
|
||||
.ha-tier-step { --ha-tier: var(--color-border); display: inline-flex; align-items: center; gap: .32rem; color: var(--ha-tier); border: 1px solid color-mix(in srgb, var(--ha-tier) 52%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .28rem .45rem; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.ha-tier-step i { width: .55rem; height: .55rem; background: var(--ha-tier); display: inline-block; }
|
||||
.ha-tier-arrow { color: var(--color-muted-foreground); }
|
||||
.ha-state-discovered { opacity: .92; }
|
||||
.ha-state-discovered .ha-card-title { color: color-mix(in srgb, var(--color-foreground) 82%, var(--ha-tier)); }
|
||||
.ha-state-secret { opacity: .5; filter: grayscale(.55); }
|
||||
.ha-state-secret:after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(-45deg, transparent 0 8px, rgba(255,255,255,.035) 8px 10px); }
|
||||
.ha-tier-pending { --ha-tier: color-mix(in srgb, var(--color-muted-foreground) 64%, transparent); }
|
||||
.ha-tier-copper { --ha-tier: #b87333; }
|
||||
.ha-tier-silver { --ha-tier: #c0c7d2; }
|
||||
.ha-tier-gold { --ha-tier: #f2c94c; box-shadow: 0 0 22px rgba(242,201,76,.08); }
|
||||
.ha-tier-diamond { --ha-tier: #67e8f9; box-shadow: 0 0 24px rgba(103,232,249,.1); }
|
||||
.ha-tier-olympian { --ha-tier: #c084fc; box-shadow: 0 0 34px rgba(192,132,252,.18), 0 0 12px rgba(242,201,76,.1); }
|
||||
@media (max-width: 980px) { .ha-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ha-guide { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 800px) { .ha-stats { grid-template-columns: 1fr; } .ha-hero { flex-direction: column; align-items: stretch; } .ha-card-head { grid-template-columns: 3.1rem 1fr; } .ha-badges { grid-column: 1 / -1; align-items: flex-start; flex-direction: row; } }
|
||||
|
||||
.ha-secret-empty-content { padding: 1rem !important; }
|
||||
.ha-secret-empty strong { display: block; margin-bottom: .35rem; }
|
||||
.ha-secret-empty p { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; }
|
||||
.ha-page-loading { animation: ha-fade-in .18s ease-out; }
|
||||
.ha-loading-hero { align-items: center; }
|
||||
.ha-scan-status { position: relative; z-index: 1; display: flex; align-items: center; gap: .8rem; min-width: 18rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; color: var(--color-foreground); }
|
||||
.ha-scan-status strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-scan-status p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); }
|
||||
.ha-scan-pulse { width: .72rem; height: .72rem; flex: 0 0 auto; border-radius: 999px; background: #67e8f9; box-shadow: 0 0 0 0 rgba(103,232,249,.55); animation: ha-pulse 1.35s ease-out infinite; }
|
||||
.ha-skeleton-card { pointer-events: none; }
|
||||
.ha-skeleton { position: relative; overflow: hidden; border-radius: 0; background: color-mix(in srgb, var(--color-muted-foreground) 16%, transparent); }
|
||||
.ha-skeleton:after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.14), transparent); animation: ha-shimmer 1.35s infinite; }
|
||||
.ha-skeleton-stack { display: flex; flex-direction: column; gap: .45rem; padding-top: .15rem; }
|
||||
.ha-skeleton-icon { width: 2.9rem; height: 2.9rem; }
|
||||
.ha-skeleton-title { width: 72%; height: .95rem; }
|
||||
.ha-skeleton-meta { width: 45%; height: .65rem; }
|
||||
.ha-skeleton-badge { width: 4.4rem; height: 1.05rem; }
|
||||
.ha-skeleton-badge-short { width: 3.6rem; }
|
||||
.ha-skeleton-line { height: .78rem; width: 92%; }
|
||||
.ha-skeleton-line-short { width: 68%; }
|
||||
.ha-skeleton-criteria { height: 2.2rem; width: 100%; border: 1px solid color-mix(in srgb, var(--color-muted-foreground) 18%, var(--color-border)); }
|
||||
.ha-skeleton-evidence { width: 58%; height: .8rem; }
|
||||
.ha-skeleton-progress { flex: 1; height: .48rem; }
|
||||
.ha-skeleton-progress-text { width: 4.6rem; height: .75rem; }
|
||||
.ha-skeleton-stat-value { width: 56%; height: 1.35rem; margin-top: .55rem; }
|
||||
.ha-skeleton-stat-hint { width: 76%; height: .7rem; margin-top: .55rem; }
|
||||
.ha-loading-guide p { color: var(--color-muted-foreground); }
|
||||
@keyframes ha-shimmer { 100% { transform: translateX(100%); } }
|
||||
@keyframes ha-pulse { 0% { box-shadow: 0 0 0 0 rgba(103,232,249,.48); } 70% { box-shadow: 0 0 0 .65rem rgba(103,232,249,0); } 100% { box-shadow: 0 0 0 0 rgba(103,232,249,0); } }
|
||||
@keyframes ha-fade-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.ha-loading-hero p, .ha-scan-status p, .ha-loading-guide p { text-transform: none; letter-spacing: normal; }
|
||||
|
||||
/* In-progress scan banner — shown on the main page while the background scan
|
||||
* is still walking through session history, so the user sees continuous
|
||||
* progress (X / Y sessions · Z%) instead of guessing whether anything is
|
||||
* happening. Reuses .ha-scan-pulse + ha-pulse keyframes from the loading page.
|
||||
*/
|
||||
.ha-scan-banner { display: flex; flex-direction: column; gap: .6rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; animation: ha-fade-in .18s ease-out; }
|
||||
.ha-scan-banner-head { display: flex; align-items: center; gap: .8rem; }
|
||||
.ha-scan-banner-text strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
|
||||
.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; }
|
||||
.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
|
||||
.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; }
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "hermes-achievements",
|
||||
"label": "Achievements",
|
||||
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
|
||||
"icon": "Star",
|
||||
"version": "0.3.1",
|
||||
"tab": { "path": "/achievements", "position": "after:analytics" },
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
"api": "plugin_api.py"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user