mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 02:37:05 +08:00
Compare commits
135 Commits
dependabot
...
bb/gui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9987f1e22 | ||
|
|
dda3894523 | ||
|
|
ddf83e95b0 | ||
|
|
5269012c51 | ||
|
|
5ec0667fb3 | ||
|
|
87b113c2e3 | ||
|
|
60235dba5e | ||
|
|
cc2c820975 | ||
|
|
e8e9147377 | ||
|
|
dbe9b15fa1 | ||
|
|
f8ba265340 | ||
|
|
8c0f254c06 | ||
|
|
244bacd0dc | ||
|
|
4553e32bc4 | ||
|
|
a877c3f6d9 | ||
|
|
77a102b7de | ||
|
|
526742199b | ||
|
|
12135b4c8a | ||
|
|
0120d8f31e | ||
|
|
d9f0875591 | ||
|
|
247c9d468c | ||
|
|
935cf2fcca | ||
|
|
6430d67569 | ||
|
|
269be4ec84 | ||
|
|
d8097d587f | ||
|
|
c62d8c9b74 | ||
|
|
dad62c4c47 | ||
|
|
45949e944a | ||
|
|
e4e0090b54 | ||
|
|
5795b3be4e | ||
|
|
e805380b82 | ||
|
|
ecc909de38 | ||
|
|
db84c1535d | ||
|
|
8e18d10318 | ||
|
|
b014a3d315 | ||
|
|
969bfff449 | ||
|
|
de9238d37e | ||
|
|
7de3c86c5a | ||
|
|
b7bd177105 | ||
|
|
7530ce04e0 | ||
|
|
02147cc850 | ||
|
|
8ebb81fd76 | ||
|
|
c46bc92949 | ||
|
|
fb311952d7 | ||
|
|
285c208cf7 | ||
|
|
3b16c590e0 | ||
|
|
349d0da07e | ||
|
|
4e6f51167d | ||
|
|
37b5731694 | ||
|
|
f6677748a0 | ||
|
|
f844e516d8 | ||
|
|
19eebf6e0d | ||
|
|
96514de472 | ||
|
|
c7fc5af122 | ||
|
|
80b386a472 | ||
|
|
314361733f | ||
|
|
7f735b4db2 | ||
|
|
34c6f93496 | ||
|
|
c1a2710a32 | ||
|
|
9e893d16d1 | ||
|
|
44cf33449d | ||
|
|
005b2f4c5d | ||
|
|
f15b0fbb4f | ||
|
|
b10e38e392 | ||
|
|
fe8560fc12 | ||
|
|
436672de0e | ||
|
|
4f76166cf0 | ||
|
|
0a7cc85eab | ||
|
|
046c293183 | ||
|
|
8f4c0bf088 | ||
|
|
83a07f4759 | ||
|
|
9e0ef2a1bc | ||
|
|
efe1cb00c8 | ||
|
|
4577f392f9 | ||
|
|
6b76ea4707 | ||
|
|
354502ee48 | ||
|
|
cca8587d35 | ||
|
|
4d0f59fa5a | ||
|
|
68c1a08ad1 | ||
|
|
5168226d60 | ||
|
|
b93643c8fe | ||
|
|
2eef395e1c | ||
|
|
c725d7d648 | ||
|
|
660ce7c54b | ||
|
|
1a03e3b1c6 | ||
|
|
f6b68f0f50 | ||
|
|
aacf36e943 | ||
|
|
fe8dc26bc9 | ||
|
|
4a3e3e20e5 | ||
|
|
f8a6db68ca | ||
|
|
b22b3f506a | ||
|
|
d472d697cd | ||
|
|
8c82d0664d | ||
|
|
2a285d5ec2 | ||
|
|
28f4d6db63 | ||
|
|
542e06c789 | ||
|
|
fc4aa66ee4 | ||
|
|
f25d3ec917 | ||
|
|
ca5595fe7b | ||
|
|
91ce8fc000 | ||
|
|
8ad5e98f8d | ||
|
|
2785355750 | ||
|
|
c3112adac5 | ||
|
|
13a7cbcd64 | ||
|
|
3aabae20eb | ||
|
|
2964f25534 | ||
|
|
b352e8ed17 | ||
|
|
301c698491 | ||
|
|
023730314b | ||
|
|
fcce49db3f | ||
|
|
42db075e10 | ||
|
|
74127e0c48 | ||
|
|
64a63d0d2b | ||
|
|
12307a66e0 | ||
|
|
5f334e86fd | ||
|
|
d1d0ed4016 | ||
|
|
ca8f2c7907 | ||
|
|
27c5fa5381 | ||
|
|
9ca5ea1375 | ||
|
|
fa92720d2c | ||
|
|
fd97a7cba4 | ||
|
|
6dcf5bcbc0 | ||
|
|
a66303eaef | ||
|
|
5e4473df96 | ||
|
|
215bf4b96c | ||
|
|
db884f4646 | ||
|
|
420f68e4e2 | ||
|
|
935970898f | ||
|
|
322cc94c98 | ||
|
|
cd381d6ba5 | ||
|
|
e00297782d | ||
|
|
d5d7b5c6dc | ||
|
|
9f3d393a4d | ||
|
|
6c624f197c | ||
|
|
7b61f86529 |
@@ -384,9 +384,9 @@ IMAGE_TOOLS_DEBUG=false
|
|||||||
# Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed.
|
# Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed.
|
||||||
# Install with: pip install faster-whisper
|
# Install with: pip install faster-whisper
|
||||||
# Model downloads automatically on first use (~150 MB for "base").
|
# Model downloads automatically on first use (~150 MB for "base").
|
||||||
# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.
|
# To use cloud providers instead, set GROQ_API_KEY, VOICE_TOOLS_OPENAI_KEY, or ELEVENLABS_API_KEY above.
|
||||||
# Provider priority: local > groq > openai
|
# Provider priority: local > groq > openai > mistral > xai > elevenlabs
|
||||||
# Configure in config.yaml: stt.provider: local | groq | openai
|
# Configure in config.yaml: stt.provider: local | groq | openai | mistral | xai | elevenlabs
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STT ADVANCED OVERRIDES (optional)
|
# STT ADVANCED OVERRIDES (optional)
|
||||||
@@ -394,10 +394,12 @@ IMAGE_TOOLS_DEBUG=false
|
|||||||
# Override default STT models per provider (normally set via stt.model in config.yaml)
|
# Override default STT models per provider (normally set via stt.model in config.yaml)
|
||||||
# STT_GROQ_MODEL=whisper-large-v3-turbo
|
# STT_GROQ_MODEL=whisper-large-v3-turbo
|
||||||
# STT_OPENAI_MODEL=whisper-1
|
# STT_OPENAI_MODEL=whisper-1
|
||||||
|
# STT_ELEVENLABS_MODEL=scribe_v2
|
||||||
|
|
||||||
# Override STT provider endpoints (for proxies or self-hosted instances)
|
# Override STT provider endpoints (for proxies or self-hosted instances)
|
||||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||||
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
|
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# ELEVENLABS_STT_BASE_URL=https://api.elevenlabs.io/v1
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MICROSOFT TEAMS INTEGRATION
|
# MICROSOFT TEAMS INTEGRATION
|
||||||
|
|||||||
341
.github/workflows/desktop-release.yml
vendored
Normal file
341
.github/workflows/desktop-release.yml
vendored
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
name: Desktop Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
channel:
|
||||||
|
description: Release channel to build
|
||||||
|
required: true
|
||||||
|
default: nightly
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- nightly
|
||||||
|
- stable
|
||||||
|
release_tag:
|
||||||
|
description: "Required when channel=stable (example: v2026.5.5)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
if: github.repository == 'NousResearch/hermes-agent'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
channel: ${{ steps.meta.outputs.channel }}
|
||||||
|
release_name: ${{ steps.meta.outputs.release_name }}
|
||||||
|
release_tag: ${{ steps.meta.outputs.release_tag }}
|
||||||
|
version: ${{ steps.meta.outputs.version }}
|
||||||
|
is_stable: ${{ steps.meta.outputs.is_stable }}
|
||||||
|
steps:
|
||||||
|
- id: meta
|
||||||
|
env:
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
INPUT_CHANNEL: ${{ github.event.inputs.channel }}
|
||||||
|
INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||||
|
RELEASE_TAG_FROM_EVENT: ${{ github.event.release.tag_name }}
|
||||||
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
channel="nightly"
|
||||||
|
release_tag="desktop-nightly"
|
||||||
|
is_stable="false"
|
||||||
|
|
||||||
|
if [[ "$EVENT_NAME" == "release" ]]; then
|
||||||
|
channel="stable"
|
||||||
|
release_tag="$RELEASE_TAG_FROM_EVENT"
|
||||||
|
is_stable="true"
|
||||||
|
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_CHANNEL" == "stable" ]]; then
|
||||||
|
channel="stable"
|
||||||
|
release_tag="$INPUT_RELEASE_TAG"
|
||||||
|
is_stable="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$channel" == "stable" ]]; then
|
||||||
|
if [[ -z "$release_tag" ]]; then
|
||||||
|
echo "Stable desktop releases require a release tag." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
version="${release_tag#v}"
|
||||||
|
release_name="Hermes Desktop ${release_tag}"
|
||||||
|
else
|
||||||
|
stamp="$(date -u +%Y%m%d)"
|
||||||
|
short_sha="${GITHUB_SHA::7}"
|
||||||
|
version="0.0.0-nightly.${stamp}.${short_sha}"
|
||||||
|
release_name="Hermes Desktop Nightly ${stamp}-${short_sha}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "channel=$channel"
|
||||||
|
echo "release_name=$release_name"
|
||||||
|
echo "release_tag=$release_tag"
|
||||||
|
echo "version=$version"
|
||||||
|
echo "is_stable=$is_stable"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
build:
|
||||||
|
if: github.repository == 'NousResearch/hermes-agent'
|
||||||
|
needs: prepare
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: mac
|
||||||
|
runner: macos-latest
|
||||||
|
build_args: --mac dmg zip
|
||||||
|
- platform: win
|
||||||
|
runner: windows-latest
|
||||||
|
build_args: --win nsis msi
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
env:
|
||||||
|
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
|
||||||
|
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
|
MAC_CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||||
|
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Enforce signing gates for stable releases
|
||||||
|
if: needs.prepare.outputs.is_stable == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
missing=()
|
||||||
|
|
||||||
|
if [[ "${{ matrix.platform }}" == "mac" ]]; then
|
||||||
|
[[ -z "${MAC_CSC_LINK:-}" ]] && missing+=("CSC_LINK")
|
||||||
|
[[ -z "${MAC_CSC_KEY_PASSWORD:-}" ]] && missing+=("CSC_KEY_PASSWORD")
|
||||||
|
[[ -z "${APPLE_API_KEY:-}" ]] && missing+=("APPLE_API_KEY")
|
||||||
|
[[ -z "${APPLE_API_KEY_ID:-}" ]] && missing+=("APPLE_API_KEY_ID")
|
||||||
|
[[ -z "${APPLE_API_ISSUER:-}" ]] && missing+=("APPLE_API_ISSUER")
|
||||||
|
else
|
||||||
|
[[ -z "${WIN_CSC_LINK:-}" ]] && missing+=("WIN_CSC_LINK")
|
||||||
|
[[ -z "${WIN_CSC_KEY_PASSWORD:-}" ]] && missing+=("WIN_CSC_KEY_PASSWORD")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#missing[@]} > 0 )); then
|
||||||
|
echo "::error::Stable desktop release missing required secrets: ${missing[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install workspace dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build bundled TUI payload
|
||||||
|
run: npm --prefix ui-tui run build
|
||||||
|
|
||||||
|
- name: Build desktop renderer
|
||||||
|
run: npm --prefix apps/desktop run build
|
||||||
|
|
||||||
|
- name: Stage Hermes payload
|
||||||
|
run: npm --prefix apps/desktop run stage:hermes
|
||||||
|
|
||||||
|
- name: Map macOS signing credentials
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
has_link=0
|
||||||
|
has_pass=0
|
||||||
|
[[ -n "${MAC_CSC_LINK:-}" ]] && has_link=1
|
||||||
|
[[ -n "${MAC_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
|
||||||
|
|
||||||
|
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
|
||||||
|
echo "CSC_LINK=${MAC_CSC_LINK}" >> "$GITHUB_ENV"
|
||||||
|
echo "CSC_KEY_PASSWORD=${MAC_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
|
||||||
|
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
|
||||||
|
echo "::error::macOS signing secrets are partially configured. Set both CSC_LINK and CSC_KEY_PASSWORD."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Map Windows signing credentials
|
||||||
|
if: matrix.platform == 'win'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
has_link=0
|
||||||
|
has_pass=0
|
||||||
|
[[ -n "${WIN_CSC_LINK:-}" ]] && has_link=1
|
||||||
|
[[ -n "${WIN_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
|
||||||
|
|
||||||
|
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
|
||||||
|
echo "CSC_LINK=${WIN_CSC_LINK}" >> "$GITHUB_ENV"
|
||||||
|
echo "CSC_KEY_PASSWORD=${WIN_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
|
||||||
|
echo "CSC_FOR_PULL_REQUEST=true" >> "$GITHUB_ENV"
|
||||||
|
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
|
||||||
|
echo "::error::Windows signing secrets are partially configured. Set both WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build desktop installers
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm --prefix apps/desktop exec electron-builder -- \
|
||||||
|
${{ matrix.build_args }} \
|
||||||
|
--publish never \
|
||||||
|
--config.extraMetadata.version="${DESKTOP_VERSION}" \
|
||||||
|
--config.extraMetadata.desktopChannel="${DESKTOP_CHANNEL}" \
|
||||||
|
'--config.artifactName=Hermes-${version}-${env.DESKTOP_CHANNEL}-${os}-${arch}.${ext}'
|
||||||
|
|
||||||
|
- name: Notarize and staple macOS DMG
|
||||||
|
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
|
||||||
|
node apps/desktop/scripts/notarize-artifact.cjs "$dmg_path"
|
||||||
|
|
||||||
|
- name: Validate macOS notarization and Gatekeeper trust
|
||||||
|
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
app_path="$(ls -d apps/desktop/release/mac*/Hermes.app | head -n 1)"
|
||||||
|
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
|
||||||
|
xcrun stapler validate "$app_path"
|
||||||
|
xcrun stapler validate "$dmg_path"
|
||||||
|
spctl --assess --type execute --verbose=4 "$app_path"
|
||||||
|
|
||||||
|
- name: Generate desktop checksums
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node <<'EOF'
|
||||||
|
const crypto = require('node:crypto')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const releaseDir = path.resolve('apps/desktop/release')
|
||||||
|
const platform = process.env.PLATFORM
|
||||||
|
const extensions = platform === 'mac' ? ['.dmg', '.zip'] : ['.exe', '.msi']
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(releaseDir)
|
||||||
|
.filter(name => extensions.some(ext => name.endsWith(ext)))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
throw new Error(`No release artifacts were produced for ${platform}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = files.map(name => {
|
||||||
|
const full = path.join(releaseDir, name)
|
||||||
|
const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')
|
||||||
|
return `${hash} ${name}`
|
||||||
|
})
|
||||||
|
fs.writeFileSync(path.join(releaseDir, `SHA256SUMS-${platform}.txt`), `${lines.join('\n')}\n`)
|
||||||
|
EOF
|
||||||
|
env:
|
||||||
|
PLATFORM: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Upload packaged desktop artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: desktop-${{ matrix.platform }}
|
||||||
|
path: |
|
||||||
|
apps/desktop/release/*.dmg
|
||||||
|
apps/desktop/release/*.zip
|
||||||
|
apps/desktop/release/*.exe
|
||||||
|
apps/desktop/release/*.msi
|
||||||
|
apps/desktop/release/SHA256SUMS-${{ matrix.platform }}.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
if: github.repository == 'NousResearch/hermes-agent'
|
||||||
|
needs: [prepare, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
CHANNEL: ${{ needs.prepare.outputs.channel }}
|
||||||
|
RELEASE_NAME: ${{ needs.prepare.outputs.release_name }}
|
||||||
|
RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
|
with:
|
||||||
|
pattern: desktop-*
|
||||||
|
merge-multiple: true
|
||||||
|
path: dist/desktop
|
||||||
|
|
||||||
|
- name: Publish desktop assets to GitHub release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s globstar nullglob
|
||||||
|
|
||||||
|
files=(
|
||||||
|
dist/desktop/**/*.dmg
|
||||||
|
dist/desktop/**/*.zip
|
||||||
|
dist/desktop/**/*.exe
|
||||||
|
dist/desktop/**/*.msi
|
||||||
|
dist/desktop/**/SHA256SUMS-*.txt
|
||||||
|
)
|
||||||
|
|
||||||
|
if (( ${#files[@]} == 0 )); then
|
||||||
|
echo "No desktop artifacts were downloaded for publishing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||||
|
git tag -f "$RELEASE_TAG" "$GITHUB_SHA"
|
||||||
|
git push origin "refs/tags/$RELEASE_TAG" --force
|
||||||
|
|
||||||
|
notes="Automated nightly desktop build from main. This prerelease is replaced on each new run."
|
||||||
|
|
||||||
|
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||||
|
while IFS= read -r asset_name; do
|
||||||
|
gh release delete-asset "$RELEASE_TAG" "$asset_name" --yes
|
||||||
|
done < <(gh release view "$RELEASE_TAG" --json assets -q '.assets[].name')
|
||||||
|
|
||||||
|
gh release edit "$RELEASE_TAG" \
|
||||||
|
--title "$RELEASE_NAME" \
|
||||||
|
--prerelease \
|
||||||
|
--notes "$notes"
|
||||||
|
else
|
||||||
|
gh release create "$RELEASE_TAG" \
|
||||||
|
--target "$GITHUB_SHA" \
|
||||||
|
--title "$RELEASE_NAME" \
|
||||||
|
--notes "$notes" \
|
||||||
|
--prerelease
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||||
|
notes="Automated desktop artifacts attached by desktop-release workflow."
|
||||||
|
gh release create "$RELEASE_TAG" \
|
||||||
|
--target "$GITHUB_SHA" \
|
||||||
|
--title "$RELEASE_NAME" \
|
||||||
|
--notes "$notes"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
|
||||||
8
.github/workflows/nix-lockfile-fix.yml
vendored
8
.github/workflows/nix-lockfile-fix.yml
vendored
@@ -6,8 +6,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'ui-tui/package-lock.json'
|
- 'ui-tui/package-lock.json'
|
||||||
- 'ui-tui/package.json'
|
- 'ui-tui/package.json'
|
||||||
- 'web/package-lock.json'
|
- 'apps/dashboard/package-lock.json'
|
||||||
- 'web/package.json'
|
- 'apps/dashboard/package.json'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
pr_number:
|
pr_number:
|
||||||
@@ -28,7 +28,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||||
# Fires when a push to main touches package.json or package-lock.json
|
# Fires when a push to main touches package.json or package-lock.json
|
||||||
# in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash
|
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
|
||||||
# update commit directly to main so Nix builds never stay broken.
|
# update commit directly to main so Nix builds never stay broken.
|
||||||
#
|
#
|
||||||
# Safety invariants:
|
# Safety invariants:
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
# run recompute from the correct package-lock state.
|
# run recompute from the correct package-lock state.
|
||||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
||||||
'web/package-lock.json' 'web/package.json' || true)"
|
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
|
||||||
if [ -n "$pkg_changed" ]; then
|
if [ -n "$pkg_changed" ]; then
|
||||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -54,6 +54,10 @@ environments/benchmarks/evals/
|
|||||||
|
|
||||||
# Web UI build output
|
# Web UI build output
|
||||||
hermes_cli/web_dist/
|
hermes_cli/web_dist/
|
||||||
|
apps/desktop/build/
|
||||||
|
apps/desktop/dist/
|
||||||
|
apps/desktop/release/
|
||||||
|
apps/desktop/*.tsbuildinfo
|
||||||
|
|
||||||
# Web UI assets — synced from @nous-research/ui at build time via
|
# Web UI assets — synced from @nous-research/ui at build time via
|
||||||
# `npm run sync-assets` (see web/package.json).
|
# `npm run sync-assets` (see web/package.json).
|
||||||
@@ -70,3 +74,12 @@ mini-swe-agent/
|
|||||||
result
|
result
|
||||||
website/static/api/skills-index.json
|
website/static/api/skills-index.json
|
||||||
models-dev-upstream/
|
models-dev-upstream/
|
||||||
|
|
||||||
|
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||||
|
.codex/
|
||||||
|
.cursor/
|
||||||
|
.gemini/
|
||||||
|
.zed/
|
||||||
|
.mcp.json
|
||||||
|
opencode.json
|
||||||
|
config/mcporter.json
|
||||||
|
|||||||
231
AGENTS.md
231
AGENTS.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
|
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
|
||||||
|
|
||||||
|
**Never give up on the right solution.**
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -37,12 +39,17 @@ hermes-agent/
|
|||||||
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
|
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
|
||||||
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
|
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
|
||||||
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
|
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
|
||||||
│ │ # webhook, api_server, ...). See ADDING_A_PLATFORM.md.
|
│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
|
||||||
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
|
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
|
||||||
├── plugins/ # Plugin system (see "Plugins" section below)
|
├── plugins/ # Plugin system (see "Plugins" section below)
|
||||||
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
|
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
|
||||||
│ ├── context_engine/ # Context-engine plugins
|
│ ├── context_engine/ # Context-engine plugins
|
||||||
│ └── <others>/ # Dashboard, image-gen, disk-cleanup, examples, ...
|
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
|
||||||
|
│ ├── hermes-achievements/ # Gamified achievement tracking
|
||||||
|
│ ├── observability/ # Metrics / traces / logs plugin
|
||||||
|
│ ├── image_gen/ # Image-generation providers
|
||||||
|
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
|
||||||
|
│ # spotify, strike-freedom-cockpit, ...
|
||||||
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
|
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
|
||||||
├── skills/ # Built-in skills bundled with the repo
|
├── skills/ # Built-in skills bundled with the repo
|
||||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||||
@@ -53,7 +60,7 @@ hermes-agent/
|
|||||||
├── environments/ # RL training environments (Atropos)
|
├── environments/ # RL training environments (Atropos)
|
||||||
├── scripts/ # run_tests.sh, release.py, auxiliary scripts
|
├── scripts/ # run_tests.sh, release.py, auxiliary scripts
|
||||||
├── website/ # Docusaurus docs site
|
├── website/ # Docusaurus docs site
|
||||||
└── tests/ # Pytest suite (~15k tests across ~700 files as of Apr 2026)
|
└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
|
||||||
```
|
```
|
||||||
|
|
||||||
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
|
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
|
||||||
@@ -61,6 +68,29 @@ hermes-agent/
|
|||||||
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
|
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
|
||||||
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
|
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
|
||||||
|
|
||||||
|
## TypeScript Style
|
||||||
|
|
||||||
|
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
|
||||||
|
|
||||||
|
- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
|
||||||
|
- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
|
||||||
|
- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
|
||||||
|
- Do not pass state through three components when the leaf can subscribe to the atom.
|
||||||
|
- Keep persistence beside the atom that owns it.
|
||||||
|
- Keep route roots thin. They compose routes and shell; they should not become controllers.
|
||||||
|
- No monolithic hooks. A hook should own one narrow job.
|
||||||
|
- Prefer colocated action modules over hidden god hooks.
|
||||||
|
- If a callback is pure side effect, use the terse void form:
|
||||||
|
`onState={st => void setGatewayState(st)}`.
|
||||||
|
- Async UI handlers should make intent explicit:
|
||||||
|
`onClick={() => void save()}`.
|
||||||
|
- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
|
||||||
|
- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
|
||||||
|
- Table-driven beats condition ladders when mapping ids, routes, or views.
|
||||||
|
- `src/app` owns routes, pages, and page-specific components.
|
||||||
|
- `src/store` owns shared atoms.
|
||||||
|
- `src/lib` owns shared pure helpers.
|
||||||
|
|
||||||
## File Dependency Chain
|
## File Dependency Chain
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -244,7 +274,7 @@ npm test # vitest
|
|||||||
|
|
||||||
The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
|
The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
|
||||||
|
|
||||||
- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
|
- Browser loads `apps/dashboard/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
|
||||||
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
|
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
|
||||||
- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
|
- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
|
||||||
- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
|
- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
|
||||||
@@ -289,9 +319,9 @@ registry.register(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
|
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
|
||||||
|
|
||||||
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
|
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
|
||||||
|
|
||||||
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
|
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
|
||||||
|
|
||||||
@@ -313,6 +343,22 @@ The registry handles schema collection, dispatch, availability checking, and err
|
|||||||
section is handled automatically by the deep-merge and does NOT require
|
section is handled automatically by the deep-merge and does NOT require
|
||||||
a version bump.
|
a version bump.
|
||||||
|
|
||||||
|
### Top-level `config.yaml` sections (non-exhaustive):
|
||||||
|
|
||||||
|
`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
|
||||||
|
`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
|
||||||
|
`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
|
||||||
|
`plugins`, `honcho`.
|
||||||
|
|
||||||
|
`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
|
||||||
|
embedding, title generation, session_search, etc.) — each task can pin
|
||||||
|
its own provider/model/base_url/max_tokens/reasoning_effort. See
|
||||||
|
`agent/auxiliary_client.py::_resolve_auto` for resolution order.
|
||||||
|
|
||||||
|
`curator` holds the background skill-maintenance config —
|
||||||
|
`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
|
||||||
|
`archive_after_days`, `backup` (nested).
|
||||||
|
|
||||||
### .env variables (SECRETS ONLY — API keys, tokens, passwords):
|
### .env variables (SECRETS ONLY — API keys, tokens, passwords):
|
||||||
1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
|
1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
|
||||||
```python
|
```python
|
||||||
@@ -519,11 +565,176 @@ niche skills belong in `optional-skills/`.
|
|||||||
|
|
||||||
### SKILL.md frontmatter
|
### SKILL.md frontmatter
|
||||||
|
|
||||||
Standard fields: `name`, `description`, `version`, `platforms`
|
Standard fields: `name`, `description`, `version`, `author`, `license`,
|
||||||
(OS-gating list: `[macos]`, `[linux, macos]`, ...),
|
`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
|
||||||
`metadata.hermes.tags`, `metadata.hermes.category`,
|
`metadata.hermes.tags`, `metadata.hermes.category`,
|
||||||
`metadata.hermes.config` (config.yaml settings the skill needs — stored
|
`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
|
||||||
under `skills.config.<key>`, prompted during setup, injected at load time).
|
settings the skill needs — stored under `skills.config.<key>`, prompted
|
||||||
|
during setup, injected at load time).
|
||||||
|
|
||||||
|
Top-level `tags:` and `category:` are also accepted and mirrored from
|
||||||
|
`metadata.hermes.*` by the loader.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toolsets
|
||||||
|
|
||||||
|
All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
|
||||||
|
Each platform's adapter picks a base toolset (e.g. Telegram uses
|
||||||
|
`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
|
||||||
|
platforms inherit from.
|
||||||
|
|
||||||
|
Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
|
||||||
|
`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
|
||||||
|
`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
|
||||||
|
`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
|
||||||
|
`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
|
||||||
|
|
||||||
|
Enable/disable per platform via `hermes tools` (the curses UI) or the
|
||||||
|
`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
|
||||||
|
`config.yaml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delegation (`delegate_task`)
|
||||||
|
|
||||||
|
`tools/delegate_tool.py` spawns a subagent with an isolated
|
||||||
|
context + terminal session. Synchronous: the parent waits for the
|
||||||
|
child's summary before continuing its own loop — if the parent is
|
||||||
|
interrupted, the child is cancelled.
|
||||||
|
|
||||||
|
Two shapes:
|
||||||
|
|
||||||
|
- **Single:** pass `goal` (+ optional `context`, `toolsets`).
|
||||||
|
- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
|
||||||
|
running concurrently. Concurrency is capped by
|
||||||
|
`delegation.max_concurrent_children` (default 3).
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
|
||||||
|
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
|
||||||
|
`clarify`, `memory`, `send_message`, `execute_code`.
|
||||||
|
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
|
||||||
|
own workers. Gated by `delegation.orchestrator_enabled` (default true)
|
||||||
|
and bounded by `delegation.max_spawn_depth` (default 2).
|
||||||
|
|
||||||
|
Key config knobs (under `delegation:` in `config.yaml`):
|
||||||
|
`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
|
||||||
|
`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
|
||||||
|
`max_iterations`.
|
||||||
|
|
||||||
|
Synchronicity rule: delegate_task is **not** durable. For long-running
|
||||||
|
work that must outlive the current turn, use `cronjob` or
|
||||||
|
`terminal(background=True, notify_on_complete=True)` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Curator (skill lifecycle)
|
||||||
|
|
||||||
|
Background skill-maintenance system that tracks usage on agent-created
|
||||||
|
skills and auto-archives stale ones. Users never lose skills; archives
|
||||||
|
go to `~/.hermes/skills/.archive/` and are restorable.
|
||||||
|
|
||||||
|
- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
|
||||||
|
prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
|
||||||
|
- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
|
||||||
|
verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
|
||||||
|
`archive`, `restore`, `prune`, `backup`, `rollback`.
|
||||||
|
- **Telemetry:** `tools/skill_usage.py` owns the sidecar
|
||||||
|
`~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
|
||||||
|
`patch_count`, `last_activity_at`, `state` (active / stale /
|
||||||
|
archived), `pinned`.
|
||||||
|
|
||||||
|
Invariants:
|
||||||
|
- Curator only touches skills with `created_by: "agent"` provenance —
|
||||||
|
bundled + hub-installed skills are off-limits.
|
||||||
|
- Never deletes; max destructive action is archive.
|
||||||
|
- Pinned skills are exempt from every auto-transition and from the
|
||||||
|
LLM review pass.
|
||||||
|
- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
|
||||||
|
write_file/remove_file go through so the agent can keep improving
|
||||||
|
pinned skills.
|
||||||
|
|
||||||
|
Config section (`curator:` in `config.yaml`):
|
||||||
|
`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
|
||||||
|
`archive_after_days`, `backup.*`.
|
||||||
|
|
||||||
|
Full user-facing docs: `website/docs/user-guide/features/curator.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cron (scheduled jobs)
|
||||||
|
|
||||||
|
`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
|
||||||
|
schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
|
||||||
|
(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
|
||||||
|
`/cron` slash command.
|
||||||
|
|
||||||
|
Supported schedule formats:
|
||||||
|
- Duration: `"30m"`, `"2h"`, `"1d"`
|
||||||
|
- "every" phrase: `"every 2h"`, `"every monday 9am"`
|
||||||
|
- 5-field cron expression: `"0 9 * * *"`
|
||||||
|
- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
|
||||||
|
|
||||||
|
Per-job fields include `skills` (load specific skills), `model` /
|
||||||
|
`provider` overrides, `script` (pre-run data-collection script whose
|
||||||
|
stdout is injected into the prompt; `no_agent=True` turns the script
|
||||||
|
into the entire job), `context_from` (chain job A's last output into
|
||||||
|
job B's prompt), `workdir` (run in a specific directory with its
|
||||||
|
`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
|
||||||
|
|
||||||
|
Hardening invariants:
|
||||||
|
- **3-minute hard interrupt** on cron sessions — runaway agent loops
|
||||||
|
cannot monopolize the scheduler.
|
||||||
|
- Catchup window: half the job's period, clamped to 120s–2h.
|
||||||
|
- Grace window: 120s for one-shot jobs whose fire time was missed.
|
||||||
|
- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
|
||||||
|
across processes.
|
||||||
|
- Cron sessions pass `skip_memory=True` by default; memory providers
|
||||||
|
intentionally do not run during cron.
|
||||||
|
|
||||||
|
Cron deliveries are **not** mirrored into the target gateway session —
|
||||||
|
they land in their own cron session with a header/footer frame so the
|
||||||
|
main conversation's message-role alternation stays intact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kanban (multi-agent work queue)
|
||||||
|
|
||||||
|
Durable SQLite-backed board that lets multiple profiles / workers
|
||||||
|
collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
|
||||||
|
workers spawned by the dispatcher drive it via a dedicated `kanban_*`
|
||||||
|
toolset so their schema footprint is zero when they're not inside a
|
||||||
|
kanban task.
|
||||||
|
|
||||||
|
- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
|
||||||
|
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
|
||||||
|
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
|
||||||
|
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
|
||||||
|
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
|
||||||
|
- **Worker toolset:** `tools/kanban_tools.py` exposes `kanban_show`,
|
||||||
|
`kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`,
|
||||||
|
`kanban_create`, `kanban_link` — gated by `HERMES_KANBAN_TASK` so
|
||||||
|
the schema only appears for processes actually running as a worker.
|
||||||
|
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
|
||||||
|
stale claims, promotes ready tasks, atomically claims, and spawns
|
||||||
|
assigned profiles. Runs **inside the gateway** by default via
|
||||||
|
`kanban.dispatch_in_gateway: true`.
|
||||||
|
- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
|
||||||
|
`plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
|
||||||
|
standalone dispatcher deployment).
|
||||||
|
|
||||||
|
Isolation model:
|
||||||
|
- **Board** is the hard boundary — workers are spawned with
|
||||||
|
`HERMES_KANBAN_BOARD` pinned in their env so they can't see other
|
||||||
|
boards.
|
||||||
|
- **Tenant** is a soft namespace *within* a board — one specialist
|
||||||
|
fleet can serve multiple businesses with workspace-path + memory-key
|
||||||
|
isolation.
|
||||||
|
- After ~5 consecutive spawn failures on the same task the dispatcher
|
||||||
|
auto-blocks it to prevent spin loops.
|
||||||
|
|
||||||
|
Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -466,17 +466,10 @@ class SessionManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to update ACP session metadata", exc_info=True)
|
logger.debug("Failed to update ACP session metadata", exc_info=True)
|
||||||
|
|
||||||
# Replace stored messages with current history.
|
# Replace stored messages with current history atomically so a
|
||||||
db.clear_messages(state.session_id)
|
# mid-rewrite failure rolls back and the previously persisted
|
||||||
for msg in state.history:
|
# conversation is preserved (salvaged from #13675).
|
||||||
db.append_message(
|
db.replace_messages(state.session_id, state.history)
|
||||||
session_id=state.session_id,
|
|
||||||
role=msg.get("role", "user"),
|
|
||||||
content=msg.get("content"),
|
|
||||||
tool_name=msg.get("tool_name") or msg.get("name"),
|
|
||||||
tool_calls=msg.get("tool_calls"),
|
|
||||||
tool_call_id=msg.get("tool_call_id"),
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
|
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -259,10 +259,12 @@ _PROVIDERS_WITHOUT_VISION: frozenset = frozenset({
|
|||||||
"kimi-coding-cn",
|
"kimi-coding-cn",
|
||||||
})
|
})
|
||||||
|
|
||||||
# OpenRouter app attribution headers (base — always sent)
|
# OpenRouter app attribution headers (base — always sent).
|
||||||
|
# `X-Title` is the canonical attribution header OpenRouter's dashboard
|
||||||
|
# reads; the previous `X-OpenRouter-Title` label was not recognized there.
|
||||||
_OR_HEADERS_BASE = {
|
_OR_HEADERS_BASE = {
|
||||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||||
"X-OpenRouter-Title": "Hermes Agent",
|
"X-Title": "Hermes Agent",
|
||||||
"X-OpenRouter-Categories": "productivity,cli-agent",
|
"X-OpenRouter-Categories": "productivity,cli-agent",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +569,12 @@ class _CodexCompletionsAdapter:
|
|||||||
# API allows it.
|
# API allows it.
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
effort = reasoning_cfg.get("effort", "medium")
|
# Truthy-only check mirrors agent/transports/codex.py
|
||||||
|
# build_kwargs(): falsy values (None, "", 0) fall back
|
||||||
|
# to the default rather than being forwarded to the
|
||||||
|
# Codex backend, which rejects e.g. {"effort": null}
|
||||||
|
# with a 400.
|
||||||
|
effort = reasoning_cfg.get("effort") or "medium"
|
||||||
# Codex backend rejects "minimal"; clamp to "low" to
|
# Codex backend rejects "minimal"; clamp to "low" to
|
||||||
# match the main-agent Codex transport behavior.
|
# match the main-agent Codex transport behavior.
|
||||||
if effort == "minimal":
|
if effort == "minimal":
|
||||||
@@ -1643,6 +1650,39 @@ def _is_payment_error(exc: Exception) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rate_limit_error(exc: Exception) -> bool:
|
||||||
|
"""Detect rate-limit errors that warrant provider fallback.
|
||||||
|
|
||||||
|
Returns True for HTTP 429 errors whose message indicates rate limiting
|
||||||
|
(as opposed to billing/quota exhaustion, which _is_payment_error handles).
|
||||||
|
Also catches OpenAI SDK RateLimitError instances that may not set
|
||||||
|
.status_code on the exception object.
|
||||||
|
"""
|
||||||
|
status = getattr(exc, "status_code", None)
|
||||||
|
err_lower = str(exc).lower()
|
||||||
|
|
||||||
|
# OpenAI SDK's RateLimitError sometimes omits .status_code —
|
||||||
|
# detect by class name so we don't miss these. (PR #8023 pattern)
|
||||||
|
if type(exc).__name__ == "RateLimitError":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if status == 429:
|
||||||
|
# Distinguish rate-limit from billing: billing keywords are handled
|
||||||
|
# by _is_payment_error, everything else on 429 is a rate limit.
|
||||||
|
if any(kw in err_lower for kw in (
|
||||||
|
"rate limit", "rate_limit", "too many requests",
|
||||||
|
"try again", "retry after", "resets in",
|
||||||
|
)):
|
||||||
|
return True
|
||||||
|
# Generic 429 without billing keywords = likely a rate limit
|
||||||
|
if not any(kw in err_lower for kw in (
|
||||||
|
"credits", "insufficient funds", "billing",
|
||||||
|
"payment required", "can only afford",
|
||||||
|
)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_connection_error(exc: Exception) -> bool:
|
def _is_connection_error(exc: Exception) -> bool:
|
||||||
"""Detect connection/network errors that warrant provider fallback.
|
"""Detect connection/network errors that warrant provider fallback.
|
||||||
|
|
||||||
@@ -3127,8 +3167,14 @@ def _resolve_task_provider_model(
|
|||||||
|
|
||||||
if task:
|
if task:
|
||||||
# Config.yaml is the primary source for per-task overrides.
|
# Config.yaml is the primary source for per-task overrides.
|
||||||
if cfg_base_url:
|
if cfg_base_url and cfg_api_key:
|
||||||
|
# Both base_url and api_key explicitly set → custom endpoint.
|
||||||
return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
|
return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
|
||||||
|
if cfg_base_url and cfg_provider and cfg_provider != "auto":
|
||||||
|
# base_url set without api_key but with a known provider — use
|
||||||
|
# the provider so it can resolve credentials from env vars
|
||||||
|
# (e.g. OPENROUTER_API_KEY) instead of locking into "custom".
|
||||||
|
return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode
|
||||||
if cfg_provider and cfg_provider != "auto":
|
if cfg_provider and cfg_provider != "auto":
|
||||||
return cfg_provider, resolved_model, None, None, resolved_api_mode
|
return cfg_provider, resolved_model, None, None, resolved_api_mode
|
||||||
|
|
||||||
@@ -3529,7 +3575,7 @@ def call_llm(
|
|||||||
except Exception as retry_err:
|
except Exception as retry_err:
|
||||||
# If the max_tokens retry also hits a payment or connection
|
# If the max_tokens retry also hits a payment or connection
|
||||||
# error, fall through to the fallback chain below.
|
# error, fall through to the fallback chain below.
|
||||||
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
|
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||||
raise
|
raise
|
||||||
first_err = retry_err
|
first_err = retry_err
|
||||||
|
|
||||||
@@ -3612,13 +3658,27 @@ def call_llm(
|
|||||||
# Codex/OAuth tokens that authenticate but whose endpoint is down,
|
# Codex/OAuth tokens that authenticate but whose endpoint is down,
|
||||||
# and providers the user never configured that got picked up by
|
# and providers the user never configured that got picked up by
|
||||||
# the auto-detection chain.
|
# the auto-detection chain.
|
||||||
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
|
#
|
||||||
|
# ── Rate-limit fallback (#13579) ─────────────────────────────
|
||||||
|
# When the provider returns a 429 rate-limit (not billing), fall
|
||||||
|
# back to an alternative provider instead of exhausting retries
|
||||||
|
# against the same rate-limited endpoint.
|
||||||
|
should_fallback = (
|
||||||
|
_is_payment_error(first_err)
|
||||||
|
or _is_connection_error(first_err)
|
||||||
|
or _is_rate_limit_error(first_err)
|
||||||
|
)
|
||||||
# Only try alternative providers when the user didn't explicitly
|
# Only try alternative providers when the user didn't explicitly
|
||||||
# configure this task's provider. Explicit provider = hard constraint;
|
# configure this task's provider. Explicit provider = hard constraint;
|
||||||
# auto (the default) = best-effort fallback chain. (#7559)
|
# auto (the default) = best-effort fallback chain. (#7559)
|
||||||
is_auto = resolved_provider in ("auto", "", None)
|
is_auto = resolved_provider in ("auto", "", None)
|
||||||
if should_fallback and is_auto:
|
if should_fallback and is_auto:
|
||||||
reason = "payment error" if _is_payment_error(first_err) else "connection error"
|
if _is_payment_error(first_err):
|
||||||
|
reason = "payment error"
|
||||||
|
elif _is_rate_limit_error(first_err):
|
||||||
|
reason = "rate limit"
|
||||||
|
else:
|
||||||
|
reason = "connection error"
|
||||||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||||
task or "call", reason, resolved_provider, first_err)
|
task or "call", reason, resolved_provider, first_err)
|
||||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||||
@@ -3821,7 +3881,7 @@ async def async_call_llm(
|
|||||||
except Exception as retry_err:
|
except Exception as retry_err:
|
||||||
# If the max_tokens retry also hits a payment or connection
|
# If the max_tokens retry also hits a payment or connection
|
||||||
# error, fall through to the fallback chain below.
|
# error, fall through to the fallback chain below.
|
||||||
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
|
if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||||
raise
|
raise
|
||||||
first_err = retry_err
|
first_err = retry_err
|
||||||
|
|
||||||
@@ -3890,11 +3950,20 @@ async def async_call_llm(
|
|||||||
return _validate_llm_response(
|
return _validate_llm_response(
|
||||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||||
|
|
||||||
# ── Payment / connection fallback (mirrors sync call_llm) ─────
|
# ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
|
||||||
should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
|
should_fallback = (
|
||||||
|
_is_payment_error(first_err)
|
||||||
|
or _is_connection_error(first_err)
|
||||||
|
or _is_rate_limit_error(first_err)
|
||||||
|
)
|
||||||
is_auto = resolved_provider in ("auto", "", None)
|
is_auto = resolved_provider in ("auto", "", None)
|
||||||
if should_fallback and is_auto:
|
if should_fallback and is_auto:
|
||||||
reason = "payment error" if _is_payment_error(first_err) else "connection error"
|
if _is_payment_error(first_err):
|
||||||
|
reason = "payment error"
|
||||||
|
elif _is_rate_limit_error(first_err):
|
||||||
|
reason = "rate limit"
|
||||||
|
else:
|
||||||
|
reason = "connection error"
|
||||||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||||
task or "call", reason, resolved_provider, first_err)
|
task or "call", reason, resolved_provider, first_err)
|
||||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||||
|
|||||||
@@ -993,15 +993,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _with_summary_prefix(summary: str) -> str:
|
def _strip_summary_prefix(summary: str) -> str:
|
||||||
"""Normalize summary text to the current compaction handoff format."""
|
"""Return summary body without the current or legacy handoff prefix."""
|
||||||
text = (summary or "").strip()
|
text = (summary or "").strip()
|
||||||
for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX):
|
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
|
||||||
if text.startswith(prefix):
|
if text.startswith(prefix):
|
||||||
text = text[len(prefix):].lstrip()
|
return text[len(prefix):].lstrip()
|
||||||
break
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _with_summary_prefix(cls, summary: str) -> str:
|
||||||
|
"""Normalize summary text to the current compaction handoff format."""
|
||||||
|
text = cls._strip_summary_prefix(summary)
|
||||||
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
|
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_context_summary_content(content: Any) -> bool:
|
||||||
|
text = _content_text_for_contains(content).lstrip()
|
||||||
|
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _find_latest_context_summary(
|
||||||
|
cls,
|
||||||
|
messages: List[Dict[str, Any]],
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
) -> tuple[Optional[int], str]:
|
||||||
|
"""Find the newest handoff summary inside a compression window."""
|
||||||
|
for idx in range(end - 1, start - 1, -1):
|
||||||
|
content = messages[idx].get("content")
|
||||||
|
if cls._is_context_summary_content(content):
|
||||||
|
return idx, cls._strip_summary_prefix(_content_text_for_contains(content))
|
||||||
|
return None, ""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Tool-call / tool-result pair integrity helpers
|
# Tool-call / tool-result pair integrity helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1308,6 +1332,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||||||
return messages
|
return messages
|
||||||
|
|
||||||
turns_to_summarize = messages[compress_start:compress_end]
|
turns_to_summarize = messages[compress_start:compress_end]
|
||||||
|
summary_idx, summary_body = self._find_latest_context_summary(
|
||||||
|
messages,
|
||||||
|
compress_start,
|
||||||
|
compress_end,
|
||||||
|
)
|
||||||
|
if summary_idx is not None:
|
||||||
|
if summary_body and not self._previous_summary:
|
||||||
|
self._previous_summary = summary_body
|
||||||
|
turns_to_summarize = messages[summary_idx + 1:compress_end]
|
||||||
|
|
||||||
if not self.quiet_mode:
|
if not self.quiet_mode:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1385,6 +1418,19 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||||||
# Merge the summary into the first tail message instead
|
# Merge the summary into the first tail message instead
|
||||||
# of inserting a standalone message that breaks alternation.
|
# of inserting a standalone message that breaks alternation.
|
||||||
_merge_summary_into_tail = True
|
_merge_summary_into_tail = True
|
||||||
|
|
||||||
|
# When the summary lands as a standalone role="user" message,
|
||||||
|
# weak models read the verbatim "## Active Task" quote of a past
|
||||||
|
# user request as fresh input (#11475, #14521). Append the explicit
|
||||||
|
# end marker — the same one used in the merge-into-tail path — so
|
||||||
|
# the model has a clear "summary above, not new input" signal.
|
||||||
|
if not _merge_summary_into_tail and summary_role == "user":
|
||||||
|
summary = (
|
||||||
|
summary
|
||||||
|
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||||
|
"respond to the message below, not the summary above ---"
|
||||||
|
)
|
||||||
|
|
||||||
if not _merge_summary_into_tail:
|
if not _merge_summary_into_tail:
|
||||||
compressed.append({"role": summary_role, "content": summary})
|
compressed.append({"role": summary_role, "content": summary})
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class FailoverReason(enum.Enum):
|
|||||||
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
|
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
|
||||||
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
|
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
|
oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry
|
||||||
|
llama_cpp_grammar_pattern = "llama_cpp_grammar_pattern" # llama.cpp json-schema-to-grammar rejects regex escapes in `pattern` / `format` — strip from tools and retry
|
||||||
|
|
||||||
# Catch-all
|
# Catch-all
|
||||||
unknown = "unknown" # Unclassifiable — retry with backoff
|
unknown = "unknown" # Unclassifiable — retry with backoff
|
||||||
@@ -470,6 +471,31 @@ def classify_api_error(
|
|||||||
should_compress=False,
|
should_compress=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# llama.cpp's ``json-schema-to-grammar`` converter (used by its OAI
|
||||||
|
# server to build GBNF tool-call parsers) rejects regex escape classes
|
||||||
|
# like ``\d``/``\w``/``\s`` and most ``format`` values. MCP servers
|
||||||
|
# routinely emit ``"pattern": "\\d{4}-\\d{2}-\\d{2}"`` for date/phone/
|
||||||
|
# email params. llama.cpp surfaces this as HTTP 400 with one of a few
|
||||||
|
# recognizable phrases; on match we strip ``pattern``/``format`` from
|
||||||
|
# ``self.tools`` in the retry loop and retry once. Cloud providers are
|
||||||
|
# unaffected — they accept these keywords and we never hit this branch.
|
||||||
|
if (
|
||||||
|
status_code == 400
|
||||||
|
and (
|
||||||
|
"error parsing grammar" in error_msg
|
||||||
|
or "json-schema-to-grammar" in error_msg
|
||||||
|
or (
|
||||||
|
"unable to generate parser" in error_msg
|
||||||
|
and "template" in error_msg
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return _result(
|
||||||
|
FailoverReason.llama_cpp_grammar_pattern,
|
||||||
|
retryable=True,
|
||||||
|
should_compress=False,
|
||||||
|
)
|
||||||
|
|
||||||
# ── 2. HTTP status code classification ──────────────────────────
|
# ── 2. HTTP status code classification ──────────────────────────
|
||||||
|
|
||||||
if status_code is not None:
|
if status_code is not None:
|
||||||
|
|||||||
230
agent/i18n.py
Normal file
230
agent/i18n.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Lightweight internationalization (i18n) for Hermes static user-facing messages.
|
||||||
|
|
||||||
|
Scope (thin slice, by design): only the highest-impact static strings shown
|
||||||
|
to the user by Hermes itself -- approval prompts, a handful of gateway slash
|
||||||
|
command replies, restart-drain notices. Agent-generated output, log lines,
|
||||||
|
error tracebacks, tool outputs, and slash-command descriptions all stay in
|
||||||
|
English.
|
||||||
|
|
||||||
|
Catalog files live under ``locales/<lang>.yaml`` at the repo root. Each
|
||||||
|
catalog is a flat dict keyed by dotted paths (e.g. ``approval.choose`` or
|
||||||
|
``gateway.approval_expired``). Missing keys fall back to English; if English
|
||||||
|
is missing too, the key path itself is returned so a broken catalog never
|
||||||
|
crashes the agent.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from agent.i18n import t
|
||||||
|
print(t("approval.choose_long")) # current lang
|
||||||
|
print(t("gateway.draining", count=3)) # {count} formatted
|
||||||
|
print(t("approval.choose_long", lang="zh")) # explicit override
|
||||||
|
|
||||||
|
Language resolution order:
|
||||||
|
1. Explicit ``lang=`` argument passed to :func:`t`
|
||||||
|
2. ``HERMES_LANGUAGE`` environment variable (for tests / quick override)
|
||||||
|
3. ``display.language`` from config.yaml
|
||||||
|
4. ``"en"`` (baseline)
|
||||||
|
|
||||||
|
Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
|
||||||
|
DEFAULT_LANGUAGE = "en"
|
||||||
|
|
||||||
|
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
|
||||||
|
# get the right catalog instead of silently falling back to English.
|
||||||
|
_LANGUAGE_ALIASES: dict[str, str] = {
|
||||||
|
"english": "en", "en-us": "en", "en-gb": "en",
|
||||||
|
"chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-tw": "zh", "zh-hans": "zh", "zh-hant": "zh",
|
||||||
|
"japanese": "ja", "jp": "ja", "ja-jp": "ja",
|
||||||
|
"german": "de", "deutsch": "de", "de-de": "de",
|
||||||
|
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
|
||||||
|
}
|
||||||
|
|
||||||
|
_catalog_cache: dict[str, dict[str, str]] = {}
|
||||||
|
_catalog_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _locales_dir() -> Path:
|
||||||
|
"""Return the directory containing locale YAML files.
|
||||||
|
|
||||||
|
Lives next to the repo root so both the bundled install and editable
|
||||||
|
checkouts find it without PYTHONPATH gymnastics.
|
||||||
|
"""
|
||||||
|
# agent/i18n.py -> agent/ -> repo root
|
||||||
|
return Path(__file__).resolve().parent.parent / "locales"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_lang(value: Any) -> str:
|
||||||
|
"""Normalize a user-supplied language value to a supported code.
|
||||||
|
|
||||||
|
Accepts supported codes directly, common aliases (``chinese`` -> ``zh``),
|
||||||
|
and case-insensitive regional tags (``zh-CN`` -> ``zh``). Returns the
|
||||||
|
default language for unknown values.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return DEFAULT_LANGUAGE
|
||||||
|
key = value.strip().lower()
|
||||||
|
if not key:
|
||||||
|
return DEFAULT_LANGUAGE
|
||||||
|
if key in SUPPORTED_LANGUAGES:
|
||||||
|
return key
|
||||||
|
if key in _LANGUAGE_ALIASES:
|
||||||
|
return _LANGUAGE_ALIASES[key]
|
||||||
|
# Try stripping a region suffix (e.g. "pt-br" -> "pt" won't be supported,
|
||||||
|
# but "zh-CN" -> "zh" will).
|
||||||
|
base = key.split("-", 1)[0]
|
||||||
|
if base in SUPPORTED_LANGUAGES:
|
||||||
|
return base
|
||||||
|
return DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog(lang: str) -> dict[str, str]:
|
||||||
|
"""Load and flatten one locale YAML file into a dotted-key dict.
|
||||||
|
|
||||||
|
YAML files can be nested for human readability; this produces the flat
|
||||||
|
key space :func:`t` expects. Cached per-language for the process.
|
||||||
|
"""
|
||||||
|
with _catalog_lock:
|
||||||
|
cached = _catalog_cache.get(lang)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
path = _locales_dir() / f"{lang}.yaml"
|
||||||
|
if not path.is_file():
|
||||||
|
logger.debug("i18n catalog missing for %s at %s", lang, path)
|
||||||
|
with _catalog_lock:
|
||||||
|
_catalog_cache[lang] = {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml # PyYAML is already a hermes dependency
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
raw = yaml.safe_load(f) or {}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to load i18n catalog %s: %s", path, exc)
|
||||||
|
with _catalog_lock:
|
||||||
|
_catalog_cache[lang] = {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
flat: dict[str, str] = {}
|
||||||
|
_flatten_into(raw, "", flat)
|
||||||
|
with _catalog_lock:
|
||||||
|
_catalog_cache[lang] = flat
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
child_key = f"{prefix}.{key}" if prefix else str(key)
|
||||||
|
_flatten_into(value, child_key, out)
|
||||||
|
elif isinstance(node, str):
|
||||||
|
out[prefix] = node
|
||||||
|
# Non-string, non-dict leaves are ignored -- catalogs are text-only.
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _config_language_cached() -> str | None:
|
||||||
|
"""Read ``display.language`` from config.yaml once per process.
|
||||||
|
|
||||||
|
Cached because ``t()`` is called in hot paths (every approval prompt,
|
||||||
|
every gateway reply) and re-reading YAML each call would be wasteful.
|
||||||
|
``reset_language_cache()`` clears this when config changes at runtime
|
||||||
|
(e.g. after the setup wizard).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
cfg = load_config()
|
||||||
|
lang = (cfg.get("display") or {}).get("language")
|
||||||
|
if lang:
|
||||||
|
return _normalize_lang(lang)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Could not read display.language from config: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_language_cache() -> None:
|
||||||
|
"""Invalidate cached language resolution and catalogs.
|
||||||
|
|
||||||
|
Call after :func:`hermes_cli.config.save_config` if a running process
|
||||||
|
needs to pick up a changed ``display.language`` without restart.
|
||||||
|
"""
|
||||||
|
_config_language_cached.cache_clear()
|
||||||
|
with _catalog_lock:
|
||||||
|
_catalog_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def get_language() -> str:
|
||||||
|
"""Resolve the active language using env > config > default order."""
|
||||||
|
env_lang = os.environ.get("HERMES_LANGUAGE")
|
||||||
|
if env_lang:
|
||||||
|
return _normalize_lang(env_lang)
|
||||||
|
cfg_lang = _config_language_cached()
|
||||||
|
if cfg_lang:
|
||||||
|
return cfg_lang
|
||||||
|
return DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
|
||||||
|
def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
|
||||||
|
"""Translate a dotted key to the active language.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
key
|
||||||
|
Dotted path into the catalog, e.g. ``"approval.choose_long"``.
|
||||||
|
lang
|
||||||
|
Explicit language override. Takes precedence over env + config.
|
||||||
|
**format_kwargs
|
||||||
|
``str.format`` substitution arguments (``t("gateway.drain", count=3)``
|
||||||
|
expects a catalog entry with a ``{count}`` placeholder).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
The translated string, or the English fallback if the key is missing in
|
||||||
|
the target language, or the bare key if English is also missing.
|
||||||
|
"""
|
||||||
|
target = _normalize_lang(lang) if lang else get_language()
|
||||||
|
catalog = _load_catalog(target)
|
||||||
|
value = catalog.get(key)
|
||||||
|
|
||||||
|
if value is None and target != DEFAULT_LANGUAGE:
|
||||||
|
# Fall through to English rather than showing a key path to the user.
|
||||||
|
value = _load_catalog(DEFAULT_LANGUAGE).get(key)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
# Last-ditch: return the key itself. A broken catalog should not
|
||||||
|
# crash anything; it just looks ugly until someone fixes it.
|
||||||
|
logger.debug("i18n miss: key=%r lang=%r", key, target)
|
||||||
|
value = key
|
||||||
|
|
||||||
|
if format_kwargs:
|
||||||
|
try:
|
||||||
|
return value.format(**format_kwargs)
|
||||||
|
except (KeyError, IndexError, ValueError) as exc:
|
||||||
|
logger.warning(
|
||||||
|
"i18n format failed for key=%r lang=%r kwargs=%r: %s",
|
||||||
|
key, target, format_kwargs, exc,
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SUPPORTED_LANGUAGES",
|
||||||
|
"DEFAULT_LANGUAGE",
|
||||||
|
"t",
|
||||||
|
"get_language",
|
||||||
|
"reset_language_cache",
|
||||||
|
]
|
||||||
@@ -513,6 +513,12 @@ PLATFORM_HINTS = {
|
|||||||
"image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
|
"image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
|
||||||
"— when a sticker is the right response, use yb_send_sticker."
|
"— when a sticker is the right response, use yb_send_sticker."
|
||||||
),
|
),
|
||||||
|
"api_server": (
|
||||||
|
"You're responding through an API server. The rendering layer is unknown — "
|
||||||
|
"assume plain text. No markdown formatting (no asterisks, bullets, headers, "
|
||||||
|
"code fences). Treat this like a conversation, not a document. Keep responses "
|
||||||
|
"brief and natural."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
386
agent/think_scrubber.py
Normal file
386
agent/think_scrubber.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"""Stateful scrubber for reasoning/thinking blocks in streamed assistant text.
|
||||||
|
|
||||||
|
``run_agent._strip_think_blocks`` is regex-based and correct for a complete
|
||||||
|
string, but when it runs *per-delta* in ``_fire_stream_delta`` it destroys
|
||||||
|
the state that downstream consumers (CLI ``_stream_delta``, gateway
|
||||||
|
``GatewayStreamConsumer._filter_and_accumulate``) rely on.
|
||||||
|
|
||||||
|
Concretely, when MiniMax-M2.7 streams
|
||||||
|
|
||||||
|
delta1 = "<think>"
|
||||||
|
delta2 = "Let me check their config"
|
||||||
|
delta3 = "</think>"
|
||||||
|
|
||||||
|
the per-delta regex erases delta1 entirely (case 2: unterminated-open at
|
||||||
|
boundary matches ``^<think>...``), so the downstream state machine never
|
||||||
|
sees the open tag, treats delta2 as regular content, and leaks reasoning
|
||||||
|
to the user. Consumers that don't run their own state machine (ACP,
|
||||||
|
api_server, TTS) never had any defence at all — they just emitted
|
||||||
|
whatever survived the upstream regex.
|
||||||
|
|
||||||
|
This module centralises the tag-suppression state machine at the
|
||||||
|
upstream layer so every stream_delta_callback sees text that has
|
||||||
|
already had reasoning blocks removed. Partial tags at delta
|
||||||
|
boundaries are held back until the next delta resolves them, and
|
||||||
|
end-of-stream flushing surfaces any held-back prose that turned out
|
||||||
|
not to be a real tag.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
scrubber = StreamingThinkScrubber()
|
||||||
|
for delta in stream:
|
||||||
|
visible = scrubber.feed(delta)
|
||||||
|
if visible:
|
||||||
|
emit(visible)
|
||||||
|
tail = scrubber.flush() # at end of stream
|
||||||
|
if tail:
|
||||||
|
emit(tail)
|
||||||
|
|
||||||
|
The scrubber is re-entrant per agent instance. Call ``reset()`` at
|
||||||
|
the top of each new turn so a hung block from an interrupted prior
|
||||||
|
stream cannot taint the next turn's output.
|
||||||
|
|
||||||
|
Tag variants handled (case-insensitive):
|
||||||
|
``<think>``, ``<thinking>``, ``<reasoning>``, ``<thought>``,
|
||||||
|
``<REASONING_SCRATCHPAD>``.
|
||||||
|
|
||||||
|
Block-boundary rule for opens: an opening tag is only treated as a
|
||||||
|
reasoning-block opener when it appears at the start of the stream,
|
||||||
|
after a newline (optionally followed by whitespace), or when only
|
||||||
|
whitespace has been emitted on the current line. This prevents prose
|
||||||
|
that *mentions* the tag name (e.g. ``"use <think> tags here"``) from
|
||||||
|
being incorrectly suppressed. Closed pairs (``<think>X</think>``) are
|
||||||
|
always suppressed regardless of boundary; a closed pair is an
|
||||||
|
intentional, bounded construct.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
__all__ = ["StreamingThinkScrubber"]
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingThinkScrubber:
|
||||||
|
"""Stateful scrubber for streaming reasoning/thinking blocks.
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
- ``_in_block``: True while inside an opened block, waiting for
|
||||||
|
a close tag. All text inside is discarded.
|
||||||
|
- ``_buf``: held-back partial-tag tail. Emitted / discarded on
|
||||||
|
the next ``feed()`` call or by ``flush()``.
|
||||||
|
- ``_last_emitted_ended_newline``: True iff the most recent
|
||||||
|
emission to the consumer ended with ``\\n``, or nothing has
|
||||||
|
been emitted yet (start-of-stream counts as a boundary). Used
|
||||||
|
to decide whether an open tag at buffer position 0 is at a
|
||||||
|
block boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_OPEN_TAG_NAMES: Tuple[str, ...] = (
|
||||||
|
"think",
|
||||||
|
"thinking",
|
||||||
|
"reasoning",
|
||||||
|
"thought",
|
||||||
|
"REASONING_SCRATCHPAD",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Materialise literal tag strings so the hot path does string
|
||||||
|
# operations, not regex compilation per feed().
|
||||||
|
_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name}>" for name in _OPEN_TAG_NAMES)
|
||||||
|
_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name}>" for name in _OPEN_TAG_NAMES)
|
||||||
|
|
||||||
|
# Pre-compute the longest tag (for partial-tag hold-back bound).
|
||||||
|
_MAX_TAG_LEN: int = max(len(tag) for tag in _OPEN_TAGS + _CLOSE_TAGS)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._in_block: bool = False
|
||||||
|
self._buf: str = ""
|
||||||
|
self._last_emitted_ended_newline: bool = True
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset all state. Call at the top of every new turn."""
|
||||||
|
self._in_block = False
|
||||||
|
self._buf = ""
|
||||||
|
self._last_emitted_ended_newline = True
|
||||||
|
|
||||||
|
def feed(self, text: str) -> str:
|
||||||
|
"""Feed one delta; return the scrubbed visible portion.
|
||||||
|
|
||||||
|
May return an empty string when the entire delta is reasoning
|
||||||
|
content or is being held back pending resolution of a partial
|
||||||
|
tag at the boundary.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
buf = self._buf + text
|
||||||
|
self._buf = ""
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
while buf:
|
||||||
|
if self._in_block:
|
||||||
|
# Hunt for the earliest close tag.
|
||||||
|
close_idx, close_len = self._find_first_tag(
|
||||||
|
buf, self._CLOSE_TAGS,
|
||||||
|
)
|
||||||
|
if close_idx == -1:
|
||||||
|
# No close yet — hold back a potential partial
|
||||||
|
# close-tag prefix; discard everything else.
|
||||||
|
held = self._max_partial_suffix(buf, self._CLOSE_TAGS)
|
||||||
|
self._buf = buf[-held:] if held else ""
|
||||||
|
return "".join(out)
|
||||||
|
# Found close: discard block content + tag, continue.
|
||||||
|
buf = buf[close_idx + close_len:]
|
||||||
|
self._in_block = False
|
||||||
|
else:
|
||||||
|
# Priority 1 — closed <tag>X</tag> pair anywhere in
|
||||||
|
# buf. Closed pairs are always an intentional,
|
||||||
|
# bounded construct (even mid-line prose containing
|
||||||
|
# an open/close pair is almost certainly a model
|
||||||
|
# leaking reasoning inline), so no boundary gating.
|
||||||
|
pair = self._find_earliest_closed_pair(buf)
|
||||||
|
# Priority 2 — unterminated open tag at a block
|
||||||
|
# boundary. Boundary-gated so prose that mentions
|
||||||
|
# '<think>' isn't over-stripped.
|
||||||
|
open_idx, open_len = self._find_open_at_boundary(
|
||||||
|
buf, out,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pick whichever match comes earliest in the buffer.
|
||||||
|
if pair is not None and (
|
||||||
|
open_idx == -1 or pair[0] <= open_idx
|
||||||
|
):
|
||||||
|
start_idx, end_idx = pair
|
||||||
|
preceding = buf[:start_idx]
|
||||||
|
if preceding:
|
||||||
|
preceding = self._strip_orphan_close_tags(preceding)
|
||||||
|
if preceding:
|
||||||
|
out.append(preceding)
|
||||||
|
self._last_emitted_ended_newline = (
|
||||||
|
preceding.endswith("\n")
|
||||||
|
)
|
||||||
|
buf = buf[end_idx:]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if open_idx != -1:
|
||||||
|
# Unterminated open at boundary — emit preceding,
|
||||||
|
# enter block, continue loop with remainder.
|
||||||
|
preceding = buf[:open_idx]
|
||||||
|
if preceding:
|
||||||
|
preceding = self._strip_orphan_close_tags(preceding)
|
||||||
|
if preceding:
|
||||||
|
out.append(preceding)
|
||||||
|
self._last_emitted_ended_newline = (
|
||||||
|
preceding.endswith("\n")
|
||||||
|
)
|
||||||
|
self._in_block = True
|
||||||
|
buf = buf[open_idx + open_len:]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# No resolvable tag structure in buf. Hold back any
|
||||||
|
# partial-tag prefix at the tail so a split tag
|
||||||
|
# across deltas isn't missed, then emit the rest.
|
||||||
|
held = self._max_partial_suffix(buf, self._OPEN_TAGS)
|
||||||
|
held_close = self._max_partial_suffix(
|
||||||
|
buf, self._CLOSE_TAGS,
|
||||||
|
)
|
||||||
|
held = max(held, held_close)
|
||||||
|
if held:
|
||||||
|
emit_text = buf[:-held]
|
||||||
|
self._buf = buf[-held:]
|
||||||
|
else:
|
||||||
|
emit_text = buf
|
||||||
|
self._buf = ""
|
||||||
|
if emit_text:
|
||||||
|
emit_text = self._strip_orphan_close_tags(emit_text)
|
||||||
|
if emit_text:
|
||||||
|
out.append(emit_text)
|
||||||
|
self._last_emitted_ended_newline = (
|
||||||
|
emit_text.endswith("\n")
|
||||||
|
)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
def flush(self) -> str:
|
||||||
|
"""End-of-stream flush.
|
||||||
|
|
||||||
|
If still inside an unterminated block, held-back content is
|
||||||
|
discarded — leaking partial reasoning is worse than a
|
||||||
|
truncated answer. Otherwise the held-back partial-tag tail is
|
||||||
|
emitted verbatim (it turned out not to be a real tag prefix).
|
||||||
|
"""
|
||||||
|
if self._in_block:
|
||||||
|
self._buf = ""
|
||||||
|
self._in_block = False
|
||||||
|
return ""
|
||||||
|
tail = self._buf
|
||||||
|
self._buf = ""
|
||||||
|
if not tail:
|
||||||
|
return ""
|
||||||
|
tail = self._strip_orphan_close_tags(tail)
|
||||||
|
if tail:
|
||||||
|
self._last_emitted_ended_newline = tail.endswith("\n")
|
||||||
|
return tail
|
||||||
|
|
||||||
|
# ── internal helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_first_tag(
|
||||||
|
buf: str, tags: Tuple[str, ...],
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""Return (earliest_index, tag_length) over *tags*, or (-1, 0).
|
||||||
|
|
||||||
|
Case-insensitive match.
|
||||||
|
"""
|
||||||
|
buf_lower = buf.lower()
|
||||||
|
best_idx = -1
|
||||||
|
best_len = 0
|
||||||
|
for tag in tags:
|
||||||
|
idx = buf_lower.find(tag.lower())
|
||||||
|
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
||||||
|
best_idx = idx
|
||||||
|
best_len = len(tag)
|
||||||
|
return best_idx, best_len
|
||||||
|
|
||||||
|
def _find_earliest_closed_pair(self, buf: str):
|
||||||
|
"""Return (start_idx, end_idx) of the earliest closed pair, else None.
|
||||||
|
|
||||||
|
A closed pair is ``<tag>...</tag>`` of any variant. Matches are
|
||||||
|
case-insensitive and non-greedy (the closest close tag after
|
||||||
|
an open tag wins), matching the regex ``<tag>.*?</tag>``
|
||||||
|
semantics of ``_strip_think_blocks`` case 1. When two tag
|
||||||
|
variants could both match, the one whose open tag appears
|
||||||
|
earlier wins.
|
||||||
|
"""
|
||||||
|
buf_lower = buf.lower()
|
||||||
|
best: "tuple[int, int] | None" = None
|
||||||
|
for open_tag, close_tag in zip(self._OPEN_TAGS, self._CLOSE_TAGS):
|
||||||
|
open_lower = open_tag.lower()
|
||||||
|
close_lower = close_tag.lower()
|
||||||
|
open_idx = buf_lower.find(open_lower)
|
||||||
|
if open_idx == -1:
|
||||||
|
continue
|
||||||
|
close_idx = buf_lower.find(
|
||||||
|
close_lower, open_idx + len(open_lower),
|
||||||
|
)
|
||||||
|
if close_idx == -1:
|
||||||
|
continue
|
||||||
|
end_idx = close_idx + len(close_lower)
|
||||||
|
if best is None or open_idx < best[0]:
|
||||||
|
best = (open_idx, end_idx)
|
||||||
|
return best
|
||||||
|
|
||||||
|
def _find_open_at_boundary(
|
||||||
|
self, buf: str, already_emitted: list[str],
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""Return the earliest block-boundary open-tag (idx, len).
|
||||||
|
|
||||||
|
Returns (-1, 0) if no boundary-legal opener is present.
|
||||||
|
"""
|
||||||
|
buf_lower = buf.lower()
|
||||||
|
best_idx = -1
|
||||||
|
best_len = 0
|
||||||
|
for tag in self._OPEN_TAGS:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
search_start = 0
|
||||||
|
while True:
|
||||||
|
idx = buf_lower.find(tag_lower, search_start)
|
||||||
|
if idx == -1:
|
||||||
|
break
|
||||||
|
if self._is_block_boundary(buf, idx, already_emitted):
|
||||||
|
if best_idx == -1 or idx < best_idx:
|
||||||
|
best_idx = idx
|
||||||
|
best_len = len(tag)
|
||||||
|
break # first boundary hit for this tag is enough
|
||||||
|
search_start = idx + 1
|
||||||
|
return best_idx, best_len
|
||||||
|
|
||||||
|
def _is_block_boundary(
|
||||||
|
self, buf: str, idx: int, already_emitted: list[str],
|
||||||
|
) -> bool:
|
||||||
|
"""True iff position *idx* in *buf* is a block boundary.
|
||||||
|
|
||||||
|
A block boundary is:
|
||||||
|
- buf position 0 AND the most recent emission ended with
|
||||||
|
a newline (or nothing has been emitted yet)
|
||||||
|
- any position whose preceding text on the current line
|
||||||
|
(since the last newline in buf) is whitespace-only, AND
|
||||||
|
if there is no newline in the preceding buf portion, the
|
||||||
|
most recent prior emission ended with a newline
|
||||||
|
"""
|
||||||
|
if idx == 0:
|
||||||
|
# Check whether the last already-emitted chunk in THIS
|
||||||
|
# feed() call ended with a newline, otherwise fall back
|
||||||
|
# to the cross-feed flag.
|
||||||
|
if already_emitted:
|
||||||
|
return already_emitted[-1].endswith("\n")
|
||||||
|
return self._last_emitted_ended_newline
|
||||||
|
preceding = buf[:idx]
|
||||||
|
last_nl = preceding.rfind("\n")
|
||||||
|
if last_nl == -1:
|
||||||
|
# No newline in buf before the tag — boundary only if the
|
||||||
|
# prior emission ended with a newline AND everything since
|
||||||
|
# is whitespace.
|
||||||
|
if already_emitted:
|
||||||
|
prior_newline = already_emitted[-1].endswith("\n")
|
||||||
|
else:
|
||||||
|
prior_newline = self._last_emitted_ended_newline
|
||||||
|
return prior_newline and preceding.strip() == ""
|
||||||
|
# Newline present — text between it and the tag must be
|
||||||
|
# whitespace-only.
|
||||||
|
return preceding[last_nl + 1:].strip() == ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _max_partial_suffix(
|
||||||
|
cls, buf: str, tags: Tuple[str, ...],
|
||||||
|
) -> int:
|
||||||
|
"""Return the longest buf-suffix that is a prefix of any tag.
|
||||||
|
|
||||||
|
Only prefixes strictly shorter than the tag itself count
|
||||||
|
(full-length suffixes are the tag and are handled as matches,
|
||||||
|
not held-back partials). Case-insensitive.
|
||||||
|
"""
|
||||||
|
if not buf:
|
||||||
|
return 0
|
||||||
|
buf_lower = buf.lower()
|
||||||
|
max_check = min(len(buf_lower), cls._MAX_TAG_LEN - 1)
|
||||||
|
for i in range(max_check, 0, -1):
|
||||||
|
suffix = buf_lower[-i:]
|
||||||
|
for tag in tags:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
if len(tag_lower) > i and tag_lower.startswith(suffix):
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _strip_orphan_close_tags(cls, text: str) -> str:
|
||||||
|
"""Remove any close tags from *text* (orphan-close handling).
|
||||||
|
|
||||||
|
An orphan close tag has no matching open in the current
|
||||||
|
scrubber state; it's always noise, stripped with any trailing
|
||||||
|
whitespace so the surrounding prose flows naturally.
|
||||||
|
"""
|
||||||
|
if "</" not in text:
|
||||||
|
return text
|
||||||
|
text_lower = text.lower()
|
||||||
|
out: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
matched = False
|
||||||
|
if text_lower[i:i + 2] == "</":
|
||||||
|
for tag in cls._CLOSE_TAGS:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
tag_len = len(tag_lower)
|
||||||
|
if text_lower[i:i + tag_len] == tag_lower:
|
||||||
|
# Skip the tag and any trailing whitespace,
|
||||||
|
# matching _strip_think_blocks case 3.
|
||||||
|
j = i + tag_len
|
||||||
|
while j < len(text) and text[j] in " \t\n\r":
|
||||||
|
j += 1
|
||||||
|
i = j
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
out.append(text[i])
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
@@ -10,17 +10,34 @@ Browser-based dashboard for managing Hermes Agent configuration, API keys, and m
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
Install workspace dependencies from the repo root first:
|
||||||
# Start the backend API server
|
|
||||||
cd ../
|
|
||||||
python -m hermes_cli.main web --no-open
|
|
||||||
|
|
||||||
# In another terminal, start the Vite dev server (with HMR + API proxy)
|
```bash
|
||||||
cd web/
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the backend API server from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes dashboard --tui --no-open
|
||||||
|
```
|
||||||
|
|
||||||
|
`--tui` exposes the in-browser Chat tab through `/api/pty`. Omit it if you only need the config/session dashboard.
|
||||||
|
|
||||||
|
In another terminal, start the Vite dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/dashboard
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
|
The Vite dev server proxies `/api`, `/api/pty`, and `/dashboard-plugins` to `http://127.0.0.1:9119` (the FastAPI backend). It also fetches the backend's `index.html` on each dev page load so the ephemeral session token stays in sync.
|
||||||
|
|
||||||
|
If the `hermes` entry point is not installed, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m hermes_cli.main dashboard --tui --no-open
|
||||||
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -28,7 +45,7 @@ The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the Fast
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
|
This outputs to `../../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "web",
|
"name": "dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hermes/shared": "file:../shared",
|
||||||
"@nous-research/ui": "^0.10.0",
|
"@nous-research/ui": "^0.10.0",
|
||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
@@ -45,6 +46,13 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../shared": {
|
||||||
|
"name": "@hermes/shared",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -947,6 +955,10 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@hermes/shared": {
|
||||||
|
"resolved": "../shared",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -2371,6 +2383,64 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.4",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-assets": "rm -rf public/fonts public/ds-assets && cp -r node_modules/@nous-research/ui/dist/fonts public/fonts && cp -r node_modules/@nous-research/ui/dist/assets public/ds-assets",
|
"sync-assets": "node scripts/sync-assets.cjs",
|
||||||
"predev": "npm run sync-assets",
|
"predev": "npm run sync-assets",
|
||||||
"prebuild": "npm run sync-assets",
|
"prebuild": "npm run sync-assets",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hermes/shared": "file:../shared",
|
||||||
"@nous-research/ui": "^0.10.0",
|
"@nous-research/ui": "^0.10.0",
|
||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
BIN
apps/dashboard/public/ds-assets/filler-bg0.jpg
Normal file
BIN
apps/dashboard/public/ds-assets/filler-bg0.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
BIN
apps/dashboard/public/fonts/Collapse-BoldItalic.woff2
Normal file
BIN
apps/dashboard/public/fonts/Collapse-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
apps/dashboard/public/fonts/Collapse-Italic.woff2
Normal file
BIN
apps/dashboard/public/fonts/Collapse-Italic.woff2
Normal file
Binary file not shown.
BIN
apps/dashboard/public/fonts/Collapse-Light.woff2
Normal file
BIN
apps/dashboard/public/fonts/Collapse-Light.woff2
Normal file
Binary file not shown.
BIN
apps/dashboard/public/fonts/Collapse-LightItalic.woff2
Normal file
BIN
apps/dashboard/public/fonts/Collapse-LightItalic.woff2
Normal file
Binary file not shown.
BIN
apps/dashboard/public/fonts/Collapse-Thin.woff2
Normal file
BIN
apps/dashboard/public/fonts/Collapse-Thin.woff2
Normal file
Binary file not shown.
BIN
apps/dashboard/public/fonts/Collapse-ThinItalic.woff2
Normal file
BIN
apps/dashboard/public/fonts/Collapse-ThinItalic.woff2
Normal file
Binary file not shown.
BIN
apps/dashboard/public/fonts/Neuebit-Bold.woff2
Normal file
BIN
apps/dashboard/public/fonts/Neuebit-Bold.woff2
Normal file
Binary file not shown.
46
apps/dashboard/scripts/sync-assets.cjs
Normal file
46
apps/dashboard/scripts/sync-assets.cjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copy font and asset folders from @nous-research/ui into public/ for Vite.
|
||||||
|
*
|
||||||
|
* Locates @nous-research/ui by walking up from this script looking for
|
||||||
|
* node_modules/@nous-research/ui — works whether the dep is co-located
|
||||||
|
* (non-workspace layout) or hoisted to the repo root (npm workspaces).
|
||||||
|
*/
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const DASHBOARD_ROOT = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
function locateUiPackage() {
|
||||||
|
let dir = DASHBOARD_ROOT
|
||||||
|
const { root } = path.parse(dir)
|
||||||
|
while (true) {
|
||||||
|
const candidate = path.join(dir, 'node_modules', '@nous-research', 'ui')
|
||||||
|
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
if (dir === root) break
|
||||||
|
dir = path.dirname(dir)
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'@nous-research/ui not found. Run `npm install` from the repo root.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiRoot = locateUiPackage()
|
||||||
|
const distRoot = path.join(uiRoot, 'dist')
|
||||||
|
|
||||||
|
const mappings = [
|
||||||
|
['fonts', path.join(DASHBOARD_ROOT, 'public', 'fonts')],
|
||||||
|
['assets', path.join(DASHBOARD_ROOT, 'public', 'ds-assets')],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [srcName, destPath] of mappings) {
|
||||||
|
const srcPath = path.join(distRoot, srcName)
|
||||||
|
if (!fs.existsSync(srcPath)) {
|
||||||
|
throw new Error(`Missing ${srcPath} in @nous-research/ui — rebuild that package.`)
|
||||||
|
}
|
||||||
|
fs.rmSync(destPath, { recursive: true, force: true })
|
||||||
|
fs.cpSync(srcPath, destPath, { recursive: true })
|
||||||
|
console.log(`synced ${path.relative(DASHBOARD_ROOT, destPath)}`)
|
||||||
|
}
|
||||||
36
apps/dashboard/src/lib/gatewayClient.ts
Normal file
36
apps/dashboard/src/lib/gatewayClient.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
JsonRpcGatewayClient,
|
||||||
|
type ConnectionState,
|
||||||
|
type GatewayEvent,
|
||||||
|
type GatewayEventName,
|
||||||
|
} from "@hermes/shared";
|
||||||
|
|
||||||
|
export type { ConnectionState, GatewayEvent, GatewayEventName };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser wrapper for the shared tui_gateway JSON-RPC client.
|
||||||
|
*
|
||||||
|
* Dashboard resolves its token and host from the served page. Desktop uses the
|
||||||
|
* same shared protocol client, but supplies an absolute wsUrl from Electron.
|
||||||
|
*/
|
||||||
|
export class GatewayClient extends JsonRpcGatewayClient {
|
||||||
|
async connect(token?: string): Promise<void> {
|
||||||
|
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(
|
||||||
|
"Session token not available — page must be served by the Hermes dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
await super.connect(
|
||||||
|
`${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__HERMES_SESSION_TOKEN__?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user