mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
236 Commits
perf/honch
...
desktop-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bff052d61f | ||
|
|
61fb5a48b7 | ||
|
|
cb7f1d7e0e | ||
|
|
44cdf555a8 | ||
|
|
826e7171e9 | ||
|
|
9ee9a4297d | ||
|
|
6b5e0119b3 | ||
|
|
9457644390 | ||
|
|
c6dc295a35 | ||
|
|
2a6f3deb50 | ||
|
|
dcc8de83a9 | ||
|
|
e5af1dd633 | ||
|
|
126cbffb8a | ||
|
|
5a70d9b6be | ||
|
|
d1fc748def | ||
|
|
3d2bfc502e | ||
|
|
767736ff1e | ||
|
|
e2ce89a8aa | ||
|
|
6f2d60559e | ||
|
|
68e44642c8 | ||
|
|
eaab34e57e | ||
|
|
4d14a1479a | ||
|
|
3800972dd0 | ||
|
|
e62250453b | ||
|
|
998676dd0c | ||
|
|
a4036654f1 | ||
|
|
dd49d50389 | ||
|
|
8954537f95 | ||
|
|
eb3db231dc | ||
|
|
d04a0b81ee | ||
|
|
edc015886b | ||
|
|
08ec602770 | ||
|
|
ded194eb6a | ||
|
|
4375b82cd9 | ||
|
|
b67ea7ff47 | ||
|
|
5971a4e092 | ||
|
|
da086a0154 | ||
|
|
85383c6363 | ||
|
|
de54618720 | ||
|
|
4fdaf0b4d8 | ||
|
|
f93b8c28e3 | ||
|
|
1fb9f7c68c | ||
|
|
4ca7c2104d | ||
|
|
6bf7ac3185 | ||
|
|
2ffef15675 | ||
|
|
4f8d8ad912 | ||
|
|
6ddc48b058 | ||
|
|
246c676c2b | ||
|
|
116a1446a4 | ||
|
|
53ec32819c | ||
|
|
c179bdab3c | ||
|
|
6d5d467d39 | ||
|
|
3863d6d344 | ||
|
|
2245879af0 | ||
|
|
058c50816c | ||
|
|
35f773c459 | ||
|
|
0c5c4d1b8d | ||
|
|
af9df46525 | ||
|
|
1321bcf5fe | ||
|
|
c1cc3d4ea6 | ||
|
|
fef1a41248 | ||
|
|
0bcc327cab | ||
|
|
70bfd429e5 | ||
|
|
c7f0aab949 | ||
|
|
b349ae1e4c | ||
|
|
550f6e2efc | ||
|
|
840ebe063e | ||
|
|
9c26297c80 | ||
|
|
bfc84bdc6f | ||
|
|
883e11f0a0 | ||
|
|
5e2eba87e6 | ||
|
|
1508dcb9c2 | ||
|
|
448c11f16d | ||
|
|
b4d3092f69 | ||
|
|
236f3b0521 | ||
|
|
ca13993217 | ||
|
|
1c9ffb177c | ||
|
|
fe61d95b44 | ||
|
|
6e848f60ef | ||
|
|
1dd0790654 | ||
|
|
78698381af | ||
|
|
68854cdcdb | ||
|
|
98e94beb1b | ||
|
|
a671d8a27a | ||
|
|
3fd4ccbd8b | ||
|
|
48bf0ea249 | ||
|
|
3170c8d448 | ||
|
|
5a0021146b | ||
|
|
17d8914850 | ||
|
|
775c0e22cf | ||
|
|
cd712b176a | ||
|
|
252d68fd45 | ||
|
|
ea2d66ddc0 | ||
|
|
dcff23a25f | ||
|
|
5b32c9fc66 | ||
|
|
13b474c56e | ||
|
|
e612c3d6f0 | ||
|
|
8f711f79a4 | ||
|
|
6e5489c9f3 | ||
|
|
e7c0d6ee53 | ||
|
|
70bc52e408 | ||
|
|
2124ad72a2 | ||
|
|
86f69e8c2a | ||
|
|
ade5981429 | ||
|
|
f00dc6d7a3 | ||
|
|
e90aa7f280 | ||
|
|
dae94fa652 | ||
|
|
55f518e521 | ||
|
|
369cee018d | ||
|
|
b959cfa056 | ||
|
|
4e8b8573ca | ||
|
|
b6ff96c057 | ||
|
|
691778a08b | ||
|
|
783d11717a | ||
|
|
9aefa74a9f | ||
|
|
684fd14db0 | ||
|
|
c705c7ac9b | ||
|
|
a33c63b9f8 | ||
|
|
854c2ce309 | ||
|
|
78b8155ecb | ||
|
|
c8ede8aa1b | ||
|
|
124fbb0af0 | ||
|
|
7d276bfbee | ||
|
|
1f4200debf | ||
|
|
000ddb8a93 | ||
|
|
cda20eec0c | ||
|
|
79694018f8 | ||
|
|
8f83046f6c | ||
|
|
0d9800743c | ||
|
|
0c22434f03 | ||
|
|
b9c001116e | ||
|
|
0cafe7d50d | ||
|
|
f1f42a7b9f | ||
|
|
8fdaf4d3d6 | ||
|
|
9222f1c491 | ||
|
|
f6d45e5df4 | ||
|
|
1ac8deb3ca | ||
|
|
8b6501786c | ||
|
|
cca2869d78 | ||
|
|
f7e514d4ad | ||
|
|
93e25ceb13 | ||
|
|
3801825efd | ||
|
|
5d2a75ddf2 | ||
|
|
4a1840e683 | ||
|
|
b7d8e280e8 | ||
|
|
7e578f02c8 | ||
|
|
e3ebaa19ba | ||
|
|
9bbad3cc10 | ||
|
|
e3cd4e401d | ||
|
|
8578f898cb | ||
|
|
c386400040 | ||
|
|
0f1d41a88c | ||
|
|
2c8c48fbc7 | ||
|
|
aad5490e74 | ||
|
|
7330183d08 | ||
|
|
326ca754ad | ||
|
|
4632be123d | ||
|
|
2a7047c2ed | ||
|
|
ae005ec588 | ||
|
|
8fb3e2d63a | ||
|
|
c7e8add120 | ||
|
|
aef297a45e | ||
|
|
b3239572f0 | ||
|
|
28b5bd7e93 | ||
|
|
96dc272623 | ||
|
|
e572737274 | ||
|
|
e407376c50 | ||
|
|
f2afa68a4a | ||
|
|
dbafa083b5 | ||
|
|
cc0bd10420 | ||
|
|
a7e7921dbc | ||
|
|
78b0008f44 | ||
|
|
dccf1fb6e0 | ||
|
|
524cbabd89 | ||
|
|
24d3216175 | ||
|
|
fae9166cf4 | ||
|
|
f790c61207 | ||
|
|
9ec0f7cbff | ||
|
|
94fbfb2019 | ||
|
|
d3d1772837 | ||
|
|
0961854b88 | ||
|
|
a02ea9d8ff | ||
|
|
5e4f2301f8 | ||
|
|
281f764e2a | ||
|
|
b3e7133da1 | ||
|
|
2d0aa1b7cb | ||
|
|
11d04d9d5e | ||
|
|
da6b745fff | ||
|
|
726a1a97a7 | ||
|
|
37d1c57f8a | ||
|
|
85f30e07a5 | ||
|
|
c5413c17ad | ||
|
|
7d652fc466 | ||
|
|
e31b74073b | ||
|
|
c730a9976d | ||
|
|
8d95e006b8 | ||
|
|
89d5ee4b10 | ||
|
|
fc9d18b03f | ||
|
|
07e0bb8aae | ||
|
|
81d4316b4a | ||
|
|
c9987f1e22 | ||
|
|
dda3894523 | ||
|
|
ddf83e95b0 | ||
|
|
5269012c51 | ||
|
|
5ec0667fb3 | ||
|
|
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 |
@@ -393,9 +393,9 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed.
|
||||
# Install with: pip install faster-whisper
|
||||
# Model downloads automatically on first use (~150 MB for "base").
|
||||
# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.
|
||||
# Provider priority: local > groq > openai
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai
|
||||
# To use cloud providers instead, set GROQ_API_KEY, VOICE_TOOLS_OPENAI_KEY, or ELEVENLABS_API_KEY above.
|
||||
# Provider priority: local > groq > openai > mistral > xai > elevenlabs
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai | mistral | xai | elevenlabs
|
||||
|
||||
# =============================================================================
|
||||
# STT ADVANCED OVERRIDES (optional)
|
||||
@@ -403,10 +403,12 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Override default STT models per provider (normally set via stt.model in config.yaml)
|
||||
# STT_GROQ_MODEL=whisper-large-v3-turbo
|
||||
# STT_OPENAI_MODEL=whisper-1
|
||||
# STT_ELEVENLABS_MODEL=scribe_v2
|
||||
|
||||
# Override STT provider endpoints (for proxies or self-hosted instances)
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# ELEVENLABS_STT_BASE_URL=https://api.elevenlabs.io/v1
|
||||
|
||||
# =============================================================================
|
||||
# MICROSOFT TEAMS INTEGRATION
|
||||
|
||||
343
.github/workflows/desktop-release.yml
vendored
Normal file
343
.github/workflows/desktop-release.yml
vendored
Normal file
@@ -0,0 +1,343 @@
|
||||
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
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=16384
|
||||
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:
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'web/package-lock.json'
|
||||
- 'web/package.json'
|
||||
- 'apps/dashboard/package-lock.json'
|
||||
- 'apps/dashboard/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -28,7 +28,7 @@ concurrency:
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles 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.
|
||||
#
|
||||
# Safety invariants:
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'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
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -54,6 +54,10 @@ environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
@@ -70,3 +74,12 @@ mini-swe-agent/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
.codex/
|
||||
.cursor/
|
||||
.gemini/
|
||||
.zed/
|
||||
.mcp.json
|
||||
opencode.json
|
||||
config/mcporter.json
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -2,6 +2,8 @@
|
||||
|
||||
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
|
||||
|
||||
**Never give up on the right solution.**
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
@@ -67,6 +69,29 @@ hermes-agent/
|
||||
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
|
||||
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
|
||||
|
||||
## TypeScript Style
|
||||
|
||||
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
|
||||
|
||||
- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
|
||||
- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
|
||||
- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
|
||||
- Do not pass state through three components when the leaf can subscribe to the atom.
|
||||
- Keep persistence beside the atom that owns it.
|
||||
- Keep route roots thin. They compose routes and shell; they should not become controllers.
|
||||
- No monolithic hooks. A hook should own one narrow job.
|
||||
- Prefer colocated action modules over hidden god hooks.
|
||||
- If a callback is pure side effect, use the terse void form:
|
||||
`onState={st => void setGatewayState(st)}`.
|
||||
- Async UI handlers should make intent explicit:
|
||||
`onClick={() => void save()}`.
|
||||
- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
|
||||
- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
|
||||
- Table-driven beats condition ladders when mapping ids, routes, or views.
|
||||
- `src/app` owns routes, pages, and page-specific components.
|
||||
- `src/store` owns shared atoms.
|
||||
- `src/lib` owns shared pure helpers.
|
||||
|
||||
## File Dependency Chain
|
||||
|
||||
```
|
||||
@@ -250,7 +275,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`.
|
||||
|
||||
- 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).
|
||||
- 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`.
|
||||
|
||||
@@ -601,6 +601,7 @@ class SessionManager:
|
||||
),
|
||||
"quiet_mode": True,
|
||||
"session_id": session_id,
|
||||
"session_db": self._get_db(),
|
||||
"model": model or default_model,
|
||||
}
|
||||
|
||||
|
||||
@@ -490,6 +490,29 @@ def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:
|
||||
return True, None
|
||||
|
||||
|
||||
def _peek_pool_entry(provider: str) -> Optional[Any]:
|
||||
"""Best-effort current/next pool entry without mutating selection order."""
|
||||
try:
|
||||
pool = load_pool(provider)
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary client: could not load pool for %s (peek): %s", provider, exc)
|
||||
return None
|
||||
if not pool or not pool.has_credentials():
|
||||
return None
|
||||
try:
|
||||
current_fn = getattr(pool, "current", None)
|
||||
if callable(current_fn):
|
||||
current = current_fn()
|
||||
if current is not None:
|
||||
return current
|
||||
peek_fn = getattr(pool, "peek", None)
|
||||
if callable(peek_fn):
|
||||
return peek_fn()
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary client: could not peek pool entry for %s: %s", provider, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _pool_runtime_api_key(entry: Any) -> str:
|
||||
if entry is None:
|
||||
return ""
|
||||
@@ -1440,7 +1463,16 @@ def _read_main_model() -> str:
|
||||
|
||||
config.yaml model.default is the single source of truth for the active
|
||||
model. Environment variables are no longer consulted.
|
||||
|
||||
Runtime override: when an AIAgent is active with a CLI/gateway-provided
|
||||
model that differs from config.yaml, ``set_runtime_main()`` records the
|
||||
override in a process-local global. This is consulted FIRST so tools
|
||||
that gate on "the active main model" (e.g. ``vision_analyze``'s native
|
||||
fast path) see the live runtime, not the persisted config default.
|
||||
"""
|
||||
override = _RUNTIME_MAIN_MODEL
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.strip()
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
@@ -1461,7 +1493,13 @@ def _read_main_provider() -> str:
|
||||
|
||||
Returns the lowercase provider id (e.g. "alibaba", "openrouter") or ""
|
||||
if not configured.
|
||||
|
||||
Runtime override: see ``_read_main_model`` — same mechanism for the
|
||||
provider half of the runtime tuple.
|
||||
"""
|
||||
override = _RUNTIME_MAIN_PROVIDER
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.strip().lower()
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
@@ -1475,6 +1513,32 @@ def _read_main_provider() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# Process-local override set by AIAgent at session/turn start. Single-threaded
|
||||
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
|
||||
_RUNTIME_MAIN_PROVIDER: str = ""
|
||||
_RUNTIME_MAIN_MODEL: str = ""
|
||||
|
||||
|
||||
def set_runtime_main(provider: str, model: str) -> None:
|
||||
"""Record the live runtime provider/model for the current AIAgent.
|
||||
|
||||
Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
|
||||
equivalent setter) at the top of each turn so that
|
||||
``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
|
||||
overrides instead of the stale config.yaml default.
|
||||
"""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
_RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
|
||||
_RUNTIME_MAIN_MODEL = (model or "").strip()
|
||||
|
||||
|
||||
def clear_runtime_main() -> None:
|
||||
"""Clear the runtime override (e.g. on session end)."""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
_RUNTIME_MAIN_PROVIDER = ""
|
||||
_RUNTIME_MAIN_MODEL = ""
|
||||
|
||||
|
||||
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve the active custom/main endpoint the same way the main CLI does.
|
||||
|
||||
@@ -1817,10 +1881,12 @@ def _is_connection_error(exc: Exception) -> bool:
|
||||
distinct from API errors (4xx/5xx) which indicate the provider IS
|
||||
reachable but returned an error.
|
||||
"""
|
||||
from openai import APIConnectionError, APITimeoutError
|
||||
|
||||
if isinstance(exc, (APIConnectionError, APITimeoutError)):
|
||||
return True
|
||||
try:
|
||||
from openai import APIConnectionError, APITimeoutError
|
||||
if isinstance(exc, (APIConnectionError, APITimeoutError)):
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
# urllib3 / httpx / httpcore connection errors
|
||||
err_type = type(exc).__name__
|
||||
if any(kw in err_type for kw in ("Connection", "Timeout", "DNS", "SSL")):
|
||||
@@ -1830,6 +1896,16 @@ def _is_connection_error(exc: Exception) -> bool:
|
||||
"connection refused", "name or service not known",
|
||||
"no route to host", "network is unreachable",
|
||||
"timed out", "connection reset",
|
||||
# httpcore / httpx streaming premature-close errors. These surface
|
||||
# when a proxy or provider drops the connection mid-stream and are
|
||||
# transient by nature — the request should be retried or rerouted.
|
||||
# See issue #18458.
|
||||
"incomplete chunked read",
|
||||
"peer closed connection",
|
||||
"response ended prematurely",
|
||||
"unexpected eof",
|
||||
"remoteprotocolerror",
|
||||
"localprotocolerror",
|
||||
)):
|
||||
return True
|
||||
return False
|
||||
@@ -1908,6 +1984,211 @@ def _evict_cached_clients(provider: str) -> None:
|
||||
_client_cache.pop(key, None)
|
||||
|
||||
|
||||
def _pool_cache_hint(
|
||||
provider: str,
|
||||
*,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Return a stable cache discriminator for pooled providers."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
if normalized == "auto":
|
||||
runtime = _normalize_main_runtime(main_runtime)
|
||||
normalized = _normalize_aux_provider(runtime.get("provider") or _read_main_provider())
|
||||
if normalized in ("", "auto", "custom"):
|
||||
return ""
|
||||
entry = _peek_pool_entry(normalized)
|
||||
if entry is None:
|
||||
return ""
|
||||
entry_id = str(getattr(entry, "id", "") or "").strip()
|
||||
if not entry_id:
|
||||
return ""
|
||||
return f"{normalized}:{entry_id}"
|
||||
|
||||
|
||||
def _pool_error_context(exc: Exception) -> Dict[str, Any]:
|
||||
status = getattr(exc, "status_code", None)
|
||||
payload: Dict[str, Any] = {"message": str(exc)}
|
||||
if status is not None:
|
||||
payload["status_code"] = status
|
||||
return payload
|
||||
|
||||
|
||||
def _recoverable_pool_provider(resolved_provider: str, client: Any) -> Optional[str]:
|
||||
"""Infer which provider pool can recover the current auxiliary client."""
|
||||
normalized = _normalize_aux_provider(resolved_provider)
|
||||
if normalized not in ("", "auto", "custom"):
|
||||
return normalized
|
||||
base = str(getattr(client, "base_url", "") or "")
|
||||
if base_url_host_matches(base, "chatgpt.com"):
|
||||
return "openai-codex"
|
||||
if base_url_host_matches(base, "openrouter.ai"):
|
||||
return "openrouter"
|
||||
if base_url_host_matches(base, "inference-api.nousresearch.com"):
|
||||
return "nous"
|
||||
if base_url_host_matches(base, "api.anthropic.com"):
|
||||
return "anthropic"
|
||||
if base_url_host_matches(base, "api.githubcopilot.com"):
|
||||
return "copilot"
|
||||
if base_url_host_matches(base, "api.kimi.com"):
|
||||
return "kimi-coding"
|
||||
return None
|
||||
|
||||
|
||||
def _recover_provider_pool(provider: str, exc: Exception) -> bool:
|
||||
"""Try same-provider credential-pool recovery for auxiliary calls."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
try:
|
||||
pool = load_pool(normalized)
|
||||
except Exception as load_exc:
|
||||
logger.debug("Auxiliary client: could not load pool for %s recovery: %s", normalized, load_exc)
|
||||
return False
|
||||
if not pool or not pool.has_credentials():
|
||||
return False
|
||||
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
error_context = _pool_error_context(exc)
|
||||
|
||||
if _is_auth_error(exc):
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is not None:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
next_entry = pool.mark_exhausted_and_rotate(
|
||||
status_code=status_code if status_code is not None else 401,
|
||||
error_context=error_context,
|
||||
)
|
||||
if next_entry is not None:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
return False
|
||||
|
||||
if _is_payment_error(exc) or _is_rate_limit_error(exc):
|
||||
fallback_status = 402 if _is_payment_error(exc) else 429
|
||||
next_entry = pool.mark_exhausted_and_rotate(
|
||||
status_code=status_code if status_code is not None else fallback_status,
|
||||
error_context=error_context,
|
||||
)
|
||||
if next_entry is not None:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _retry_same_provider_sync(
|
||||
*,
|
||||
task: Optional[str],
|
||||
resolved_provider: str,
|
||||
resolved_model: Optional[str],
|
||||
resolved_base_url: Optional[str],
|
||||
resolved_api_key: Optional[str],
|
||||
resolved_api_mode: Optional[str],
|
||||
main_runtime: Optional[Dict[str, Any]],
|
||||
final_model: Optional[str],
|
||||
messages: list,
|
||||
temperature: Optional[float],
|
||||
max_tokens: Optional[int],
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
) -> Any:
|
||||
if task == "vision":
|
||||
_, retry_client, retry_model = resolve_vision_provider_client(
|
||||
provider=resolved_provider,
|
||||
model=final_model,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
async_mode=False,
|
||||
)
|
||||
else:
|
||||
retry_client, retry_model = _get_cached_client(
|
||||
resolved_provider,
|
||||
resolved_model,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
main_runtime=main_runtime,
|
||||
)
|
||||
if retry_client is None:
|
||||
raise RuntimeError(
|
||||
f"Auxiliary {task or 'call'}: provider {resolved_provider} could not be rebuilt after recovery"
|
||||
)
|
||||
|
||||
retry_base = str(getattr(retry_client, "base_url", "") or "")
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
resolved_provider,
|
||||
retry_model or final_model,
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
)
|
||||
|
||||
|
||||
async def _retry_same_provider_async(
|
||||
*,
|
||||
task: Optional[str],
|
||||
resolved_provider: str,
|
||||
resolved_model: Optional[str],
|
||||
resolved_base_url: Optional[str],
|
||||
resolved_api_key: Optional[str],
|
||||
resolved_api_mode: Optional[str],
|
||||
final_model: Optional[str],
|
||||
messages: list,
|
||||
temperature: Optional[float],
|
||||
max_tokens: Optional[int],
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
) -> Any:
|
||||
if task == "vision":
|
||||
_, retry_client, retry_model = resolve_vision_provider_client(
|
||||
provider=resolved_provider,
|
||||
model=final_model,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
async_mode=True,
|
||||
)
|
||||
else:
|
||||
retry_client, retry_model = _get_cached_client(
|
||||
resolved_provider,
|
||||
resolved_model,
|
||||
async_mode=True,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
)
|
||||
if retry_client is None:
|
||||
raise RuntimeError(
|
||||
f"Auxiliary {task or 'call'}: provider {resolved_provider} could not be rebuilt after recovery"
|
||||
)
|
||||
|
||||
retry_base = str(getattr(retry_client, "base_url", "") or "")
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
resolved_provider,
|
||||
retry_model or final_model,
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
)
|
||||
|
||||
|
||||
def _refresh_provider_credentials(provider: str) -> bool:
|
||||
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -3033,7 +3314,8 @@ def _client_cache_key(
|
||||
) -> tuple:
|
||||
runtime = _normalize_main_runtime(main_runtime)
|
||||
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision)
|
||||
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
|
||||
|
||||
|
||||
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
||||
@@ -3821,39 +4103,56 @@ def call_llm(
|
||||
"Auxiliary %s: refreshed %s credentials after auth error, retrying",
|
||||
task or "call", resolved_provider,
|
||||
)
|
||||
retry_client, retry_model = (
|
||||
resolve_vision_provider_client(
|
||||
provider=resolved_provider,
|
||||
model=final_model,
|
||||
async_mode=False,
|
||||
)[1:]
|
||||
if task == "vision"
|
||||
else _get_cached_client(
|
||||
resolved_provider,
|
||||
resolved_model,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
main_runtime=main_runtime,
|
||||
)
|
||||
return _retry_same_provider_sync(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
main_runtime=main_runtime,
|
||||
final_model=final_model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
)
|
||||
if retry_client is not None:
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
resolved_provider,
|
||||
retry_model or final_model,
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=resolved_base_url,
|
||||
)
|
||||
_retry_base = str(getattr(retry_client, "base_url", "") or "")
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
|
||||
# ── Same-provider credential-pool recovery ─────────────────────
|
||||
pool_provider = _recoverable_pool_provider(resolved_provider, client)
|
||||
if pool_provider and (_is_auth_error(first_err) or _is_payment_error(first_err) or _is_rate_limit_error(first_err)):
|
||||
recovery_err = first_err
|
||||
if _is_rate_limit_error(first_err):
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
recovery_err = retry_err
|
||||
if _recover_provider_pool(pool_provider, recovery_err):
|
||||
logger.info(
|
||||
"Auxiliary %s: recovered %s via credential-pool rotation after %s",
|
||||
task or "call", pool_provider, type(recovery_err).__name__,
|
||||
)
|
||||
return _retry_same_provider_sync(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
main_runtime=main_runtime,
|
||||
final_model=final_model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
)
|
||||
|
||||
# ── Payment / credit exhaustion fallback ──────────────────────
|
||||
# When the resolved provider returns 402 or a credit-related error,
|
||||
@@ -4136,38 +4435,54 @@ async def async_call_llm(
|
||||
"Auxiliary %s (async): refreshed %s credentials after auth error, retrying",
|
||||
task or "call", resolved_provider,
|
||||
)
|
||||
if task == "vision":
|
||||
_, retry_client, retry_model = resolve_vision_provider_client(
|
||||
provider=resolved_provider,
|
||||
model=final_model,
|
||||
async_mode=True,
|
||||
)
|
||||
else:
|
||||
retry_client, retry_model = _get_cached_client(
|
||||
resolved_provider,
|
||||
resolved_model,
|
||||
async_mode=True,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
)
|
||||
if retry_client is not None:
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
resolved_provider,
|
||||
retry_model or final_model,
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=resolved_base_url,
|
||||
)
|
||||
_retry_base = str(getattr(retry_client, "base_url", "") or "")
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return await _retry_same_provider_async(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
final_model=final_model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
)
|
||||
|
||||
# ── Same-provider credential-pool recovery (mirrors sync) ─────
|
||||
pool_provider = _recoverable_pool_provider(resolved_provider, client)
|
||||
if pool_provider and (_is_auth_error(first_err) or _is_payment_error(first_err) or _is_rate_limit_error(first_err)):
|
||||
recovery_err = first_err
|
||||
if _is_rate_limit_error(first_err):
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
recovery_err = retry_err
|
||||
if _recover_provider_pool(pool_provider, recovery_err):
|
||||
logger.info(
|
||||
"Auxiliary %s (async): recovered %s via credential-pool rotation after %s",
|
||||
task or "call", pool_provider, type(recovery_err).__name__,
|
||||
)
|
||||
return await _retry_same_provider_async(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
final_model=final_model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body,
|
||||
)
|
||||
|
||||
# ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
|
||||
should_fallback = (
|
||||
|
||||
@@ -410,10 +410,29 @@ def _chat_messages_to_responses_input(messages: List[Dict[str, Any]]) -> List[Di
|
||||
call_id = raw_tool_call_id.strip()
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
continue
|
||||
|
||||
# Multimodal tool result: convert OpenAI-style content list into
|
||||
# Responses ``function_call_output.output`` array. The Responses
|
||||
# API accepts ``output`` as either a string or an array of
|
||||
# ``input_text``/``input_image`` items. See
|
||||
# https://developers.openai.com/api/reference/python/resources/responses/.
|
||||
tool_content = msg.get("content")
|
||||
output_value: Any
|
||||
if isinstance(tool_content, list):
|
||||
converted = _chat_content_to_responses_parts(
|
||||
tool_content, role="user",
|
||||
)
|
||||
if converted:
|
||||
output_value = converted
|
||||
else:
|
||||
output_value = ""
|
||||
else:
|
||||
output_value = str(tool_content or "")
|
||||
|
||||
items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": str(msg.get("content", "") or ""),
|
||||
"output": output_value,
|
||||
})
|
||||
|
||||
return items
|
||||
@@ -466,6 +485,38 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
|
||||
output = item.get("output", "")
|
||||
if output is None:
|
||||
output = ""
|
||||
# Output may be a string OR an array of structured content
|
||||
# items (input_text / input_image) for multimodal tool results.
|
||||
# Both shapes are accepted by the Responses API. We preserve
|
||||
# the array form when present.
|
||||
if isinstance(output, list):
|
||||
# Validate each item is a recognised content shape; drop
|
||||
# anything else to avoid 4xx from the API.
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for part in output:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
if ptype == "input_text":
|
||||
text = part.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
cleaned.append({"type": "input_text", "text": text})
|
||||
elif ptype == "input_image":
|
||||
url = part.get("image_url")
|
||||
if isinstance(url, str) and url:
|
||||
entry: Dict[str, Any] = {"type": "input_image", "image_url": url}
|
||||
detail = part.get("detail")
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
entry["detail"] = detail.strip()
|
||||
cleaned.append(entry)
|
||||
normalized.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id.strip(),
|
||||
"output": cleaned if cleaned else "",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not isinstance(output, str):
|
||||
output = str(output)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
from agent.auxiliary_client import call_llm, _is_connection_error
|
||||
from agent.context_engine import ContextEngine
|
||||
from agent.model_metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
@@ -763,6 +763,33 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None:
|
||||
"""Switch from a separate ``summary_model`` back to the main model.
|
||||
|
||||
Centralises the bookkeeping shared by every fallback branch in
|
||||
:meth:`_generate_summary` (model-not-found, timeout, JSON decode,
|
||||
unknown error): record the aux-model failure for ``/usage``-style
|
||||
callers, clear the summary model so the next call uses the main one,
|
||||
and clear the cooldown so the immediate retry can run.
|
||||
|
||||
``reason`` is a short human-readable phrase ("unavailable",
|
||||
"timed out", "returned invalid JSON", "failed") that is interpolated
|
||||
into the warning log.
|
||||
"""
|
||||
self._summary_model_fallen_back = True
|
||||
logging.warning(
|
||||
"Summary model '%s' %s (%s). "
|
||||
"Falling back to main model '%s' for compression.",
|
||||
self.summary_model, reason, e, self.model,
|
||||
)
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately
|
||||
|
||||
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:
|
||||
"""Generate a structured summary of conversation turns.
|
||||
|
||||
@@ -961,28 +988,52 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
_status in (408, 429, 502, 504)
|
||||
or "timeout" in _err_str
|
||||
)
|
||||
# Non-JSON / malformed-body responses from misconfigured providers
|
||||
# or proxies (e.g. an HTML 502 page returned with
|
||||
# ``Content-Type: application/json``) bubble up as
|
||||
# ``json.JSONDecodeError`` from the OpenAI SDK's ``response.json()``,
|
||||
# or as a wrapping ``APIResponseValidationError`` whose message
|
||||
# carries the substring "expecting value". Treat these like a
|
||||
# transient provider failure: one retry on the main model, then a
|
||||
# short cooldown. Issue #22244.
|
||||
_is_json_decode = (
|
||||
isinstance(e, json.JSONDecodeError)
|
||||
or "expecting value" in _err_str
|
||||
)
|
||||
# httpcore / httpx streaming premature-close errors surface as
|
||||
# ConnectionError subclasses or plain Exception with characteristic
|
||||
# substrings ("incomplete chunked read", "peer closed connection",
|
||||
# "response ended prematurely", "unexpected eof"). These are
|
||||
# transient network events; treat them like a timeout so we fall
|
||||
# back to the main model instead of entering a 60-second cooldown.
|
||||
# See issue #18458.
|
||||
_is_streaming_closed = _is_connection_error(e)
|
||||
if _is_json_decode and not _is_model_not_found and not _is_timeout:
|
||||
logger.error(
|
||||
"Context compression failed: auxiliary LLM returned a "
|
||||
"non-JSON response. provider=%s summary_model=%s "
|
||||
"main_model=%s base_url=%s err=%s",
|
||||
self.provider or "auto",
|
||||
self.summary_model or "(main)",
|
||||
self.model,
|
||||
self.base_url or "default",
|
||||
e,
|
||||
)
|
||||
if (
|
||||
(_is_model_not_found or _is_timeout)
|
||||
(_is_model_not_found or _is_timeout or _is_json_decode or _is_streaming_closed)
|
||||
and self.summary_model
|
||||
and self.summary_model != self.model
|
||||
and not getattr(self, "_summary_model_fallen_back", False)
|
||||
):
|
||||
self._summary_model_fallen_back = True
|
||||
logging.warning(
|
||||
"Summary model '%s' unavailable (%s). "
|
||||
"Falling back to main model '%s' for compression.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure so callers can warn the user
|
||||
# even if the retry-on-main succeeds — a misconfigured aux
|
||||
# model is something the user needs to fix.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown
|
||||
if _is_json_decode:
|
||||
_reason = "returned invalid JSON"
|
||||
elif _is_model_not_found:
|
||||
_reason = "unavailable"
|
||||
elif _is_streaming_closed:
|
||||
_reason = "closed stream prematurely"
|
||||
else:
|
||||
_reason = "timed out"
|
||||
self._fallback_to_main_for_compression(e, _reason)
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) # retry immediately
|
||||
|
||||
# Unknown-error best-effort retry on main model. Losing N turns of
|
||||
@@ -999,26 +1050,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
and self.summary_model != self.model
|
||||
and not getattr(self, "_summary_model_fallen_back", False)
|
||||
):
|
||||
self._summary_model_fallen_back = True
|
||||
logging.warning(
|
||||
"Summary model '%s' failed (%s). "
|
||||
"Retrying on main model '%s' before giving up.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure (see 404 branch above) — user
|
||||
# should know their configured model is broken even if main
|
||||
# recovers the call.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._fallback_to_main_for_compression(e, "failed")
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
|
||||
# Transient errors (timeout, rate limit, network) — shorter cooldown
|
||||
_transient_cooldown = 60
|
||||
# Transient errors (timeout, rate limit, network, JSON decode,
|
||||
# streaming premature-close) — shorter cooldown for JSON decode and
|
||||
# streaming-closed since those conditions can self-resolve quickly.
|
||||
_transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60
|
||||
self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown
|
||||
err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(err_text) > 220:
|
||||
|
||||
@@ -72,6 +72,7 @@ def _default_state() -> Dict[str, Any]:
|
||||
"last_run_at": None,
|
||||
"last_run_duration_seconds": None,
|
||||
"last_run_summary": None,
|
||||
"last_run_summary_shown_at": None,
|
||||
"last_report_path": None,
|
||||
"paused": False,
|
||||
"run_count": 0,
|
||||
@@ -876,6 +877,82 @@ def _reconcile_classification(
|
||||
return {"consolidated": consolidated, "pruned": pruned}
|
||||
|
||||
|
||||
def _build_rename_summary(
|
||||
*,
|
||||
before_names: Set[str],
|
||||
after_report: List[Dict[str, Any]],
|
||||
tool_calls: List[Dict[str, Any]],
|
||||
model_final: str,
|
||||
) -> str:
|
||||
"""Format the user-visible rename map for a curator run.
|
||||
|
||||
Renders the "where did my skills go?" lines that get appended to the
|
||||
`final_summary` string fed to gateway/CLI receivers. Empty string when
|
||||
nothing was archived this run — most ticks are no-op and shouldn't add
|
||||
extra log noise.
|
||||
|
||||
Format::
|
||||
|
||||
archived 4 skill(s):
|
||||
• pdf-extraction → document-tools
|
||||
• docx-extraction → document-tools
|
||||
• flaky-thing — pruned (stale)
|
||||
• old-utility → spreadsheet-ops
|
||||
full report: hermes curator status
|
||||
|
||||
Cap is 10 entries so a 50-skill consolidation doesn't blow up
|
||||
agent.log; the full list is always in REPORT.md.
|
||||
"""
|
||||
after_by_name = {r.get("name"): r for r in after_report if isinstance(r, dict)}
|
||||
after_names = set(after_by_name.keys())
|
||||
removed = sorted(before_names - after_names)
|
||||
added = sorted(after_names - before_names)
|
||||
if not removed:
|
||||
return ""
|
||||
|
||||
heuristic = _classify_removed_skills(
|
||||
removed=removed,
|
||||
added=added,
|
||||
after_names=after_names,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
model_block = _parse_structured_summary(model_final)
|
||||
destinations = set(after_names) | set(added)
|
||||
absorbed_declarations = _extract_absorbed_into_declarations(tool_calls)
|
||||
classification = _reconcile_classification(
|
||||
removed=removed,
|
||||
heuristic=heuristic,
|
||||
model_block=model_block,
|
||||
destinations=destinations,
|
||||
absorbed_declarations=absorbed_declarations,
|
||||
)
|
||||
consolidated = classification["consolidated"]
|
||||
pruned = classification["pruned"]
|
||||
|
||||
SHOW = 10
|
||||
lines: List[str] = []
|
||||
total = len(consolidated) + len(pruned)
|
||||
lines.append(f"archived {total} skill(s):")
|
||||
shown = 0
|
||||
for entry in consolidated:
|
||||
if shown >= SHOW:
|
||||
break
|
||||
name = entry.get("name", "?")
|
||||
into = entry.get("into", "?")
|
||||
lines.append(f" • {name} → {into}")
|
||||
shown += 1
|
||||
for entry in pruned:
|
||||
if shown >= SHOW:
|
||||
break
|
||||
name = entry.get("name", "?") if isinstance(entry, dict) else str(entry)
|
||||
lines.append(f" • {name} — pruned (stale)")
|
||||
shown += 1
|
||||
if total > SHOW:
|
||||
lines.append(f" … and {total - SHOW} more")
|
||||
lines.append("full report: hermes curator status")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _write_run_report(
|
||||
*,
|
||||
started_at: datetime,
|
||||
@@ -1398,6 +1475,22 @@ def run_curator_review(
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# Append the rename map (`old-name → umbrella`) to the user-visible
|
||||
# summary so people don't have to dig into REPORT.md to find out where
|
||||
# their skills went. Best-effort: classification is pure but never
|
||||
# block the run on a formatting issue.
|
||||
try:
|
||||
rename_lines = _build_rename_summary(
|
||||
before_names=before_names,
|
||||
after_report=skill_usage.agent_created_report(),
|
||||
tool_calls=llm_meta.get("tool_calls", []) or [],
|
||||
model_final=llm_meta.get("final", "") or "",
|
||||
)
|
||||
if rename_lines:
|
||||
final_summary = f"{final_summary}\n{rename_lines}"
|
||||
except Exception as e:
|
||||
logger.debug("Curator rename summary build failed: %s", e, exc_info=True)
|
||||
|
||||
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
||||
state2 = load_state()
|
||||
state2["last_run_duration_seconds"] = elapsed
|
||||
|
||||
@@ -254,6 +254,20 @@ _THINKING_SIG_PATTERNS = [
|
||||
"signature", # Combined with "thinking" check
|
||||
]
|
||||
|
||||
# Message-string patterns that indicate a provider-side timeout even when
|
||||
# the exception type is generic (e.g. RuntimeError from a local shim that
|
||||
# wraps a subprocess timeout). Checked before the type-based transport
|
||||
# heuristics so custom-provider "timed out" errors don't fall through to
|
||||
# the unknown bucket and get misreported as empty responses.
|
||||
_TIMEOUT_MESSAGE_PATTERNS = [
|
||||
"timed out",
|
||||
"turn timed out",
|
||||
"request timed out",
|
||||
"deadline exceeded",
|
||||
"operation timed out",
|
||||
"upstream timed out",
|
||||
]
|
||||
|
||||
# Transport error type names
|
||||
_TRANSPORT_ERROR_TYPES = frozenset({
|
||||
"ReadTimeout", "ConnectTimeout", "PoolTimeout",
|
||||
@@ -963,6 +977,14 @@ def _classify_by_message(
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Timeout message patterns — generic exception types (e.g. RuntimeError)
|
||||
# raised by local shims or custom providers that internally wrap a
|
||||
# subprocess/HTTP timeout. Classified as transport timeout so the retry
|
||||
# loop rebuilds the client instead of treating the turn as an empty
|
||||
# model response.
|
||||
if any(p in error_msg for p in _TIMEOUT_MESSAGE_PATTERNS):
|
||||
return result_fn(FailoverReason.timeout, retryable=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,13 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
|
||||
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
|
||||
"gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context)
|
||||
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) and
|
||||
# uses a smaller 128k window than other gpt-5.x slugs. Listed here as
|
||||
# a defensive override so the longest-substring fallback doesn't match
|
||||
# the generic "gpt-5" entry below (400k) and report the wrong limit if
|
||||
# Spark's context ever needs to be resolved through this path. Real
|
||||
# usage flows through _CODEX_OAUTH_CONTEXT_FALLBACK at line ~1113.
|
||||
"gpt-5.3-codex-spark": 128000,
|
||||
"gpt-5.1-chat": 128000, # Chat variant has 128k context
|
||||
"gpt-5": 400000, # GPT-5.x base, mini, codex variants (400k)
|
||||
"gpt-4.1": 1047576,
|
||||
@@ -210,8 +217,10 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
|
||||
# Kimi
|
||||
"kimi": 262144,
|
||||
# Tencent — Hy3 Preview (Hunyuan) with 256K context window
|
||||
"hy3-preview": 256000,
|
||||
# Tencent — Hy3 Preview (Hunyuan) with 256K context window.
|
||||
# OpenRouter live metadata reports 262144 (256 × 1024); align the
|
||||
# static fallback so cache and offline both agree (issue #22268).
|
||||
"hy3-preview": 262144,
|
||||
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
|
||||
"nemotron": 131072,
|
||||
# Arcee
|
||||
@@ -1106,6 +1115,12 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
|
||||
"gpt-5.1-codex-max": 272_000,
|
||||
"gpt-5.1-codex-mini": 272_000,
|
||||
"gpt-5.3-codex": 272_000,
|
||||
# Spark runs on specialised low-latency hardware and exposes a smaller
|
||||
# 128k window than other Codex OAuth slugs. Listed explicitly so the
|
||||
# longest-key-first fallback resolves it correctly — substring match
|
||||
# on "gpt-5.3-codex" otherwise wins and reports 272k. Availability is
|
||||
# gated by ChatGPT Pro entitlement on the Codex backend.
|
||||
"gpt-5.3-codex-spark": 128_000,
|
||||
"gpt-5.2-codex": 272_000,
|
||||
"gpt-5.4-mini": 272_000,
|
||||
"gpt-5.5": 272_000,
|
||||
|
||||
@@ -197,6 +197,32 @@ def _load_disk_cache() -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _disk_cache_age_seconds() -> Optional[float]:
|
||||
"""Return age (in seconds) of the disk cache file, or None if missing.
|
||||
|
||||
Used by ``fetch_models_dev`` to short-circuit the network probe when
|
||||
a recent on-disk cache exists. Errors (missing file, permission
|
||||
denied, weird filesystem) all return None — callers fall through
|
||||
to the network fetch path.
|
||||
"""
|
||||
try:
|
||||
cache_path = _get_cache_path()
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
mtime = cache_path.stat().st_mtime
|
||||
age = time.time() - mtime
|
||||
# Negative age means the file's mtime is in the future (clock skew
|
||||
# or system clock reset). Treat as "unknown freshness" → fall
|
||||
# through to network so we don't serve potentially-bad data
|
||||
# forever.
|
||||
if age < 0:
|
||||
return None
|
||||
return age
|
||||
except Exception as e:
|
||||
logger.debug("Failed to stat models.dev disk cache: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _save_disk_cache(data: Dict[str, Any]) -> None:
|
||||
"""Save models.dev data to disk cache atomically."""
|
||||
try:
|
||||
@@ -207,13 +233,29 @@ def _save_disk_cache(data: Dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""Fetch models.dev registry. In-memory cache (1hr) + disk fallback.
|
||||
"""Fetch models.dev registry. Cache hierarchy: in-mem → disk → network.
|
||||
|
||||
Returns the full registry dict keyed by provider ID, or empty dict on failure.
|
||||
|
||||
Cache hierarchy (when ``force_refresh=False``):
|
||||
1. In-memory cache, populated and < TTL old → return immediately.
|
||||
2. **Disk cache file < TTL old by mtime → load, populate in-mem, return.**
|
||||
No network call. Saves ~500 ms per cold-start agent construction;
|
||||
``models.dev`` only changes when providers add new models, so a
|
||||
1 hour staleness window is acceptable (same TTL as in-mem cache).
|
||||
3. Network fetch → on success, save to disk + in-mem and return.
|
||||
4. Network fails → fall back to ANY available disk cache (even stale)
|
||||
with a short 5 min in-mem grace period before retrying network.
|
||||
|
||||
When ``force_refresh=True`` (used by ``hermes config refresh``, the
|
||||
\"refresh model catalog\" code path), stages 1 and 2 are skipped. The
|
||||
function always hits the network and only falls back to disk if the
|
||||
network call fails.
|
||||
"""
|
||||
global _models_dev_cache, _models_dev_cache_time
|
||||
|
||||
# Check in-memory cache
|
||||
# Stage 1: fresh in-memory cache wins. This is the hot path on
|
||||
# long-lived processes — no I/O, no system calls.
|
||||
if (
|
||||
not force_refresh
|
||||
and _models_dev_cache
|
||||
@@ -221,7 +263,27 @@ def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
|
||||
):
|
||||
return _models_dev_cache
|
||||
|
||||
# Try network fetch
|
||||
# Stage 2: fresh-by-mtime disk cache short-circuits the network call.
|
||||
# Only kicks in on cold-start processes (in-mem cache is empty or
|
||||
# expired) and only when the user hasn't asked for a forced refresh.
|
||||
# Skipped if the disk cache file is missing, unreadable, or older
|
||||
# than _MODELS_DEV_CACHE_TTL.
|
||||
if not force_refresh:
|
||||
disk_age = _disk_cache_age_seconds()
|
||||
if disk_age is not None and disk_age < _MODELS_DEV_CACHE_TTL:
|
||||
disk_data = _load_disk_cache()
|
||||
if disk_data:
|
||||
_models_dev_cache = disk_data
|
||||
# Anchor in-mem TTL to the disk file's age so we don't
|
||||
# extend an already-aging cache by another full hour.
|
||||
_models_dev_cache_time = time.time() - disk_age
|
||||
logger.debug(
|
||||
"Loaded models.dev from fresh disk cache "
|
||||
"(%d providers, age=%.0fs)", len(disk_data), disk_age,
|
||||
)
|
||||
return _models_dev_cache
|
||||
|
||||
# Stage 3: network fetch.
|
||||
try:
|
||||
response = requests.get(MODELS_DEV_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
@@ -239,8 +301,9 @@ def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch models.dev: %s", e)
|
||||
|
||||
# Fall back to disk cache — use a short TTL (5 min) so we retry
|
||||
# the network fetch soon instead of serving stale data for a full hour.
|
||||
# Stage 4: network failed — fall back to whatever disk cache exists,
|
||||
# even if it's stale. Give it a short 5 min in-mem TTL so we retry
|
||||
# the network soon instead of serving stale data for a full hour.
|
||||
if not _models_dev_cache:
|
||||
_models_dev_cache = _load_disk_cache()
|
||||
if _models_dev_cache:
|
||||
|
||||
@@ -157,6 +157,9 @@ MEMORY_GUIDANCE = (
|
||||
"User preferences and recurring corrections matter more than procedural task details.\n"
|
||||
"Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO "
|
||||
"state to memory; use session_search to recall those from past transcripts. "
|
||||
"Specifically: do not record PR numbers, issue numbers, commit SHAs, 'fixed bug X', "
|
||||
"'submitted PR Y', 'Phase N done', file counts, or any artifact that will be stale "
|
||||
"in 7 days. If a fact will be stale in a week, it does not belong in memory. "
|
||||
"If you've discovered a new way to do something, solved a problem that could be "
|
||||
"necessary later, save it as a skill with the skill tool.\n"
|
||||
"Write memories as declarative facts, not instructions to yourself. "
|
||||
@@ -564,6 +567,18 @@ PLATFORM_HINTS = {
|
||||
"code fences). Treat this like a conversation, not a document. Keep responses "
|
||||
"brief and natural."
|
||||
),
|
||||
"webui": (
|
||||
"You are in the Hermes WebUI, a browser-based chat interface. "
|
||||
"Full Markdown rendering is supported — headings, bold, italic, code "
|
||||
"blocks, tables, math (LaTeX), and Mermaid diagrams all render natively. "
|
||||
"To display local or remote media/files inline, include "
|
||||
"MEDIA:/absolute/path/to/file or MEDIA:https://... in your response. "
|
||||
"Local file paths must be absolute. Images, audio (with playback speed "
|
||||
"controls), video, PDFs, HTML, CSV, diffs/patches, and Excalidraw files "
|
||||
"render as rich previews. Do not use Markdown image syntax like "
|
||||
" for local files; local paths are not served that way. "
|
||||
"Use MEDIA:/absolute/path instead."
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -323,6 +323,21 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if provider_prefs and is_openrouter:
|
||||
extra_body["provider"] = provider_prefs
|
||||
|
||||
# Pareto Code router plugin — model-gated. Same shape as the
|
||||
# profile path in plugins/model-providers/openrouter/__init__.py;
|
||||
# this branch only runs when the OpenRouter profile isn't loaded.
|
||||
if is_openrouter and model == "openrouter/pareto-code":
|
||||
_pareto_score = params.get("openrouter_min_coding_score")
|
||||
if _pareto_score is not None and _pareto_score != "":
|
||||
try:
|
||||
_pareto_score_f = float(_pareto_score)
|
||||
except (TypeError, ValueError):
|
||||
_pareto_score_f = None
|
||||
if _pareto_score_f is not None and 0.0 <= _pareto_score_f <= 1.0:
|
||||
extra_body["plugins"] = [
|
||||
{"id": "pareto-router", "min_coding_score": _pareto_score_f}
|
||||
]
|
||||
|
||||
# Kimi extra_body.thinking
|
||||
if is_kimi:
|
||||
_kimi_thinking_enabled = True
|
||||
@@ -448,6 +463,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
qwen_session_metadata=params.get("qwen_session_metadata"),
|
||||
model=model,
|
||||
ollama_num_ctx=params.get("ollama_num_ctx"),
|
||||
session_id=params.get("session_id"),
|
||||
)
|
||||
)
|
||||
api_kwargs.update(top_level_from_profile)
|
||||
@@ -462,6 +478,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
model=model,
|
||||
base_url=params.get("base_url"),
|
||||
reasoning_config=reasoning_config,
|
||||
openrouter_min_coding_score=params.get("openrouter_min_coding_score"),
|
||||
)
|
||||
if profile_body:
|
||||
extra_body.update(profile_body)
|
||||
|
||||
@@ -105,6 +105,7 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
|
||||
if reasoning_enabled and is_xai_responses:
|
||||
kwargs["include"] = ["reasoning.encrypted_content"]
|
||||
kwargs["reasoning"] = {"effort": reasoning_effort}
|
||||
elif reasoning_enabled:
|
||||
if is_github_responses:
|
||||
github_reasoning = params.get("github_reasoning_extra")
|
||||
|
||||
@@ -62,7 +62,7 @@ class ToolCall:
|
||||
return (self.provider_data or {}).get("response_item_id")
|
||||
|
||||
@property
|
||||
def extra_content(self) -> Optional[Dict[str, Any]]:
|
||||
def extra_content(self) -> dict[str, Any] | None:
|
||||
"""Gemini extra_content (thought_signature) from provider_data.
|
||||
|
||||
Gemini 3 thinking models attach ``extra_content`` with a
|
||||
|
||||
@@ -10,17 +10,34 @@ Browser-based dashboard for managing Hermes Agent configuration, API keys, and m
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start the backend API server
|
||||
cd ../
|
||||
python -m hermes_cli.main web --no-open
|
||||
Install workspace dependencies from the repo root first:
|
||||
|
||||
# In another terminal, start the Vite dev server (with HMR + API proxy)
|
||||
cd web/
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -28,7 +45,7 @@ The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the Fast
|
||||
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
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name": "dashboard",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "web",
|
||||
"name": "dashboard",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nous-research/ui": "^0.10.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
@@ -45,6 +46,13 @@
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
},
|
||||
"../shared": {
|
||||
"name": "@hermes/shared",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -947,6 +955,10 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hermes/shared": {
|
||||
"resolved": "../shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -2371,6 +2383,64 @@
|
||||
"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": {
|
||||
"version": "4.2.4",
|
||||
"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,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"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",
|
||||
"prebuild": "npm run sync-assets",
|
||||
"dev": "vite",
|
||||
@@ -13,6 +13,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nous-research/ui": "^0.10.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@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)}`)
|
||||
}
|
||||
@@ -553,13 +553,14 @@ export interface ModelsAnalyticsResponse {
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
schedule: { kind: string; expr: string; display: string };
|
||||
schedule_display: string;
|
||||
name?: string | null;
|
||||
prompt?: string | null;
|
||||
script?: string | null;
|
||||
schedule?: { kind?: string; expr?: string; display?: string };
|
||||
schedule_display?: string | null;
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
deliver?: string;
|
||||
state?: string | null;
|
||||
deliver?: string | null;
|
||||
last_run_at?: string | null;
|
||||
next_run_at?: string | null;
|
||||
last_error?: string | null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,50 @@ function formatTime(iso?: string | null): string {
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function asText(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength
|
||||
? value.slice(0, maxLength) + "..."
|
||||
: value;
|
||||
}
|
||||
|
||||
function getJobPrompt(job: CronJob): string {
|
||||
return asText(job.prompt);
|
||||
}
|
||||
|
||||
function getJobName(job: CronJob): string {
|
||||
return asText(job.name).trim();
|
||||
}
|
||||
|
||||
function getJobTitle(job: CronJob): string {
|
||||
const name = getJobName(job);
|
||||
if (name) return name;
|
||||
|
||||
const prompt = getJobPrompt(job);
|
||||
if (prompt) return truncateText(prompt, 60);
|
||||
|
||||
const script = asText(job.script);
|
||||
if (script) return truncateText(script, 60);
|
||||
|
||||
return job.id || "Cron job";
|
||||
}
|
||||
|
||||
function getJobScheduleDisplay(job: CronJob): string {
|
||||
return (
|
||||
asText(job.schedule_display) ||
|
||||
asText(job.schedule?.display) ||
|
||||
asText(job.schedule?.expr) ||
|
||||
"—"
|
||||
);
|
||||
}
|
||||
|
||||
function getJobState(job: CronJob): string {
|
||||
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
|
||||
enabled: "success",
|
||||
scheduled: "success",
|
||||
@@ -84,17 +128,17 @@ export default function CronPage() {
|
||||
|
||||
const handlePauseResume = async (job: CronJob) => {
|
||||
try {
|
||||
const isPaused = job.state === "paused";
|
||||
const isPaused = getJobState(job) === "paused";
|
||||
if (isPaused) {
|
||||
await api.resumeCronJob(job.id);
|
||||
showToast(
|
||||
`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
await api.pauseCronJob(job.id);
|
||||
showToast(
|
||||
`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
`${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
@@ -108,7 +152,7 @@ export default function CronPage() {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
showToast(
|
||||
`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
`${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
@@ -124,7 +168,7 @@ export default function CronPage() {
|
||||
try {
|
||||
await api.deleteCronJob(id);
|
||||
showToast(
|
||||
`${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`,
|
||||
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
@@ -161,7 +205,9 @@ export default function CronPage() {
|
||||
title={t.cron.confirmDeleteTitle}
|
||||
description={
|
||||
pendingJob
|
||||
? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}`
|
||||
? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${
|
||||
t.cron.confirmDeleteMessage
|
||||
}`
|
||||
: t.cron.confirmDeleteMessage
|
||||
}
|
||||
loading={jobDelete.isDeleting}
|
||||
@@ -265,85 +311,90 @@ export default function CronPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{jobs.map((job) => (
|
||||
<Card key={job.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{job.name ||
|
||||
job.prompt.slice(0, 60) +
|
||||
(job.prompt.length > 60 ? "..." : "")}
|
||||
</span>
|
||||
<Badge tone={STATUS_TONE[job.state] ?? "secondary"}>
|
||||
{job.state}
|
||||
</Badge>
|
||||
{job.deliver && job.deliver !== "local" && (
|
||||
<Badge tone="outline">{job.deliver}</Badge>
|
||||
{jobs.map((job) => {
|
||||
const state = getJobState(job);
|
||||
const promptText = getJobPrompt(job);
|
||||
const title = getJobTitle(job);
|
||||
const hasName = Boolean(getJobName(job));
|
||||
const deliver = asText(job.deliver);
|
||||
|
||||
return (
|
||||
<Card key={job.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{title}
|
||||
</span>
|
||||
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
|
||||
{state}
|
||||
</Badge>
|
||||
{deliver && deliver !== "local" && (
|
||||
<Badge tone="outline">{deliver}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasName && promptText && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
{truncateText(promptText, 100)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
|
||||
<span>
|
||||
{t.cron.last}: {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{t.cron.next}: {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{job.last_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{job.name && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
{job.prompt.slice(0, 100)}
|
||||
{job.prompt.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule_display}</span>
|
||||
<span>
|
||||
{t.cron.last}: {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{t.cron.next}: {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
aria-label={
|
||||
state === "paused" ? t.cron.resume : t.cron.pause
|
||||
}
|
||||
onClick={() => handlePauseResume(job)}
|
||||
className={
|
||||
state === "paused" ? "text-success" : "text-warning"
|
||||
}
|
||||
>
|
||||
{state === "paused" ? <Play /> : <Pause />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={t.cron.triggerNow}
|
||||
aria-label={t.cron.triggerNow}
|
||||
onClick={() => handleTrigger(job)}
|
||||
>
|
||||
<Zap />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => jobDelete.requestDelete(job.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{job.last_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
aria-label={
|
||||
job.state === "paused" ? t.cron.resume : t.cron.pause
|
||||
}
|
||||
onClick={() => handlePauseResume(job)}
|
||||
className={
|
||||
job.state === "paused" ? "text-success" : "text-warning"
|
||||
}
|
||||
>
|
||||
{job.state === "paused" ? <Play /> : <Pause />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title={t.cron.triggerNow}
|
||||
aria-label={t.cron.triggerNow}
|
||||
onClick={() => handleTrigger(job)}
|
||||
>
|
||||
<Zap />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => jobDelete.requestDelete(job.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<PluginSlot name="cron:bottom" />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user