Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1647dadba | |||
| 422f2866e6 | |||
| 722331a57d | |||
| 41e2d61b3f | |||
| 4da598b48a | |||
| 33ae403890 | |||
| 47e6ea84bb | |||
| 4bcb2f2d26 | |||
| 1c4d3216d3 | |||
| dedc4600dd | |||
| 8bc9b5a0b4 | |||
| 2546b7acea | |||
| 7b2700c9af | |||
| a4e1842f12 | |||
| e69526be79 | |||
| 180b14442f | |||
| 03446e06bb | |||
| df7be3d8ae | |||
| da8bab77fb | |||
| 9932366f3c | |||
| 029938fbed | |||
| 772cfb6c4e | |||
| 5d5d21556e | |||
| 9855190f23 | |||
| 50c35dcabe | |||
| 93fe4ead83 | |||
| a8b7db35b2 | |||
| 8548893d14 | |||
| c5688e7c8b | |||
| ba24f058ed | |||
| ef04de3e98 | |||
| fc6cb5b970 | |||
| 4b2a1a4337 | |||
| 2871ef1807 | |||
| 5cbb45d93e | |||
| ca0ae56ccb | |||
| 23b87c8ca8 | |||
| 92385679b6 | |||
| 82f364ffd1 | |||
| 31d0620663 | |||
| cf1d718823 | |||
| 302554b158 | |||
| d6c09ab94a | |||
| da528a8207 | |||
| 677f1227c3 | |||
| 4610551d74 | |||
| 498cb7a0fc | |||
| c10fea8d26 | |||
| cda64a5961 | |||
| 2a98098035 | |||
| 6c89306437 | |||
| 847d7cbea5 | |||
| a9c78d0eb0 | |||
| e7475b1582 | |||
| ac1f8fcccd | |||
| 56c34ac4f7 | |||
| 3ca7417c2a | |||
| cfa24532d3 | |||
| b24e5ee4b0 | |||
| 3b50821555 | |||
| 10494b42a1 | |||
| 039023f497 | |||
| 6448e1da23 | |||
| 1e5e1e822b | |||
| 55ce76b372 | |||
| 1525624904 | |||
| 353b5bacbd | |||
| 139a5e37a4 | |||
| 673acf22ae | |||
| 6ed682f111 | |||
| 45595f4805 | |||
| 397386cae2 | |||
| eed891f1bb | |||
| 9bbf7659e9 | |||
| 1aa76620d4 | |||
| fa8c448f7d | |||
| 95d11dfd8e | |||
| a37a095980 | |||
| 0bd3f521ae | |||
| 3e0bccc54c | |||
| 326cbbe40e | |||
| 8b52356849 | |||
| 064f8d74de | |||
| 99bcc2de5b | |||
| b583210c97 | |||
| 8bb5973950 | |||
| 90c98345c9 | |||
| 1ace9b4dc4 | |||
| e964cfc403 | |||
| 9bdfcd1b93 | |||
| b867171291 | |||
| c95b1c5096 | |||
| a686dbdd26 | |||
| b21b3bfd68 | |||
| 4b47856f90 | |||
| 8a002d4efc | |||
| 8ea9ceb44c | |||
| 7636baf49c | |||
| 0e7dd30acc | |||
| 5f36b42b2e | |||
| 420d27098f | |||
| 449c17e9a9 | |||
| 70611879de | |||
| 206259d111 | |||
| 4ffaac542b | |||
| e88aa8a58c | |||
| 16f9d02084 | |||
| 7ad47ace51 | |||
| b4fcec6412 | |||
| 2558d28a9b | |||
| 2cfd2dafc6 | |||
| 1acf81fdf5 | |||
| 8d545da3ff | |||
| 4654f75627 | |||
| 884cd920d4 | |||
| 87bfc28e70 | |||
| eb44abd6b1 | |||
| c7e2fe655a | |||
| 6dc8f8e9c0 | |||
| bc93641c4f | |||
| 9ffc26bc8f | |||
| a2ea237db2 | |||
| 19199cd38d | |||
| 38ad158b6b | |||
| 35424f8fc1 | |||
| a91b9bb855 | |||
| d631431872 | |||
| cdd44817f2 | |||
| 110892ff69 | |||
| 3de2b98503 | |||
| e08590888a | |||
| 69d619cf89 | |||
| f0b353bade | |||
| 62fb6b2cd8 | |||
| 8fd3093f49 | |||
| eabc0a2f66 | |||
| ea74f61d98 | |||
| 943c01536f | |||
| dd86deef13 | |||
| 5719c1f391 | |||
| bc3844c907 | |||
| 5621fc449a | |||
| 0cc7f79016 | |||
| d15efc9c1b | |||
| f6626fccee | |||
| f324222b79 | |||
| 0a4cf5b3e1 | |||
| 78fa758451 | |||
| ac80bd61ad | |||
| ec9bf9e378 | |||
| 01f71007d0 | |||
| 32cea0c08d | |||
| 8d023e43ed | |||
| a66fc1365d | |||
| 448b8bfb7c | |||
| def8b959b8 | |||
| f94f53cc22 | |||
| 0ffb6f2dae | |||
| b27eaaa4db | |||
| 8680f61f8b | |||
| 063244bb16 | |||
| c763ed5801 | |||
| 204e9190c4 | |||
| 952a885fbf | |||
| d5fd74cac2 | |||
| a6f07a6c37 | |||
| a27b3c8725 |
@@ -45,6 +45,14 @@
|
||||
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # For Moonshot China keys
|
||||
# KIMI_CN_API_KEY= # Dedicated Moonshot China key
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (Arcee AI)
|
||||
# =============================================================================
|
||||
# Arcee AI provides access to Trinity models (trinity-mini, trinity-large-*)
|
||||
# Get an Arcee key at: https://chat.arcee.ai/
|
||||
# ARCEEAI_API_KEY=
|
||||
# ARCEE_BASE_URL= # Override default base URL
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (MiniMax)
|
||||
# =============================================================================
|
||||
@@ -137,6 +145,10 @@
|
||||
# Only override here if you need to force a backend without touching config.yaml:
|
||||
# TERMINAL_ENV=local
|
||||
|
||||
# Override the container runtime binary (e.g. to use Podman instead of Docker).
|
||||
# Useful on systems where Docker's storage driver is broken or unavailable.
|
||||
# HERMES_DOCKER_BINARY=/usr/local/bin/podman
|
||||
|
||||
# Container images (for singularity/docker/modal backends)
|
||||
# TERMINAL_DOCKER_IMAGE=nikolaik/python-nodejs:python3.11-nodejs20
|
||||
# TERMINAL_SINGULARITY_IMAGE=docker://nikolaik/python-nodejs:python3.11-nodejs20
|
||||
|
||||
@@ -11,6 +11,7 @@ body:
|
||||
**Before submitting**, please:
|
||||
- [ ] Search [existing issues](https://github.com/NousResearch/hermes-agent/issues) to avoid duplicates
|
||||
- [ ] Update to the latest version (`hermes update`) and confirm the bug still exists
|
||||
- [ ] Run `hermes debug share` and paste the links below (see Debug Report section)
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
@@ -82,6 +83,25 @@ body:
|
||||
- Slack
|
||||
- WhatsApp
|
||||
|
||||
- type: textarea
|
||||
id: debug-report
|
||||
attributes:
|
||||
label: Debug Report
|
||||
description: |
|
||||
Run `hermes debug share` from your terminal and paste the links it prints here.
|
||||
This uploads your system info, config, and recent logs to a paste service automatically.
|
||||
|
||||
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
|
||||
|
||||
If the upload fails, run `hermes debug share --local` and paste the output directly.
|
||||
placeholder: |
|
||||
Report https://paste.rs/abc123
|
||||
agent.log https://paste.rs/def456
|
||||
gateway.log https://paste.rs/ghi789
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
@@ -97,8 +117,6 @@ body:
|
||||
label: Python Version
|
||||
description: Output of `python --version`
|
||||
placeholder: "3.11.9"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: hermes-version
|
||||
@@ -106,14 +124,14 @@ body:
|
||||
label: Hermes Version
|
||||
description: Output of `hermes version`
|
||||
placeholder: "2.1.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs / Traceback
|
||||
description: Paste any error output, traceback, or log messages. This will be auto-formatted as code.
|
||||
label: Additional Logs / Traceback (optional)
|
||||
description: |
|
||||
The debug report above covers most logs. Use this field for any extra error output,
|
||||
tracebacks, or screenshots not captured by `hermes debug share`.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
|
||||
@@ -71,3 +71,15 @@ body:
|
||||
label: Contribution
|
||||
options:
|
||||
- label: I'd like to implement this myself and submit a PR
|
||||
|
||||
- type: textarea
|
||||
id: debug-report
|
||||
attributes:
|
||||
label: Debug Report (optional)
|
||||
description: |
|
||||
If this feature request is related to a problem you're experiencing, run `hermes debug share` and paste the links here.
|
||||
In an interactive chat session, you can use `/debug` instead.
|
||||
This helps us understand your environment and any related logs.
|
||||
placeholder: |
|
||||
Report https://paste.rs/abc123
|
||||
render: shell
|
||||
|
||||
@@ -9,7 +9,8 @@ body:
|
||||
Sorry you're having trouble! Please fill out the details below so we can help.
|
||||
|
||||
**Quick checks first:**
|
||||
- Run `hermes doctor` and include the output below
|
||||
- Run `hermes debug share` and paste the links in the Debug Report section below
|
||||
- If you're in a chat session, you can use `/debug` instead — it does the same thing
|
||||
- Try `hermes update` to get the latest version
|
||||
- Check the [README troubleshooting section](https://github.com/NousResearch/hermes-agent#troubleshooting)
|
||||
- For general questions, consider the [Nous Research Discord](https://discord.gg/NousResearch) for faster help
|
||||
@@ -74,10 +75,21 @@ body:
|
||||
placeholder: "2.1.0"
|
||||
|
||||
- type: textarea
|
||||
id: doctor-output
|
||||
id: debug-report
|
||||
attributes:
|
||||
label: Output of `hermes doctor`
|
||||
description: Run `hermes doctor` and paste the full output. This will be auto-formatted.
|
||||
label: Debug Report
|
||||
description: |
|
||||
Run `hermes debug share` from your terminal and paste the links it prints here.
|
||||
This uploads your system info, config, and recent logs to a paste service automatically.
|
||||
|
||||
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
|
||||
|
||||
If the upload fails or install didn't get that far, run `hermes debug share --local` and paste the output directly.
|
||||
If even that doesn't work, run `hermes doctor` and paste that output instead.
|
||||
placeholder: |
|
||||
Report https://paste.rs/abc123
|
||||
agent.log https://paste.rs/def456
|
||||
gateway.log https://paste.rs/ghi789
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Only run when code files change (not docs-only PRs)
|
||||
- '*.py'
|
||||
- '**/*.py'
|
||||
- '.github/workflows/contributor-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-attribution:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
# Find any new author emails in this PR's commits
|
||||
NEW_EMAILS=$(git log ${MERGE_BASE}..HEAD --format='%ae' --no-merges | sort -u)
|
||||
|
||||
if [ -z "$NEW_EMAILS" ]; then
|
||||
echo "No new commits to check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check each email against AUTHOR_MAP in release.py
|
||||
MISSING=""
|
||||
while IFS= read -r email; do
|
||||
# Skip teknium and bot emails
|
||||
case "$email" in
|
||||
*teknium*|*noreply@github.com*|*dependabot*|*github-actions*|*anthropic.com*|*cursor.com*)
|
||||
continue ;;
|
||||
esac
|
||||
|
||||
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
|
||||
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
|
||||
continue # GitHub noreply emails auto-resolve
|
||||
fi
|
||||
|
||||
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
|
||||
AUTHOR=$(git log --author="$email" --format='%an' -1)
|
||||
MISSING="${MISSING}\n ${email} (${AUTHOR})"
|
||||
fi
|
||||
done <<< "$NEW_EMAILS"
|
||||
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
|
||||
echo -e "$MISSING"
|
||||
echo ""
|
||||
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
|
||||
echo -e "$MISSING" | while read -r line; do
|
||||
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
|
||||
[ -z "$email" ] && continue
|
||||
echo " \"${email}\": \"<github-username>\","
|
||||
done
|
||||
echo ""
|
||||
echo "To find the GitHub username for an email:"
|
||||
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
|
||||
fi
|
||||
@@ -28,20 +28,20 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml httpx
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
echo "hermes-agent.nousresearch.com" > _site/CNAME
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: _site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deploy
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
|
||||
@@ -23,21 +23,21 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build amd64 only so we can `load` the image for smoke testing.
|
||||
# `load: true` cannot export a multi-arch manifest to the local daemon.
|
||||
# The multi-arch build follows on push to main / release.
|
||||
- name: Build image (amd64, smoke test)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -56,14 +56,14 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Push multi-arch image (main branch)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Push multi-arch image (release)
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
||||
@@ -7,13 +7,16 @@ on:
|
||||
- '.github/workflows/docs-site-checks.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -23,7 +26,7 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
- 'run_agent.py'
|
||||
- 'acp_adapter/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -26,7 +29,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
|
||||
- name: Check flake
|
||||
|
||||
@@ -20,14 +20,14 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install httpx pyyaml
|
||||
run: pip install httpx==0.28.1 pyyaml==6.0.2
|
||||
|
||||
- name: Build skills index
|
||||
env:
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
run: python scripts/build_skills_index.py
|
||||
|
||||
- name: Upload index artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: skills-index
|
||||
path: website/static/api/skills-index.json
|
||||
@@ -53,25 +53,25 @@ jobs:
|
||||
# Only deploy on schedule or manual trigger (not on every push to the script)
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: skills-index
|
||||
path: website/static/api/
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml
|
||||
run: pip install pyyaml==6.0.2
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
@@ -92,10 +92,10 @@ jobs:
|
||||
echo "hermes-agent.nousresearch.com" > _site/CNAME
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: _site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deploy
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -149,6 +149,62 @@ jobs:
|
||||
"
|
||||
fi
|
||||
|
||||
# --- CI/CD workflow files modified ---
|
||||
WORKFLOW_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '\.github/workflows/.*\.ya?ml$' || true)
|
||||
if [ -n "$WORKFLOW_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: CI/CD workflow files modified
|
||||
Changes to workflow files can alter build pipelines, inject steps, or modify permissions. Verify no unauthorized actions or secrets access were added.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${WORKFLOW_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Dockerfile / container build files modified ---
|
||||
DOCKER_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -iE '(Dockerfile|\.dockerignore|docker-compose)' || true)
|
||||
if [ -n "$DOCKER_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: Container build files modified
|
||||
Changes to Dockerfiles or compose files can alter base images, add build steps, or expose ports. Verify base image pins and build commands.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${DOCKER_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Dependency manifest files modified ---
|
||||
DEP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(pyproject\.toml|requirements.*\.txt|package\.json|Gemfile|go\.mod|Cargo\.toml)$' || true)
|
||||
if [ -n "$DEP_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: Dependency manifest files modified
|
||||
Changes to dependency files can introduce new packages or change version pins. Verify all dependency changes are intentional and from trusted sources.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${DEP_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- GitHub Actions version unpinning (mutable tags instead of SHAs) ---
|
||||
ACTIONS_UNPIN=$(echo "$DIFF" | grep -n '^\+' | grep 'uses:' | grep -v '#' | grep -E '@v[0-9]' | head -10 || true)
|
||||
if [ -n "$ACTIONS_UNPIN" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: GitHub Actions with mutable version tags
|
||||
Actions should be pinned to full commit SHAs (not \`@v4\`, \`@v5\`). Mutable tags can be retargeted silently if a maintainer account is compromised.
|
||||
|
||||
**Matches:**
|
||||
\`\`\`
|
||||
${ACTIONS_UNPIN}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Output results ---
|
||||
if [ -n "$FINDINGS" ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -183,7 +239,7 @@ jobs:
|
||||
---
|
||||
*Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
|
||||
- name: Fail on critical findings
|
||||
if: steps.scan.outputs.critical == 'true'
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel in-progress runs for the same PR/branch
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
@@ -17,13 +20,13 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
@@ -49,10 +52,10 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# .mailmap — canonical author mapping for git shortlog / git log / GitHub
|
||||
# Format: Canonical Name <canonical@email> <commit@email>
|
||||
# See: https://git-scm.com/docs/gitmailmap
|
||||
#
|
||||
# This maps commit emails to GitHub noreply addresses so that:
|
||||
# 1. `git shortlog -sn` shows deduplicated contributor counts
|
||||
# 2. GitHub's contributor graph can attribute commits correctly
|
||||
# 3. Contributors with personal/work emails get proper credit
|
||||
#
|
||||
# When adding entries: use the contributor's GitHub noreply email as canonical
|
||||
# so GitHub can link commits to their profile.
|
||||
|
||||
# === Teknium (multiple emails) ===
|
||||
Teknium <127238744+teknium1@users.noreply.github.com> <teknium1@gmail.com>
|
||||
Teknium <127238744+teknium1@users.noreply.github.com> <teknium@nousresearch.com>
|
||||
|
||||
# === Contributors — personal/work emails mapped to GitHub noreply ===
|
||||
# Format: Canonical Name <GH-noreply> <commit-email>
|
||||
|
||||
# Verified via GH API email search
|
||||
luyao618 <364939526@qq.com> <364939526@qq.com>
|
||||
ethernet8023 <arilotter@gmail.com> <arilotter@gmail.com>
|
||||
nicoloboschi <boschi1997@gmail.com> <boschi1997@gmail.com>
|
||||
cherifya <chef.ya@gmail.com> <chef.ya@gmail.com>
|
||||
BongSuCHOI <chlqhdtn98@gmail.com> <chlqhdtn98@gmail.com>
|
||||
dsocolobsky <dsocolobsky@gmail.com> <dsocolobsky@gmail.com>
|
||||
pefontana <fontana.pedro93@gmail.com> <fontana.pedro93@gmail.com>
|
||||
Helmi <frank@helmschrott.de> <frank@helmschrott.de>
|
||||
hata1234 <hata1234@gmail.com> <hata1234@gmail.com>
|
||||
|
||||
# Verified via PR investigation / salvage PR bodies
|
||||
DeployFaith <agents@kylefrench.dev> <agents@kylefrench.dev>
|
||||
flobo3 <floptopbot33@gmail.com> <floptopbot33@gmail.com>
|
||||
gaixianggeng <gaixg94@gmail.com> <gaixg94@gmail.com>
|
||||
KUSH42 <xush@xush.org> <xush@xush.org>
|
||||
konsisumer <der@konsi.org> <der@konsi.org>
|
||||
WorldInnovationsDepartment <vorvul.danylo@gmail.com> <vorvul.danylo@gmail.com>
|
||||
m0n5t3r <iacobs@m0n5t3r.info> <iacobs@m0n5t3r.info>
|
||||
sprmn24 <oncuevtv@gmail.com> <oncuevtv@gmail.com>
|
||||
fancydirty <fancydirty@gmail.com> <fancydirty@gmail.com>
|
||||
fxfitz <francis.x.fitzpatrick@gmail.com> <francis.x.fitzpatrick@gmail.com>
|
||||
limars874 <limars874@gmail.com> <limars874@gmail.com>
|
||||
AaronWong1999 <aaronwong1999@icloud.com> <aaronwong1999@icloud.com>
|
||||
dippwho <dipp.who@gmail.com> <dipp.who@gmail.com>
|
||||
duerzy <duerzy@gmail.com> <duerzy@gmail.com>
|
||||
geoffwellman <geoff.wellman@gmail.com> <geoff.wellman@gmail.com>
|
||||
hcshen0111 <shenhaocheng19990111@gmail.com> <shenhaocheng19990111@gmail.com>
|
||||
jamesarch <han.shan@live.cn> <han.shan@live.cn>
|
||||
stephenschoettler <stephenschoettler@gmail.com> <stephenschoettler@gmail.com>
|
||||
Tranquil-Flow <tranquil_flow@protonmail.com> <tranquil_flow@protonmail.com>
|
||||
Dusk1e <yusufalweshdemir@gmail.com> <yusufalweshdemir@gmail.com>
|
||||
Awsh1 <ysfalweshcan@gmail.com> <ysfalweshcan@gmail.com>
|
||||
WAXLYY <ysfwaxlycan@gmail.com> <ysfwaxlycan@gmail.com>
|
||||
donrhmexe <don.rhm@gmail.com> <don.rhm@gmail.com>
|
||||
hqhq1025 <1506751656@qq.com> <1506751656@qq.com>
|
||||
BlackishGreen33 <s5460703@gmail.com> <s5460703@gmail.com>
|
||||
tomqiaozc <zqiao@microsoft.com> <zqiao@microsoft.com>
|
||||
MagicRay1217 <mingjwan@microsoft.com> <mingjwan@microsoft.com>
|
||||
aaronagent <1115117931@qq.com> <1115117931@qq.com>
|
||||
YoungYang963 <young@YoungdeMacBook-Pro.local> <young@YoungdeMacBook-Pro.local>
|
||||
LongOddCode <haolong@microsoft.com> <haolong@microsoft.com>
|
||||
Cafexss <coffeemjj@gmail.com> <coffeemjj@gmail.com>
|
||||
Cygra <sjtuwbh@gmail.com> <sjtuwbh@gmail.com>
|
||||
DomGrieco <dgrieco@redhat.com> <dgrieco@redhat.com>
|
||||
|
||||
# Duplicate email mapping (same person, multiple emails)
|
||||
Sertug17 <104278804+Sertug17@users.noreply.github.com> <srhtsrht17@gmail.com>
|
||||
yyovil <birdiegyal@gmail.com> <tanishq231003@gmail.com>
|
||||
DomGrieco <dgrieco@redhat.com> <dgrieco@redhat.com>
|
||||
dsocolobsky <dsocolobsky@gmail.com> <dylan.socolobsky@lambdaclass.com>
|
||||
olafthiele <programming@olafthiele.com> <olafthiele@gmail.com>
|
||||
|
||||
# Verified via git display name matching GH contributor username
|
||||
cokemine <aptx4561@gmail.com> <aptx4561@gmail.com>
|
||||
dalianmao000 <dalianmao0107@gmail.com> <dalianmao0107@gmail.com>
|
||||
emozilla <emozilla@nousresearch.com> <emozilla@nousresearch.com>
|
||||
jjovalle99 <juan.ovalle@mistral.ai> <juan.ovalle@mistral.ai>
|
||||
kagura-agent <kagura.chen28@gmail.com> <kagura.chen28@gmail.com>
|
||||
spniyant <niyant@spicefi.xyz> <niyant@spicefi.xyz>
|
||||
olafthiele <programming@olafthiele.com> <programming@olafthiele.com>
|
||||
r266-tech <r2668940489@gmail.com> <r2668940489@gmail.com>
|
||||
xingkongliang <tianliangjay@gmail.com> <tianliangjay@gmail.com>
|
||||
win4r <win4r@outlook.com> <win4r@outlook.com>
|
||||
zhouboli <zhouboli@gmail.com> <zhouboli@gmail.com>
|
||||
yongtenglei <yongtenglei@gmail.com> <yongtenglei@gmail.com>
|
||||
|
||||
# Nous Research team
|
||||
benbarclay <ben@nousresearch.com> <ben@nousresearch.com>
|
||||
jquesnelle <jonny@nousresearch.com> <jonny@nousresearch.com>
|
||||
|
||||
# GH contributor list verified
|
||||
spideystreet <dhicham.pro@gmail.com> <dhicham.pro@gmail.com>
|
||||
dorukardahan <dorukardahan@hotmail.com> <dorukardahan@hotmail.com>
|
||||
MustafaKara7 <karamusti912@gmail.com> <karamusti912@gmail.com>
|
||||
Hmbown <hmbown@gmail.com> <hmbown@gmail.com>
|
||||
kamil-gwozdz <kamil@gwozdz.me> <kamil@gwozdz.me>
|
||||
kira-ariaki <kira@ariaki.me> <kira@ariaki.me>
|
||||
knopki <knopki@duck.com> <knopki@duck.com>
|
||||
Unayung <unayung@gmail.com> <unayung@gmail.com>
|
||||
SeeYangZhi <yangzhi.see@gmail.com> <yangzhi.see@gmail.com>
|
||||
Julientalbot <julien.talbot@ergonomia.re> <julien.talbot@ergonomia.re>
|
||||
lesterli <lisicheng168@gmail.com> <lisicheng168@gmail.com>
|
||||
JiayuuWang <jiayuw794@gmail.com> <jiayuw794@gmail.com>
|
||||
tesseracttars-creator <tesseracttars@gmail.com> <tesseracttars@gmail.com>
|
||||
xinbenlv <zzn+pa@zzn.im> <zzn+pa@zzn.im>
|
||||
SaulJWu <saul.jj.wu@gmail.com> <saul.jj.wu@gmail.com>
|
||||
angelos <angelos@oikos.lan.home.malaiwah.com> <angelos@oikos.lan.home.malaiwah.com>
|
||||
@@ -13,7 +13,7 @@ source venv/bin/activate # ALWAYS activate before running Python
|
||||
```
|
||||
hermes-agent/
|
||||
├── run_agent.py # AIAgent class — core conversation loop
|
||||
├── model_tools.py # Tool orchestration, _discover_tools(), handle_function_call()
|
||||
├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
|
||||
├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
|
||||
├── cli.py # HermesCLI class — interactive CLI orchestrator
|
||||
├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
|
||||
@@ -55,7 +55,7 @@ hermes-agent/
|
||||
├── gateway/ # Messaging platform gateway
|
||||
│ ├── run.py # Main loop, slash commands, message dispatch
|
||||
│ ├── session.py # SessionStore — conversation persistence
|
||||
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal
|
||||
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal, qqbot
|
||||
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
|
||||
├── cron/ # Scheduler (jobs.py, scheduler.py)
|
||||
├── environments/ # RL training environments (Atropos)
|
||||
@@ -181,7 +181,7 @@ if canonical == "mycommand":
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
Requires changes in **3 files**:
|
||||
Requires changes in **2 files**:
|
||||
|
||||
**1. Create `tools/your_tool.py`:**
|
||||
```python
|
||||
@@ -204,9 +204,9 @@ registry.register(
|
||||
)
|
||||
```
|
||||
|
||||
**2. Add import** in `model_tools.py` `_discover_tools()` list.
|
||||
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
|
||||
|
||||
**3. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
|
||||
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
|
||||
|
||||
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# Install system dependencies in one layer, clear APT cache
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
|
||||
|
||||
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
|
||||
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
|
||||
|
||||
<table>
|
||||
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
- **@JiayuuWang** — CLI uninstall import fix
|
||||
- **@HiddenPuppy** — Docker procps installation
|
||||
- **@dsocolobsky** — Test suite fixes
|
||||
- **@bobashopcashier** (1 PR) — Graceful gateway drain before restart (salvaged into #7503 from #7290)
|
||||
- **@benbarclay** — Docker image tag simplification
|
||||
- **@sosyz** — Shallow git clone for faster install
|
||||
- **@devorun** — Nix setupSecrets optional
|
||||
|
||||
@@ -1230,9 +1230,10 @@ def build_anthropic_kwargs(
|
||||
When *base_url* points to a third-party Anthropic-compatible endpoint,
|
||||
thinking block signatures are stripped (they are Anthropic-proprietary).
|
||||
|
||||
When *fast_mode* is True, adds ``speed: "fast"`` and the fast-mode beta
|
||||
header for ~2.5x faster output throughput on Opus 4.6. Currently only
|
||||
supported on native Anthropic endpoints (not third-party compatible ones).
|
||||
When *fast_mode* is True, adds ``extra_body["speed"] = "fast"`` and the
|
||||
fast-mode beta header for ~2.5x faster output throughput on Opus 4.6.
|
||||
Currently only supported on native Anthropic endpoints (not third-party
|
||||
compatible ones).
|
||||
"""
|
||||
system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url)
|
||||
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
|
||||
@@ -1333,11 +1334,11 @@ def build_anthropic_kwargs(
|
||||
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
|
||||
|
||||
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
|
||||
# Adds speed:"fast" + the fast-mode beta header for ~2.5x output speed.
|
||||
# Only for native Anthropic endpoints — third-party providers would
|
||||
# reject the unknown beta header and speed parameter.
|
||||
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
|
||||
# output speed. Only for native Anthropic endpoints — third-party
|
||||
# providers would reject the unknown beta header and speed parameter.
|
||||
if fast_mode and not _is_third_party_anthropic_endpoint(base_url):
|
||||
kwargs["speed"] = "fast"
|
||||
kwargs.setdefault("extra_body", {})["speed"] = "fast"
|
||||
# Build extra_headers with ALL applicable betas (the per-request
|
||||
# extra_headers override the client-level anthropic-beta header).
|
||||
betas = list(_common_betas_for_base_url(base_url))
|
||||
|
||||
@@ -112,6 +112,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
# "exotic provider" branch checks this before falling back to the main model.
|
||||
_PROVIDER_VISION_MODELS: Dict[str, str] = {
|
||||
"xiaomi": "mimo-v2-omni",
|
||||
"zai": "glm-5v-turbo",
|
||||
}
|
||||
|
||||
# OpenRouter app attribution headers
|
||||
@@ -1223,6 +1224,12 @@ def _to_async_client(sync_client, model: str):
|
||||
return AsyncCodexAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
return AsyncAnthropicAuxiliaryClient(sync_client), model
|
||||
try:
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
if isinstance(sync_client, CopilotACPClient):
|
||||
return sync_client, model
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
async_kwargs = {
|
||||
"api_key": sync_client.api_key,
|
||||
@@ -1467,7 +1474,11 @@ def resolve_provider_client(
|
||||
|
||||
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_api_key_provider_credentials,
|
||||
resolve_external_process_provider_credentials,
|
||||
)
|
||||
except ImportError:
|
||||
logger.debug("hermes_cli.auth not available for provider %s", provider)
|
||||
return None, None
|
||||
@@ -1541,6 +1552,41 @@ def resolve_provider_client(
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
if pconfig.auth_type == "external_process":
|
||||
creds = resolve_external_process_provider_credentials(provider)
|
||||
final_model = _normalize_resolved_model(model or _read_main_model(), provider)
|
||||
if provider == "copilot-acp":
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
base_url = str(creds.get("base_url", "")).strip()
|
||||
command = str(creds.get("command", "")).strip() or None
|
||||
args = list(creds.get("args") or [])
|
||||
if not final_model:
|
||||
logger.warning(
|
||||
"resolve_provider_client: copilot-acp requested but no model "
|
||||
"was provided or configured"
|
||||
)
|
||||
return None, None
|
||||
if not api_key or not base_url:
|
||||
logger.warning(
|
||||
"resolve_provider_client: copilot-acp requested but external "
|
||||
"process credentials are incomplete"
|
||||
)
|
||||
return None, None
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
|
||||
client = CopilotACPClient(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
command=command,
|
||||
args=args,
|
||||
)
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
logger.warning("resolve_provider_client: external-process provider %s not "
|
||||
"directly supported", provider)
|
||||
return None, None
|
||||
|
||||
elif pconfig.auth_type in ("oauth_device_code", "oauth_external"):
|
||||
# OAuth providers — route through their specific try functions
|
||||
if provider == "nous":
|
||||
|
||||
+307
-36
@@ -17,7 +17,10 @@ Improvements over v2:
|
||||
- Richer tool call/result detail in summarizer input
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -57,6 +60,128 @@ _CHARS_PER_TOKEN = 4
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
|
||||
|
||||
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
|
||||
"""Create an informative 1-line summary of a tool call + result.
|
||||
|
||||
Used during the pre-compression pruning pass to replace large tool
|
||||
outputs with a short but useful description of what the tool did,
|
||||
rather than a generic placeholder that carries zero information.
|
||||
|
||||
Returns strings like::
|
||||
|
||||
[terminal] ran `npm test` -> exit 0, 47 lines output
|
||||
[read_file] read config.py from line 1 (1,200 chars)
|
||||
[search_files] content search for 'compress' in agent/ -> 12 matches
|
||||
"""
|
||||
try:
|
||||
args = json.loads(tool_args) if tool_args else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
|
||||
content = tool_content or ""
|
||||
content_len = len(content)
|
||||
line_count = content.count("\n") + 1 if content.strip() else 0
|
||||
|
||||
if tool_name == "terminal":
|
||||
cmd = args.get("command", "")
|
||||
if len(cmd) > 80:
|
||||
cmd = cmd[:77] + "..."
|
||||
exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content)
|
||||
exit_code = exit_match.group(1) if exit_match else "?"
|
||||
return f"[terminal] ran `{cmd}` -> exit {exit_code}, {line_count} lines output"
|
||||
|
||||
if tool_name == "read_file":
|
||||
path = args.get("path", "?")
|
||||
offset = args.get("offset", 1)
|
||||
return f"[read_file] read {path} from line {offset} ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "write_file":
|
||||
path = args.get("path", "?")
|
||||
written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?"
|
||||
return f"[write_file] wrote to {path} ({written_lines} lines)"
|
||||
|
||||
if tool_name == "search_files":
|
||||
pattern = args.get("pattern", "?")
|
||||
path = args.get("path", ".")
|
||||
target = args.get("target", "content")
|
||||
match_count = re.search(r'"total_count"\s*:\s*(\d+)', content)
|
||||
count = match_count.group(1) if match_count else "?"
|
||||
return f"[search_files] {target} search for '{pattern}' in {path} -> {count} matches"
|
||||
|
||||
if tool_name == "patch":
|
||||
path = args.get("path", "?")
|
||||
mode = args.get("mode", "replace")
|
||||
return f"[patch] {mode} in {path} ({content_len:,} chars result)"
|
||||
|
||||
if tool_name in ("browser_navigate", "browser_click", "browser_snapshot",
|
||||
"browser_type", "browser_scroll", "browser_vision"):
|
||||
url = args.get("url", "")
|
||||
ref = args.get("ref", "")
|
||||
detail = f" {url}" if url else (f" ref={ref}" if ref else "")
|
||||
return f"[{tool_name}]{detail} ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "web_search":
|
||||
query = args.get("query", "?")
|
||||
return f"[web_search] query='{query}' ({content_len:,} chars result)"
|
||||
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
|
||||
if isinstance(urls, list) and len(urls) > 1:
|
||||
url_desc += f" (+{len(urls) - 1} more)"
|
||||
return f"[web_extract] {url_desc} ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "delegate_task":
|
||||
goal = args.get("goal", "")
|
||||
if len(goal) > 60:
|
||||
goal = goal[:57] + "..."
|
||||
return f"[delegate_task] '{goal}' ({content_len:,} chars result)"
|
||||
|
||||
if tool_name == "execute_code":
|
||||
code_preview = (args.get("code") or "")[:60].replace("\n", " ")
|
||||
if len(args.get("code", "")) > 60:
|
||||
code_preview += "..."
|
||||
return f"[execute_code] `{code_preview}` ({line_count} lines output)"
|
||||
|
||||
if tool_name in ("skill_view", "skills_list", "skill_manage"):
|
||||
name = args.get("name", "?")
|
||||
return f"[{tool_name}] name={name} ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "vision_analyze":
|
||||
question = args.get("question", "")[:50]
|
||||
return f"[vision_analyze] '{question}' ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "memory":
|
||||
action = args.get("action", "?")
|
||||
target = args.get("target", "?")
|
||||
return f"[memory] {action} on {target}"
|
||||
|
||||
if tool_name == "todo":
|
||||
return "[todo] updated task list"
|
||||
|
||||
if tool_name == "clarify":
|
||||
return "[clarify] asked user a question"
|
||||
|
||||
if tool_name == "text_to_speech":
|
||||
return f"[text_to_speech] generated audio ({content_len:,} chars)"
|
||||
|
||||
if tool_name == "cronjob":
|
||||
action = args.get("action", "?")
|
||||
return f"[cronjob] {action}"
|
||||
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "?")
|
||||
sid = args.get("session_id", "?")
|
||||
return f"[process] {action} session={sid}"
|
||||
|
||||
# Generic fallback
|
||||
first_arg = ""
|
||||
for k, v in list(args.items())[:2]:
|
||||
sv = str(v)[:40]
|
||||
first_arg += f" {k}={sv}"
|
||||
return f"[{tool_name}]{first_arg} ({content_len:,} chars result)"
|
||||
|
||||
|
||||
class ContextCompressor(ContextEngine):
|
||||
"""Default context engine — compresses conversation context via lossy summarization.
|
||||
|
||||
@@ -78,6 +203,8 @@ class ContextCompressor(ContextEngine):
|
||||
self._context_probed = False
|
||||
self._context_probe_persistable = False
|
||||
self._previous_summary = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
|
||||
def update_model(
|
||||
self,
|
||||
@@ -167,6 +294,9 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
# Stores the previous compaction summary for iterative updates
|
||||
self._previous_summary: Optional[str] = None
|
||||
# Anti-thrashing: track whether last compression was effective
|
||||
self._last_compression_savings_pct: float = 100.0
|
||||
self._ineffective_compression_count: int = 0
|
||||
self._summary_failure_cooldown_until: float = 0.0
|
||||
|
||||
def update_from_response(self, usage: Dict[str, Any]):
|
||||
@@ -175,9 +305,26 @@ class ContextCompressor(ContextEngine):
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
"""Check if context exceeds the compression threshold."""
|
||||
"""Check if context exceeds the compression threshold.
|
||||
|
||||
Includes anti-thrashing protection: if the last two compressions
|
||||
each saved less than 10%, skip compression to avoid infinite loops
|
||||
where each pass removes only 1-2 messages.
|
||||
"""
|
||||
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
|
||||
return tokens >= self.threshold_tokens
|
||||
if tokens < self.threshold_tokens:
|
||||
return False
|
||||
# Anti-thrashing: back off if recent compressions were ineffective
|
||||
if self._ineffective_compression_count >= 2:
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Compression skipped — last %d compressions saved <10%% each. "
|
||||
"Consider /new to start a fresh session, or /compress <topic> "
|
||||
"for focused compression.",
|
||||
self._ineffective_compression_count,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool output pruning (cheap pre-pass, no LLM call)
|
||||
@@ -187,7 +334,16 @@ class ContextCompressor(ContextEngine):
|
||||
self, messages: List[Dict[str, Any]], protect_tail_count: int,
|
||||
protect_tail_tokens: int | None = None,
|
||||
) -> tuple[List[Dict[str, Any]], int]:
|
||||
"""Replace old tool result contents with a short placeholder.
|
||||
"""Replace old tool result contents with informative 1-line summaries.
|
||||
|
||||
Instead of a generic placeholder, generates a summary like::
|
||||
|
||||
[terminal] ran `npm test` -> exit 0, 47 lines output
|
||||
[read_file] read config.py from line 1 (3,400 chars)
|
||||
|
||||
Also deduplicates identical tool results (e.g. reading the same file
|
||||
5x keeps only the newest full copy) and truncates large tool_call
|
||||
arguments in assistant messages outside the protected tail.
|
||||
|
||||
Walks backward from the end, protecting the most recent messages that
|
||||
fall within ``protect_tail_tokens`` (when provided) OR the last
|
||||
@@ -203,6 +359,22 @@ class ContextCompressor(ContextEngine):
|
||||
result = [m.copy() for m in messages]
|
||||
pruned = 0
|
||||
|
||||
# Build index: tool_call_id -> (tool_name, arguments_json)
|
||||
call_id_to_tool: Dict[str, tuple] = {}
|
||||
for msg in result:
|
||||
if msg.get("role") == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict):
|
||||
cid = tc.get("id", "")
|
||||
fn = tc.get("function", {})
|
||||
call_id_to_tool[cid] = (fn.get("name", "unknown"), fn.get("arguments", ""))
|
||||
else:
|
||||
cid = getattr(tc, "id", "") or ""
|
||||
fn = getattr(tc, "function", None)
|
||||
name = getattr(fn, "name", "unknown") if fn else "unknown"
|
||||
args_str = getattr(fn, "arguments", "") if fn else ""
|
||||
call_id_to_tool[cid] = (name, args_str)
|
||||
|
||||
# Determine the prune boundary
|
||||
if protect_tail_tokens is not None and protect_tail_tokens > 0:
|
||||
# Token-budget approach: walk backward accumulating tokens
|
||||
@@ -211,7 +383,8 @@ class ContextCompressor(ContextEngine):
|
||||
min_protect = min(protect_tail_count, len(result) - 1)
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
msg = result[i]
|
||||
content_len = len(msg.get("content") or "")
|
||||
raw_content = msg.get("content") or ""
|
||||
content_len = sum(len(p.get("text", "")) for p in raw_content) if isinstance(raw_content, list) else len(raw_content)
|
||||
msg_tokens = content_len // _CHARS_PER_TOKEN + 10
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict):
|
||||
@@ -226,18 +399,69 @@ class ContextCompressor(ContextEngine):
|
||||
else:
|
||||
prune_boundary = len(result) - protect_tail_count
|
||||
|
||||
# Pass 1: Deduplicate identical tool results.
|
||||
# When the same file is read multiple times, keep only the most recent
|
||||
# full copy and replace older duplicates with a back-reference.
|
||||
content_hashes: dict = {} # hash -> (index, tool_call_id)
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
msg = result[i]
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
content = msg.get("content") or ""
|
||||
# Skip multimodal content (list of content blocks)
|
||||
if isinstance(content, list):
|
||||
continue
|
||||
if len(content) < 200:
|
||||
continue
|
||||
h = hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()[:12]
|
||||
if h in content_hashes:
|
||||
# This is an older duplicate — replace with back-reference
|
||||
result[i] = {**msg, "content": "[Duplicate tool output — same content as a more recent call]"}
|
||||
pruned += 1
|
||||
else:
|
||||
content_hashes[h] = (i, msg.get("tool_call_id", "?"))
|
||||
|
||||
# Pass 2: Replace old tool results with informative summaries
|
||||
for i in range(prune_boundary):
|
||||
msg = result[i]
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
# Skip multimodal content (list of content blocks)
|
||||
if isinstance(content, list):
|
||||
continue
|
||||
if not content or content == _PRUNED_TOOL_PLACEHOLDER:
|
||||
continue
|
||||
# Skip already-deduplicated or previously-summarized results
|
||||
if content.startswith("[Duplicate tool output"):
|
||||
continue
|
||||
# Only prune if the content is substantial (>200 chars)
|
||||
if len(content) > 200:
|
||||
result[i] = {**msg, "content": _PRUNED_TOOL_PLACEHOLDER}
|
||||
call_id = msg.get("tool_call_id", "")
|
||||
tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", ""))
|
||||
summary = _summarize_tool_result(tool_name, tool_args, content)
|
||||
result[i] = {**msg, "content": summary}
|
||||
pruned += 1
|
||||
|
||||
# Pass 3: Truncate large tool_call arguments in assistant messages
|
||||
# outside the protected tail. write_file with 50KB content, for
|
||||
# example, survives pruning entirely without this.
|
||||
for i in range(prune_boundary):
|
||||
msg = result[i]
|
||||
if msg.get("role") != "assistant" or not msg.get("tool_calls"):
|
||||
continue
|
||||
new_tcs = []
|
||||
modified = False
|
||||
for tc in msg["tool_calls"]:
|
||||
if isinstance(tc, dict):
|
||||
args = tc.get("function", {}).get("arguments", "")
|
||||
if len(args) > 500:
|
||||
tc = {**tc, "function": {**tc["function"], "arguments": args[:200] + "...[truncated]"}}
|
||||
modified = True
|
||||
new_tcs.append(tc)
|
||||
if modified:
|
||||
result[i] = {**msg, "tool_calls": new_tcs}
|
||||
|
||||
return result, pruned
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -357,29 +581,37 @@ class ContextCompressor(ContextEngine):
|
||||
)
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
# Key changes vs v1:
|
||||
# - "Pending User Asks" section (from Claude Code) explicitly tracks
|
||||
# unanswered questions so the model knows what's resolved vs open
|
||||
# - "Remaining Work" replaces "Next Steps" to avoid reading as active
|
||||
# instructions
|
||||
# - "Resolved Questions" makes it clear which questions were already
|
||||
# answered (prevents model from re-answering them)
|
||||
_template_sections = f"""## Goal
|
||||
[What the user is trying to accomplish]
|
||||
|
||||
## Constraints & Preferences
|
||||
[User preferences, coding style, constraints, important decisions]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
[Completed work — include specific file paths, commands run, results obtained]
|
||||
### In Progress
|
||||
[Work currently underway]
|
||||
### Blocked
|
||||
[Any blockers or issues encountered]
|
||||
## Completed Actions
|
||||
[Numbered list of concrete actions taken — include tool used, target, and outcome.
|
||||
Format each as: N. ACTION target — outcome [tool: name]
|
||||
Example:
|
||||
1. READ config.py:45 — found `==` should be `!=` [tool: read_file]
|
||||
2. PATCH config.py:45 — changed `==` to `!=` [tool: patch]
|
||||
3. TEST `pytest tests/` — 3/50 failed: test_parse, test_validate, test_edge [tool: terminal]
|
||||
Be specific with file paths, commands, line numbers, and results.]
|
||||
|
||||
## Active State
|
||||
[Current working state — include:
|
||||
- Working directory and branch (if applicable)
|
||||
- Modified/created files with brief note on each
|
||||
- Test status (X/Y passing)
|
||||
- Any running processes or servers
|
||||
- Environment details that matter]
|
||||
|
||||
## In Progress
|
||||
[Work currently underway — what was being done when compaction fired]
|
||||
|
||||
## Blocked
|
||||
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
|
||||
|
||||
## Key Decisions
|
||||
[Important technical decisions and why they were made]
|
||||
[Important technical decisions and WHY they were made]
|
||||
|
||||
## Resolved Questions
|
||||
[Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them]
|
||||
@@ -396,10 +628,7 @@ class ContextCompressor(ContextEngine):
|
||||
## Critical Context
|
||||
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
|
||||
|
||||
## Tools & Patterns
|
||||
[Which tools were used, how they were used effectively, and any tool-specific discoveries]
|
||||
|
||||
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions.
|
||||
Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed.
|
||||
|
||||
Write only the summary body. Do not include any preamble or prefix."""
|
||||
|
||||
@@ -415,7 +644,7 @@ PREVIOUS SUMMARY:
|
||||
NEW TURNS TO INCORPORATE:
|
||||
{content_to_summarize}
|
||||
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Move answered questions to "Resolved Questions". Remove information only if it is clearly obsolete.
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete.
|
||||
|
||||
{_template_sections}"""
|
||||
else:
|
||||
@@ -450,7 +679,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
"api_mode": self.api_mode,
|
||||
},
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": summary_budget * 2,
|
||||
"max_tokens": int(summary_budget * 1.3),
|
||||
# timeout resolved from auxiliary.compression.timeout config by call_llm
|
||||
}
|
||||
if self.summary_model:
|
||||
@@ -464,8 +693,10 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
# Store for iterative updates on next compaction
|
||||
self._previous_summary = summary
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._summary_model_fallen_back = False
|
||||
return self._with_summary_prefix(summary)
|
||||
except RuntimeError:
|
||||
# No provider configured — long cooldown, unlikely to self-resolve
|
||||
self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
|
||||
logging.warning("Context compression: no provider available for "
|
||||
"summary. Middle turns will be dropped without summary "
|
||||
@@ -473,12 +704,42 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS)
|
||||
return None
|
||||
except Exception as e:
|
||||
self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
|
||||
# If the summary model is different from the main model and the
|
||||
# error looks permanent (model not found, 503, 404), fall back to
|
||||
# using the main model instead of entering cooldown that leaves
|
||||
# context growing unbounded. (#8620 sub-issue 4)
|
||||
_status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None)
|
||||
_err_str = str(e).lower()
|
||||
_is_model_not_found = (
|
||||
_status in (404, 503)
|
||||
or "model_not_found" in _err_str
|
||||
or "does not exist" in _err_str
|
||||
or "no available channel" in _err_str
|
||||
)
|
||||
if (
|
||||
_is_model_not_found
|
||||
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' not available (%s). "
|
||||
"Falling back to main model '%s' for compression.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown
|
||||
return self._generate_summary(messages, summary_budget) # retry immediately
|
||||
|
||||
# Transient errors (timeout, rate limit, network) — shorter cooldown
|
||||
_transient_cooldown = 60
|
||||
self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown
|
||||
logging.warning(
|
||||
"Failed to generate context summary: %s. "
|
||||
"Further summary attempts paused for %d seconds.",
|
||||
e,
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS,
|
||||
_transient_cooldown,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -744,11 +1005,11 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
compressed = []
|
||||
for i in range(compress_start):
|
||||
msg = messages[i].copy()
|
||||
if i == 0 and msg.get("role") == "system" and self.compression_count == 0:
|
||||
msg["content"] = (
|
||||
(msg.get("content") or "")
|
||||
+ "\n\n[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
|
||||
)
|
||||
if i == 0 and msg.get("role") == "system":
|
||||
existing = msg.get("content") or ""
|
||||
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
|
||||
if _compression_note not in existing:
|
||||
msg["content"] = existing + "\n\n" + _compression_note
|
||||
compressed.append(msg)
|
||||
|
||||
# If LLM summary failed, insert a static fallback so the model
|
||||
@@ -806,14 +1067,24 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
|
||||
compressed = self._sanitize_tool_pairs(compressed)
|
||||
|
||||
new_estimate = estimate_messages_tokens_rough(compressed)
|
||||
saved_estimate = display_tokens - new_estimate
|
||||
|
||||
# Anti-thrashing: track compression effectiveness
|
||||
savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0
|
||||
self._last_compression_savings_pct = savings_pct
|
||||
if savings_pct < 10:
|
||||
self._ineffective_compression_count += 1
|
||||
else:
|
||||
self._ineffective_compression_count = 0
|
||||
|
||||
if not self.quiet_mode:
|
||||
new_estimate = estimate_messages_tokens_rough(compressed)
|
||||
saved_estimate = display_tokens - new_estimate
|
||||
logger.info(
|
||||
"Compressed: %d -> %d messages (~%d tokens saved)",
|
||||
"Compressed: %d -> %d messages (~%d tokens saved, %.0f%%)",
|
||||
n_messages,
|
||||
len(compressed),
|
||||
saved_estimate,
|
||||
savings_pct,
|
||||
)
|
||||
logger.info("Compression #%d complete", self.compression_count)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Lifecycle:
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class ContextEngine(ABC):
|
||||
|
||||
@@ -18,7 +18,6 @@ import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
KIMI_CODE_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
_auth_store_lock,
|
||||
_codex_access_token_is_expiring,
|
||||
@@ -1153,6 +1152,59 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
},
|
||||
)
|
||||
|
||||
elif provider == "copilot":
|
||||
# Copilot tokens are resolved dynamically via `gh auth token` or
|
||||
# env vars (COPILOT_GITHUB_TOKEN / GH_TOKEN). They don't live in
|
||||
# the auth store or credential pool, so we resolve them here.
|
||||
try:
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
token, source = resolve_copilot_token()
|
||||
if token:
|
||||
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
|
||||
active_sources.add(source_name)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
source_name,
|
||||
{
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_API_KEY,
|
||||
"access_token": token,
|
||||
"label": source,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Copilot token seed failed: %s", exc)
|
||||
|
||||
elif provider == "qwen-oauth":
|
||||
# Qwen OAuth tokens live in ~/.qwen/oauth_creds.json, written by
|
||||
# the Qwen CLI (`qwen auth qwen-oauth`). They aren't in the
|
||||
# Hermes auth store or env vars, so resolve them here.
|
||||
# Use refresh_if_expiring=False to avoid network calls during
|
||||
# pool loading / provider discovery.
|
||||
try:
|
||||
from hermes_cli.auth import resolve_qwen_runtime_credentials
|
||||
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
token = creds.get("api_key", "")
|
||||
if token:
|
||||
source_name = creds.get("source", "qwen-cli")
|
||||
active_sources.add(source_name)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
source_name,
|
||||
{
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"access_token": token,
|
||||
"expires_at_ms": creds.get("expires_at_ms"),
|
||||
"base_url": creds.get("base_url", ""),
|
||||
"label": creds.get("auth_file", source_name),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Qwen OAuth token seed failed: %s", exc)
|
||||
|
||||
elif provider == "openai-codex":
|
||||
state = _load_provider_state(auth_store, "openai-codex")
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
|
||||
@@ -77,12 +77,6 @@ def _diff_ansi() -> dict[str, str]:
|
||||
return _diff_colors_cached
|
||||
|
||||
|
||||
def reset_diff_colors() -> None:
|
||||
"""Reset cached diff colors (call after /skin switch)."""
|
||||
global _diff_colors_cached
|
||||
_diff_colors_cached = None
|
||||
|
||||
|
||||
# Module-level helpers — each call resolves from the active skin lazily.
|
||||
def _diff_dim(): return _diff_ansi()["dim"]
|
||||
def _diff_file(): return _diff_ansi()["file"]
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -157,6 +156,18 @@ _CONTEXT_OVERFLOW_PATTERNS = [
|
||||
"prompt exceeds max length",
|
||||
"max_tokens",
|
||||
"maximum number of tokens",
|
||||
# vLLM / local inference server patterns
|
||||
"exceeds the max_model_len",
|
||||
"max_model_len",
|
||||
"prompt length", # "engine prompt length X exceeds"
|
||||
"input is too long",
|
||||
"maximum model length",
|
||||
# Ollama patterns
|
||||
"context length exceeded",
|
||||
"truncating input",
|
||||
# llama.cpp / llama-server patterns
|
||||
"slot context", # "slot context: N tokens, prompt N tokens"
|
||||
"n_ctx_slot",
|
||||
# Chinese error messages (some providers return these)
|
||||
"超过最大长度",
|
||||
"上下文长度",
|
||||
|
||||
@@ -27,7 +27,6 @@ from agent.usage_pricing import (
|
||||
DEFAULT_PRICING,
|
||||
estimate_usage_cost,
|
||||
format_duration_compact,
|
||||
get_pricing,
|
||||
has_known_pricing,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ Usage in run_agent.py:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
+14
-3
@@ -5,7 +5,6 @@ and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -28,6 +27,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"custom", "local",
|
||||
# Common aliases
|
||||
"google", "google-gemini", "google-ai-studio",
|
||||
@@ -35,6 +35,8 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
|
||||
"opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"mimo", "xiaomi-mimo",
|
||||
"arcee-ai", "arceeai",
|
||||
"xai", "x-ai", "x.ai", "grok",
|
||||
"qwen-portal",
|
||||
})
|
||||
|
||||
@@ -105,9 +107,15 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"claude-sonnet-4.6": 1000000,
|
||||
# Catch-all for older Claude models (must sort after specific entries)
|
||||
"claude": 200000,
|
||||
# OpenAI
|
||||
# OpenAI — GPT-5 family (most have 400k; specific overrides first)
|
||||
# Source: https://developers.openai.com/api/docs/models
|
||||
"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": 128000, # Spark variant has reduced 128k context
|
||||
"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,
|
||||
"gpt-5": 128000,
|
||||
"gpt-4": 128000,
|
||||
# Google
|
||||
"gemini": 1048576,
|
||||
@@ -149,6 +157,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"kimi": 262144,
|
||||
# Arcee
|
||||
"trinity": 262144,
|
||||
# OpenRouter
|
||||
"elephant": 262144,
|
||||
# Hugging Face Inference Providers — model IDs use org/name format
|
||||
"Qwen/Qwen3.5-397B-A17B": 131072,
|
||||
"Qwen/Qwen3.5-35B-A3B": 131072,
|
||||
@@ -213,6 +223,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"api.moonshot.ai": "kimi-coding",
|
||||
"api.moonshot.cn": "kimi-coding-cn",
|
||||
"api.kimi.com": "kimi-coding",
|
||||
"api.arcee.ai": "arcee",
|
||||
"api.minimax": "minimax",
|
||||
"dashscope.aliyuncs.com": "alibaba",
|
||||
"dashscope-intl.aliyuncs.com": "alibaba",
|
||||
|
||||
@@ -18,10 +18,8 @@ Other modules should import the dataclasses and query functions from here
|
||||
rather than parsing the raw JSON themselves.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -177,13 +175,6 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
def _get_reverse_mapping() -> Dict[str, str]:
|
||||
"""Return models.dev ID → Hermes provider ID mapping."""
|
||||
global _MODELS_DEV_TO_PROVIDER
|
||||
if _MODELS_DEV_TO_PROVIDER is None:
|
||||
_MODELS_DEV_TO_PROVIDER = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()}
|
||||
return _MODELS_DEV_TO_PROVIDER
|
||||
|
||||
|
||||
def _get_cache_path() -> Path:
|
||||
"""Return path to disk cache file."""
|
||||
@@ -464,93 +455,6 @@ def list_agentic_models(provider: str) -> List[str]:
|
||||
return result
|
||||
|
||||
|
||||
def search_models_dev(
|
||||
query: str, provider: str = None, limit: int = 5
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fuzzy search across models.dev catalog. Returns matching model entries.
|
||||
|
||||
Args:
|
||||
query: Search string to match against model IDs.
|
||||
provider: Optional Hermes provider ID to restrict search scope.
|
||||
If None, searches across all providers in PROVIDER_TO_MODELS_DEV.
|
||||
limit: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
List of dicts, each containing 'provider', 'model_id', and the full
|
||||
model 'entry' from models.dev.
|
||||
"""
|
||||
data = fetch_models_dev()
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Build list of (provider_id, model_id, entry) candidates
|
||||
candidates: List[tuple] = []
|
||||
|
||||
if provider is not None:
|
||||
# Search only the specified provider
|
||||
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
|
||||
if not mdev_provider_id:
|
||||
return []
|
||||
provider_data = data.get(mdev_provider_id, {})
|
||||
if isinstance(provider_data, dict):
|
||||
models = provider_data.get("models", {})
|
||||
if isinstance(models, dict):
|
||||
for mid, mdata in models.items():
|
||||
candidates.append((provider, mid, mdata))
|
||||
else:
|
||||
# Search across all mapped providers
|
||||
for hermes_prov, mdev_prov in PROVIDER_TO_MODELS_DEV.items():
|
||||
provider_data = data.get(mdev_prov, {})
|
||||
if isinstance(provider_data, dict):
|
||||
models = provider_data.get("models", {})
|
||||
if isinstance(models, dict):
|
||||
for mid, mdata in models.items():
|
||||
candidates.append((hermes_prov, mid, mdata))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Use difflib for fuzzy matching — case-insensitive comparison
|
||||
model_ids_lower = [c[1].lower() for c in candidates]
|
||||
query_lower = query.lower()
|
||||
|
||||
# First try exact substring matches (more intuitive than pure edit-distance)
|
||||
substring_matches = []
|
||||
for prov, mid, mdata in candidates:
|
||||
if query_lower in mid.lower():
|
||||
substring_matches.append({"provider": prov, "model_id": mid, "entry": mdata})
|
||||
|
||||
# Then add difflib fuzzy matches for any remaining slots
|
||||
fuzzy_ids = difflib.get_close_matches(
|
||||
query_lower, model_ids_lower, n=limit * 2, cutoff=0.4
|
||||
)
|
||||
|
||||
seen_ids: set = set()
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
# Prioritize substring matches
|
||||
for match in substring_matches:
|
||||
key = (match["provider"], match["model_id"])
|
||||
if key not in seen_ids:
|
||||
seen_ids.add(key)
|
||||
results.append(match)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
# Add fuzzy matches
|
||||
for fid in fuzzy_ids:
|
||||
# Find original-case candidates matching this lowered ID
|
||||
for prov, mid, mdata in candidates:
|
||||
if mid.lower() == fid:
|
||||
key = (prov, mid)
|
||||
if key not in seen_ids:
|
||||
seen_ids.add(key)
|
||||
results.append({"provider": prov, "model_id": mid, "entry": mdata})
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rich dataclass constructors — parse raw models.dev JSON into dataclasses
|
||||
|
||||
@@ -376,6 +376,12 @@ PLATFORM_HINTS = {
|
||||
"downloaded and sent as native photos. Do NOT tell the user you lack file-sending "
|
||||
"capability — use MEDIA: syntax whenever a file delivery is appropriate."
|
||||
),
|
||||
"qqbot": (
|
||||
"You are on QQ, a popular Chinese messaging platform. QQ supports markdown formatting "
|
||||
"and emoji. You can send media files natively: include MEDIA:/absolute/path/to/file in "
|
||||
"your response. Images are sent as native photos, and other files arrive as downloadable "
|
||||
"documents."
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,7 +24,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -12,6 +12,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_skill_commands: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -108,7 +110,7 @@ def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None
|
||||
if not resolved:
|
||||
return
|
||||
|
||||
lines = ["", "[Skill config (from ~/.hermes/config.yaml):"]
|
||||
lines = ["", f"[Skill config (from {display_hermes_home()}/config.yaml):"]
|
||||
for key, value in resolved.items():
|
||||
display_val = str(value) if value else "(not set)"
|
||||
lines.append(f" {key} = {display_val}")
|
||||
|
||||
+23
-1
@@ -10,7 +10,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import get_config_path, get_skills_dir
|
||||
|
||||
@@ -441,3 +441,25 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
matches.append(Path(root) / filename)
|
||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||
yield path
|
||||
|
||||
|
||||
# ── Namespace helpers for plugin-provided skills ───────────────────────────
|
||||
|
||||
_NAMESPACE_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||||
|
||||
|
||||
def parse_qualified_name(name: str) -> Tuple[Optional[str], str]:
|
||||
"""Split ``'namespace:skill-name'`` into ``(namespace, bare_name)``.
|
||||
|
||||
Returns ``(None, name)`` when there is no ``':'``.
|
||||
"""
|
||||
if ":" not in name:
|
||||
return None, name
|
||||
return tuple(name.split(":", 1)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_valid_namespace(candidate: Optional[str]) -> bool:
|
||||
"""Check whether *candidate* is a valid namespace (``[a-zA-Z0-9_-]+``)."""
|
||||
if not candidate:
|
||||
return False
|
||||
return bool(_NAMESPACE_RE.match(candidate))
|
||||
|
||||
@@ -575,25 +575,6 @@ def has_known_pricing(
|
||||
return entry is not None
|
||||
|
||||
|
||||
def get_pricing(
|
||||
model_name: str,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""Backward-compatible thin wrapper for legacy callers.
|
||||
|
||||
Returns only non-cache input/output fields when a pricing entry exists.
|
||||
Unknown routes return zeroes.
|
||||
"""
|
||||
entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
|
||||
if not entry:
|
||||
return {"input": 0.0, "output": 0.0}
|
||||
return {
|
||||
"input": float(entry.input_cost_per_million or _ZERO),
|
||||
"output": float(entry.output_cost_per_million or _ZERO),
|
||||
}
|
||||
|
||||
|
||||
def format_duration_compact(seconds: float) -> str:
|
||||
if seconds < 60:
|
||||
|
||||
@@ -25,6 +25,7 @@ model:
|
||||
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
|
||||
# "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
|
||||
# "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY)
|
||||
# "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY)
|
||||
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
|
||||
# "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
|
||||
#
|
||||
@@ -522,7 +523,7 @@ agent:
|
||||
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
|
||||
# - A list of individual toolsets to compose your own (see list below)
|
||||
#
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
@@ -551,6 +552,7 @@ agent:
|
||||
# slack: hermes-slack (same as telegram)
|
||||
# signal: hermes-signal (same as telegram)
|
||||
# homeassistant: hermes-homeassistant (same as telegram)
|
||||
# qqbot: hermes-qqbot (same as telegram)
|
||||
#
|
||||
platform_toolsets:
|
||||
cli: [hermes-cli]
|
||||
@@ -560,6 +562,7 @@ platform_toolsets:
|
||||
slack: [hermes-slack]
|
||||
signal: [hermes-signal]
|
||||
homeassistant: [hermes-homeassistant]
|
||||
qqbot: [hermes-qqbot]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Available toolsets (use these names in platform_toolsets or the toolsets list)
|
||||
|
||||
@@ -988,19 +988,20 @@ def _prune_orphaned_branches(repo_root: str) -> None:
|
||||
# ANSI building blocks for conversation display
|
||||
_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_RST = "\033[0m"
|
||||
_STREAM_PAD = " " # 4-space indent for streamed response text (matches Panel padding)
|
||||
|
||||
|
||||
def _hex_to_ansi_bold(hex_color: str) -> str:
|
||||
"""Convert a hex color like '#268bd2' to a bold true-color ANSI escape."""
|
||||
def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
|
||||
"""Convert a hex color like '#268bd2' to a true-color ANSI escape."""
|
||||
try:
|
||||
r = int(hex_color[1:3], 16)
|
||||
g = int(hex_color[3:5], 16)
|
||||
b = int(hex_color[5:7], 16)
|
||||
return f"\033[1;38;2;{r};{g};{b}m"
|
||||
prefix = "1;" if bold else ""
|
||||
return f"\033[{prefix}38;2;{r};{g};{b}m"
|
||||
except (ValueError, IndexError):
|
||||
return _ACCENT_ANSI_DEFAULT
|
||||
return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m"
|
||||
|
||||
|
||||
class _SkinAwareAnsi:
|
||||
@@ -1010,20 +1011,22 @@ class _SkinAwareAnsi:
|
||||
force re-resolution after a ``/skin`` switch.
|
||||
"""
|
||||
|
||||
def __init__(self, skin_key: str, fallback_hex: str = "#FFD700"):
|
||||
def __init__(self, skin_key: str, fallback_hex: str = "#FFD700", *, bold: bool = False):
|
||||
self._skin_key = skin_key
|
||||
self._fallback_hex = fallback_hex
|
||||
self._bold = bold
|
||||
self._cached: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self._cached is None:
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
self._cached = _hex_to_ansi_bold(
|
||||
get_active_skin().get_color(self._skin_key, self._fallback_hex)
|
||||
self._cached = _hex_to_ansi(
|
||||
get_active_skin().get_color(self._skin_key, self._fallback_hex),
|
||||
bold=self._bold,
|
||||
)
|
||||
except Exception:
|
||||
self._cached = _hex_to_ansi_bold(self._fallback_hex)
|
||||
self._cached = _hex_to_ansi(self._fallback_hex, bold=self._bold)
|
||||
return self._cached
|
||||
|
||||
def __add__(self, other: str) -> str:
|
||||
@@ -1037,7 +1040,8 @@ class _SkinAwareAnsi:
|
||||
self._cached = None
|
||||
|
||||
|
||||
_ACCENT = _SkinAwareAnsi("response_border", "#FFD700")
|
||||
_ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
|
||||
_DIM = _SkinAwareAnsi("banner_dim", "#B8860B")
|
||||
|
||||
|
||||
def _accent_hex() -> str:
|
||||
@@ -1709,9 +1713,9 @@ class HermesCLI:
|
||||
# Parse and validate toolsets
|
||||
self.enabled_toolsets = toolsets
|
||||
if toolsets and "all" not in toolsets and "*" not in toolsets:
|
||||
# Validate each toolset — MCP server names are added by
|
||||
# _get_platform_tools() but aren't registered in TOOLSETS yet
|
||||
# (that happens later in _sync_mcp_toolsets), so exclude them.
|
||||
# Validate each toolset — MCP server names are resolved via
|
||||
# live registry aliases (registered during discover_mcp_tools),
|
||||
# but discovery hasn't run yet at this point, so exclude them.
|
||||
mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys())
|
||||
invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names]
|
||||
if invalid:
|
||||
@@ -2577,7 +2581,7 @@ class HermesCLI:
|
||||
_tc = getattr(self, "_stream_text_ansi", "")
|
||||
while "\n" in self._stream_buf:
|
||||
line, self._stream_buf = self._stream_buf.split("\n", 1)
|
||||
_cprint(f"{_tc}{line}{_RST}" if _tc else line)
|
||||
_cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}")
|
||||
|
||||
def _flush_stream(self) -> None:
|
||||
"""Emit any remaining partial line from the stream buffer and close the box."""
|
||||
@@ -2594,7 +2598,7 @@ class HermesCLI:
|
||||
|
||||
if self._stream_buf:
|
||||
_tc = getattr(self, "_stream_text_ansi", "")
|
||||
_cprint(f"{_tc}{self._stream_buf}{_RST}" if _tc else self._stream_buf)
|
||||
_cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}")
|
||||
self._stream_buf = ""
|
||||
|
||||
# Close the response box
|
||||
@@ -4474,53 +4478,6 @@ class HermesCLI:
|
||||
_ask()
|
||||
return result[0]
|
||||
|
||||
def _interactive_provider_selection(
|
||||
self, providers: list, current_model: str, current_provider: str
|
||||
) -> str | None:
|
||||
"""Show provider picker, return slug or None on cancel."""
|
||||
choices = []
|
||||
for p in providers:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
|
||||
if p.get("is_current"):
|
||||
label += " ← current"
|
||||
choices.append(label)
|
||||
|
||||
default_idx = next(
|
||||
(i for i, p in enumerate(providers) if p.get("is_current")), 0
|
||||
)
|
||||
|
||||
idx = self._run_curses_picker(
|
||||
f"Select a provider (current: {current_model} on {current_provider}):",
|
||||
choices,
|
||||
default_index=default_idx,
|
||||
)
|
||||
if idx is None:
|
||||
return None
|
||||
return providers[idx]["slug"]
|
||||
|
||||
def _interactive_model_selection(
|
||||
self, model_list: list, provider_data: dict
|
||||
) -> str | None:
|
||||
"""Show model picker for a given provider, return model_id or None on cancel."""
|
||||
pname = provider_data.get("name", provider_data.get("slug", ""))
|
||||
total = provider_data.get("total_models", len(model_list))
|
||||
|
||||
if not model_list:
|
||||
_cprint(f"\n No models listed for {pname}.")
|
||||
return self._prompt_text_input(" Enter model name manually (or Enter to cancel): ")
|
||||
|
||||
choices = list(model_list) + ["Enter custom model name"]
|
||||
idx = self._run_curses_picker(
|
||||
f"Select model from {pname} ({len(model_list)} of {total}):",
|
||||
choices,
|
||||
)
|
||||
if idx is None:
|
||||
return None
|
||||
if idx < len(model_list):
|
||||
return model_list[idx]
|
||||
return self._prompt_text_input(" Enter model name: ")
|
||||
|
||||
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
|
||||
"""Open prompt_toolkit-native /model picker modal."""
|
||||
self._capture_modal_input_snapshot()
|
||||
@@ -4631,16 +4588,19 @@ class HermesCLI:
|
||||
self._close_model_picker()
|
||||
return
|
||||
provider_data = providers[selected]
|
||||
model_list = []
|
||||
try:
|
||||
from hermes_cli.models import provider_model_ids
|
||||
live = provider_model_ids(provider_data["slug"])
|
||||
if live:
|
||||
model_list = live
|
||||
except Exception:
|
||||
pass
|
||||
# Use the curated model list from list_authenticated_providers()
|
||||
# (same lists as `hermes model` and gateway pickers).
|
||||
# Only fall back to the live provider catalog when the curated
|
||||
# list is empty (e.g. user-defined endpoints with no curated list).
|
||||
model_list = provider_data.get("models", [])
|
||||
if not model_list:
|
||||
model_list = provider_data.get("models", [])
|
||||
try:
|
||||
from hermes_cli.models import provider_model_ids
|
||||
live = provider_model_ids(provider_data["slug"])
|
||||
if live:
|
||||
model_list = live
|
||||
except Exception:
|
||||
pass
|
||||
state["stage"] = "model"
|
||||
state["provider_data"] = provider_data
|
||||
state["model_list"] = model_list
|
||||
@@ -5805,7 +5765,7 @@ class HermesCLI:
|
||||
border_style=_resp_color,
|
||||
style=_resp_text,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 2),
|
||||
padding=(1, 4),
|
||||
))
|
||||
else:
|
||||
_cprint(" (No response generated)")
|
||||
@@ -5929,7 +5889,7 @@ class HermesCLI:
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 2),
|
||||
padding=(1, 4),
|
||||
))
|
||||
else:
|
||||
_cprint(" 💬 /btw: (no response)")
|
||||
@@ -5996,7 +5956,7 @@ class HermesCLI:
|
||||
parts = cmd.strip().split(None, 1)
|
||||
sub = parts[1].lower().strip() if len(parts) > 1 else "status"
|
||||
|
||||
_DEFAULT_CDP = "http://localhost:9222"
|
||||
_DEFAULT_CDP = "http://127.0.0.1:9222"
|
||||
current = os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||
|
||||
if sub.startswith("connect"):
|
||||
@@ -6203,6 +6163,7 @@ class HermesCLI:
|
||||
|
||||
set_active_skin(new_skin)
|
||||
_ACCENT.reset() # Re-resolve ANSI color for the new skin
|
||||
_DIM.reset() # Re-resolve dim/secondary ANSI color for the new skin
|
||||
if save_config_value("display.skin", new_skin):
|
||||
print(f" Skin set to: {new_skin} (saved)")
|
||||
else:
|
||||
@@ -7691,7 +7652,7 @@ class HermesCLI:
|
||||
label = " ⚕ Hermes "
|
||||
fill = w - 2 - len(label)
|
||||
_cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}")
|
||||
_cprint(sentence.rstrip())
|
||||
_cprint(f"{_STREAM_PAD}{sentence.rstrip()}")
|
||||
|
||||
tts_thread = threading.Thread(
|
||||
target=stream_tts_to_speaker,
|
||||
@@ -7922,7 +7883,7 @@ class HermesCLI:
|
||||
border_style=_resp_color,
|
||||
style=_resp_text,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 2),
|
||||
padding=(1, 4),
|
||||
))
|
||||
|
||||
|
||||
@@ -8674,6 +8635,24 @@ class HermesCLI:
|
||||
self._should_exit = True
|
||||
event.app.exit()
|
||||
|
||||
_modal_prompt_active = Condition(
|
||||
lambda: bool(self._secret_state or self._sudo_state)
|
||||
)
|
||||
|
||||
@kb.add('escape', filter=_modal_prompt_active, eager=True)
|
||||
def handle_escape_modal(event):
|
||||
"""ESC cancels active secret/sudo prompts."""
|
||||
if self._secret_state:
|
||||
self._cancel_secret_capture()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
if self._sudo_state:
|
||||
self._sudo_state["response_queue"].put("")
|
||||
self._sudo_state = None
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
@kb.add('c-z')
|
||||
def handle_ctrl_z(event):
|
||||
"""Handle Ctrl+Z - suspend process to background (Unix only)."""
|
||||
@@ -8971,9 +8950,9 @@ class HermesCLI:
|
||||
if cli_ref._voice_processing:
|
||||
return "transcribing..."
|
||||
if cli_ref._sudo_state:
|
||||
return "type password (hidden), Enter to skip"
|
||||
return "type password (hidden), Enter to submit · ESC to skip"
|
||||
if cli_ref._secret_state:
|
||||
return "type secret (hidden), Enter to skip"
|
||||
return "type secret (hidden), Enter to submit · ESC to skip"
|
||||
if cli_ref._approval_state:
|
||||
return ""
|
||||
if cli_ref._clarify_freetext:
|
||||
@@ -9216,7 +9195,7 @@ class HermesCLI:
|
||||
prompt = state.get("prompt") or f"Enter value for {state.get('var_name', 'secret')}"
|
||||
metadata = state.get("metadata") or {}
|
||||
help_text = metadata.get("help")
|
||||
body = 'Enter secret below (hidden), or press Enter to skip'
|
||||
body = 'Enter secret below (hidden), ESC or Ctrl+C to skip'
|
||||
content_lines = [prompt, body]
|
||||
if help_text:
|
||||
content_lines.insert(1, str(help_text))
|
||||
|
||||
+5
-1
@@ -45,6 +45,7 @@ _KNOWN_DELIVERY_PLATFORMS = frozenset({
|
||||
"telegram", "discord", "slack", "whatsapp", "signal",
|
||||
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu",
|
||||
"wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles",
|
||||
"qqbot",
|
||||
})
|
||||
|
||||
from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
|
||||
@@ -254,6 +255,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
"bluebubbles": Platform.BLUEBUBBLES,
|
||||
"qqbot": Platform.QQBOT,
|
||||
}
|
||||
platform = platform_map.get(platform_name.lower())
|
||||
if not platform:
|
||||
@@ -286,11 +288,13 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
|
||||
if wrap_response:
|
||||
task_name = job.get("name", job["id"])
|
||||
job_id = job.get("id", "")
|
||||
delivery_content = (
|
||||
f"Cronjob Response: {task_name}\n"
|
||||
f"(job_id: {job_id})\n"
|
||||
f"-------------\n\n"
|
||||
f"{content}\n\n"
|
||||
f"Note: The agent cannot see this message, and therefore cannot respond to it."
|
||||
f"To stop or manage this job, send me a new message (e.g. \"stop reminder {task_name}\")."
|
||||
)
|
||||
else:
|
||||
delivery_content = content
|
||||
|
||||
Regular → Executable
+13
-6
@@ -1,13 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Docker entrypoint: bootstrap config files into the mounted volume, then run hermes.
|
||||
# Docker/Podman entrypoint: bootstrap config files into the mounted volume, then run hermes.
|
||||
set -e
|
||||
|
||||
HERMES_HOME="/opt/data"
|
||||
HERMES_HOME="${HERMES_HOME:-/opt/data}"
|
||||
INSTALL_DIR="/opt/hermes"
|
||||
|
||||
# --- Privilege dropping via gosu ---
|
||||
# When started as root (the default), optionally remap the hermes user/group
|
||||
# to match host-side ownership, fix volume permissions, then re-exec as hermes.
|
||||
# When started as root (the default for Docker, or fakeroot in rootless Podman),
|
||||
# optionally remap the hermes user/group to match host-side ownership, fix volume
|
||||
# permissions, then re-exec as hermes.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
|
||||
echo "Changing hermes UID to $HERMES_UID"
|
||||
@@ -16,13 +17,19 @@ if [ "$(id -u)" = "0" ]; then
|
||||
|
||||
if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
|
||||
echo "Changing hermes GID to $HERMES_GID"
|
||||
groupmod -g "$HERMES_GID" hermes
|
||||
# -o allows non-unique GID (e.g. macOS GID 20 "staff" may already exist
|
||||
# as "dialout" in the Debian-based container image)
|
||||
groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true
|
||||
fi
|
||||
|
||||
actual_hermes_uid=$(id -u hermes)
|
||||
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
|
||||
echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing"
|
||||
chown -R hermes:hermes "$HERMES_HOME"
|
||||
# In rootless Podman the container's "root" is mapped to an unprivileged
|
||||
# host UID — chown will fail. That's fine: the volume is already owned
|
||||
# by the mapped user on the host side.
|
||||
chown -R hermes:hermes "$HERMES_HOME" 2>/dev/null || \
|
||||
echo "Warning: chown failed (rootless container?) — continuing anyway"
|
||||
fi
|
||||
|
||||
echo "Dropping root privileges"
|
||||
|
||||
@@ -41,6 +41,14 @@ colors:
|
||||
session_label: "#DAA520" # Session label
|
||||
session_border: "#8B8682" # Session ID dim color
|
||||
|
||||
# TUI surfaces
|
||||
status_bar_bg: "#1a1a2e" # Status / usage bar background
|
||||
voice_status_bg: "#1a1a2e" # Voice-mode badge background
|
||||
completion_menu_bg: "#1a1a2e" # Completion list background
|
||||
completion_menu_current_bg: "#333355" # Active completion row background
|
||||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
|
||||
completion_menu_meta_current_bg: "#333355" # Active completion meta background
|
||||
|
||||
# ── Spinner ─────────────────────────────────────────────────────────────────
|
||||
# Customize the animated spinner shown during API calls and tool execution.
|
||||
spinner:
|
||||
|
||||
@@ -18,9 +18,7 @@ suppress delivery.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("hooks.boot-md")
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ class Platform(Enum):
|
||||
WECOM_CALLBACK = "wecom_callback"
|
||||
WEIXIN = "weixin"
|
||||
BLUEBUBBLES = "bluebubbles"
|
||||
QQBOT = "qqbot"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -303,6 +304,9 @@ class GatewayConfig:
|
||||
# BlueBubbles uses extra dict for local server config
|
||||
elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"):
|
||||
connected.append(platform)
|
||||
# QQBot uses extra dict for app credentials
|
||||
elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
|
||||
connected.append(platform)
|
||||
return connected
|
||||
|
||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||
@@ -621,6 +625,11 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
ignored_threads = telegram_cfg.get("ignored_threads")
|
||||
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
|
||||
if isinstance(ignored_threads, list):
|
||||
ignored_threads = ",".join(str(v) for v in ignored_threads)
|
||||
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
|
||||
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
|
||||
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
|
||||
|
||||
@@ -1109,6 +1118,32 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
name=os.getenv("BLUEBUBBLES_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# QQ (Official Bot API v2)
|
||||
qq_app_id = os.getenv("QQ_APP_ID")
|
||||
qq_client_secret = os.getenv("QQ_CLIENT_SECRET")
|
||||
if qq_app_id or qq_client_secret:
|
||||
if Platform.QQBOT not in config.platforms:
|
||||
config.platforms[Platform.QQBOT] = PlatformConfig()
|
||||
config.platforms[Platform.QQBOT].enabled = True
|
||||
extra = config.platforms[Platform.QQBOT].extra
|
||||
if qq_app_id:
|
||||
extra["app_id"] = qq_app_id
|
||||
if qq_client_secret:
|
||||
extra["client_secret"] = qq_client_secret
|
||||
qq_allowed_users = os.getenv("QQ_ALLOWED_USERS", "").strip()
|
||||
if qq_allowed_users:
|
||||
extra["allow_from"] = qq_allowed_users
|
||||
qq_group_allowed = os.getenv("QQ_GROUP_ALLOWED_USERS", "").strip()
|
||||
if qq_group_allowed:
|
||||
extra["group_allow_from"] = qq_group_allowed
|
||||
qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip()
|
||||
if qq_home:
|
||||
config.platforms[Platform.QQBOT].home_channel = HomeChannel(
|
||||
platform=Platform.QQBOT,
|
||||
chat_id=qq_home,
|
||||
name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Session settings
|
||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||
if idle_minutes:
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
+11
-23
@@ -9,6 +9,10 @@ Resolution order (first non-None wins):
|
||||
3. ``_PLATFORM_DEFAULTS[<platform>][<key>]`` — built-in sensible default
|
||||
4. ``_GLOBAL_DEFAULTS[<key>]`` — built-in global default
|
||||
|
||||
Exception: ``display.streaming`` is CLI-only. Gateway streaming follows the
|
||||
top-level ``streaming`` config unless ``display.platforms.<platform>.streaming``
|
||||
sets an explicit per-platform override.
|
||||
|
||||
Backward compatibility: ``display.tool_progress_overrides`` is still read as a
|
||||
fallback for ``tool_progress`` when no ``display.platforms`` entry exists. A
|
||||
config migration (version bump) automatically moves the old format into the new
|
||||
@@ -143,10 +147,13 @@ def resolve_display_setting(
|
||||
if val is not None:
|
||||
return _normalise(setting, val)
|
||||
|
||||
# 2. Global user setting (display.<key>)
|
||||
val = display_cfg.get(setting)
|
||||
if val is not None:
|
||||
return _normalise(setting, val)
|
||||
# 2. Global user setting (display.<key>). Skip display.streaming because
|
||||
# that key controls only CLI terminal streaming; gateway token streaming is
|
||||
# governed by the top-level streaming config plus per-platform overrides.
|
||||
if setting != "streaming":
|
||||
val = display_cfg.get(setting)
|
||||
if val is not None:
|
||||
return _normalise(setting, val)
|
||||
|
||||
# 3. Built-in platform default
|
||||
plat_defaults = _PLATFORM_DEFAULTS.get(platform_key)
|
||||
@@ -163,25 +170,6 @@ def resolve_display_setting(
|
||||
return fallback
|
||||
|
||||
|
||||
def get_platform_defaults(platform_key: str) -> dict[str, Any]:
|
||||
"""Return the built-in default display settings for a platform.
|
||||
|
||||
Falls back to ``_GLOBAL_DEFAULTS`` for unknown platforms.
|
||||
"""
|
||||
return dict(_PLATFORM_DEFAULTS.get(platform_key, _GLOBAL_DEFAULTS))
|
||||
|
||||
|
||||
def get_effective_display(user_config: dict, platform_key: str) -> dict[str, Any]:
|
||||
"""Return the fully-resolved display settings for a platform.
|
||||
|
||||
Useful for status commands that want to show all effective settings.
|
||||
"""
|
||||
return {
|
||||
key: resolve_display_setting(user_config, platform_key, key)
|
||||
for key in OVERRIDEABLE_KEYS
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,9 +9,11 @@ Each adapter handles:
|
||||
"""
|
||||
|
||||
from .base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from .qqbot import QQAdapter
|
||||
|
||||
__all__ = [
|
||||
"BasePlatformAdapter",
|
||||
"MessageEvent",
|
||||
"SendResult",
|
||||
"QQAdapter",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ Exposes an HTTP server with endpoints:
|
||||
- POST /v1/runs — start a run, returns run_id immediately (202)
|
||||
- GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events
|
||||
- GET /health — health check
|
||||
- GET /health/detailed — rich status for cross-container dashboard probing
|
||||
|
||||
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
|
||||
AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent
|
||||
@@ -514,6 +515,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
session_id: Optional[str] = None,
|
||||
stream_delta_callback=None,
|
||||
tool_progress_callback=None,
|
||||
tool_start_callback=None,
|
||||
tool_complete_callback=None,
|
||||
) -> Any:
|
||||
"""
|
||||
Create an AIAgent instance using the gateway's runtime config.
|
||||
@@ -552,6 +555,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
platform="api_server",
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
tool_start_callback=tool_start_callback,
|
||||
tool_complete_callback=tool_complete_callback,
|
||||
session_db=self._ensure_session_db(),
|
||||
fallback_model=fallback_model,
|
||||
)
|
||||
@@ -565,6 +570,27 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"""GET /health — simple health check."""
|
||||
return web.json_response({"status": "ok", "platform": "hermes-agent"})
|
||||
|
||||
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /health/detailed — rich status for cross-container dashboard probing.
|
||||
|
||||
Returns gateway state, connected platforms, PID, and uptime so the
|
||||
dashboard can display full status without needing a shared PID file or
|
||||
/proc access. No authentication required.
|
||||
"""
|
||||
from gateway.status import read_runtime_status
|
||||
|
||||
runtime = read_runtime_status() or {}
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"platform": "hermes-agent",
|
||||
"gateway_state": runtime.get("gateway_state"),
|
||||
"platforms": runtime.get("platforms", {}),
|
||||
"active_agents": runtime.get("active_agents", 0),
|
||||
"exit_reason": runtime.get("exit_reason"),
|
||||
"updated_at": runtime.get("updated_at"),
|
||||
"pid": os.getpid(),
|
||||
})
|
||||
|
||||
async def _handle_models(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /v1/models — return hermes-agent as an available model."""
|
||||
auth_err = self._check_auth(request)
|
||||
@@ -943,6 +969,427 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
return response
|
||||
|
||||
async def _write_sse_responses(
|
||||
self,
|
||||
request: "web.Request",
|
||||
response_id: str,
|
||||
model: str,
|
||||
created_at: int,
|
||||
stream_q,
|
||||
agent_task,
|
||||
agent_ref,
|
||||
conversation_history: List[Dict[str, str]],
|
||||
user_message: str,
|
||||
instructions: Optional[str],
|
||||
conversation: Optional[str],
|
||||
store: bool,
|
||||
session_id: str,
|
||||
) -> "web.StreamResponse":
|
||||
"""Write an SSE stream for POST /v1/responses (OpenAI Responses API).
|
||||
|
||||
Emits spec-compliant event types as the agent runs:
|
||||
|
||||
- ``response.created`` — initial envelope (status=in_progress)
|
||||
- ``response.output_text.delta`` / ``response.output_text.done`` —
|
||||
streamed assistant text
|
||||
- ``response.output_item.added`` / ``response.output_item.done``
|
||||
with ``item.type == "function_call"`` — when the agent invokes a
|
||||
tool (both events fire; the ``done`` event carries the finalized
|
||||
``arguments`` string)
|
||||
- ``response.output_item.added`` with
|
||||
``item.type == "function_call_output"`` — tool result with
|
||||
``{call_id, output, status}``
|
||||
- ``response.completed`` — terminal event carrying the full
|
||||
response object with all output items + usage (same payload
|
||||
shape as the non-streaming path for parity)
|
||||
- ``response.failed`` — terminal event on agent error
|
||||
|
||||
If the client disconnects mid-stream, ``agent.interrupt()`` is
|
||||
called so the agent stops issuing upstream LLM calls, then the
|
||||
asyncio task is cancelled. When ``store=True`` the full response
|
||||
is persisted to the ResponseStore in a ``finally`` block so GET
|
||||
/v1/responses/{id} and ``previous_response_id`` chaining work the
|
||||
same as the batch path.
|
||||
"""
|
||||
import queue as _q
|
||||
|
||||
sse_headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
origin = request.headers.get("Origin", "")
|
||||
cors = self._cors_headers_for_origin(origin) if origin else None
|
||||
if cors:
|
||||
sse_headers.update(cors)
|
||||
if session_id:
|
||||
sse_headers["X-Hermes-Session-Id"] = session_id
|
||||
response = web.StreamResponse(status=200, headers=sse_headers)
|
||||
await response.prepare(request)
|
||||
|
||||
# State accumulated during the stream
|
||||
final_text_parts: List[str] = []
|
||||
# Track open function_call items by name so we can emit a matching
|
||||
# ``done`` event when the tool completes. Order preserved.
|
||||
pending_tool_calls: List[Dict[str, Any]] = []
|
||||
# Output items we've emitted so far (used to build the terminal
|
||||
# response.completed payload). Kept in the order they appeared.
|
||||
emitted_items: List[Dict[str, Any]] = []
|
||||
# Monotonic counter for output_index (spec requires it).
|
||||
output_index = 0
|
||||
# Monotonic counter for call_id generation if the agent doesn't
|
||||
# provide one (it doesn't, from tool_progress_callback).
|
||||
call_counter = 0
|
||||
# Canonical Responses SSE events include a monotonically increasing
|
||||
# sequence_number. Add it server-side for every emitted event so
|
||||
# clients that validate the OpenAI event schema can parse our stream.
|
||||
sequence_number = 0
|
||||
# Track the assistant message item id + content index for text
|
||||
# delta events — the spec ties deltas to a specific item.
|
||||
message_item_id = f"msg_{uuid.uuid4().hex[:24]}"
|
||||
message_output_index: Optional[int] = None
|
||||
message_opened = False
|
||||
|
||||
async def _write_event(event_type: str, data: Dict[str, Any]) -> None:
|
||||
nonlocal sequence_number
|
||||
if "sequence_number" not in data:
|
||||
data["sequence_number"] = sequence_number
|
||||
sequence_number += 1
|
||||
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
||||
await response.write(payload.encode())
|
||||
|
||||
def _envelope(status: str) -> Dict[str, Any]:
|
||||
env: Dict[str, Any] = {
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"status": status,
|
||||
"created_at": created_at,
|
||||
"model": model,
|
||||
}
|
||||
return env
|
||||
|
||||
final_response_text = ""
|
||||
agent_error: Optional[str] = None
|
||||
usage: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
|
||||
try:
|
||||
# response.created — initial envelope, status=in_progress
|
||||
created_env = _envelope("in_progress")
|
||||
created_env["output"] = []
|
||||
await _write_event("response.created", {
|
||||
"type": "response.created",
|
||||
"response": created_env,
|
||||
})
|
||||
last_activity = time.monotonic()
|
||||
|
||||
async def _open_message_item() -> None:
|
||||
"""Emit response.output_item.added for the assistant message
|
||||
the first time any text delta arrives."""
|
||||
nonlocal message_opened, message_output_index, output_index
|
||||
if message_opened:
|
||||
return
|
||||
message_opened = True
|
||||
message_output_index = output_index
|
||||
output_index += 1
|
||||
item = {
|
||||
"id": message_item_id,
|
||||
"type": "message",
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
}
|
||||
await _write_event("response.output_item.added", {
|
||||
"type": "response.output_item.added",
|
||||
"output_index": message_output_index,
|
||||
"item": item,
|
||||
})
|
||||
|
||||
async def _emit_text_delta(delta_text: str) -> None:
|
||||
await _open_message_item()
|
||||
final_text_parts.append(delta_text)
|
||||
await _write_event("response.output_text.delta", {
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": message_item_id,
|
||||
"output_index": message_output_index,
|
||||
"content_index": 0,
|
||||
"delta": delta_text,
|
||||
"logprobs": [],
|
||||
})
|
||||
|
||||
async def _emit_tool_started(payload: Dict[str, Any]) -> str:
|
||||
"""Emit response.output_item.added for a function_call.
|
||||
|
||||
Returns the call_id so the matching completion event can
|
||||
reference it. Prefer the real ``tool_call_id`` from the
|
||||
agent when available; fall back to a generated call id for
|
||||
safety in tests or older code paths.
|
||||
"""
|
||||
nonlocal output_index, call_counter
|
||||
call_counter += 1
|
||||
call_id = payload.get("tool_call_id") or f"call_{response_id[5:]}_{call_counter}"
|
||||
args = payload.get("arguments", {})
|
||||
if isinstance(args, dict):
|
||||
arguments_str = json.dumps(args)
|
||||
else:
|
||||
arguments_str = str(args)
|
||||
item = {
|
||||
"id": f"fc_{uuid.uuid4().hex[:24]}",
|
||||
"type": "function_call",
|
||||
"status": "in_progress",
|
||||
"name": payload.get("name", ""),
|
||||
"call_id": call_id,
|
||||
"arguments": arguments_str,
|
||||
}
|
||||
idx = output_index
|
||||
output_index += 1
|
||||
pending_tool_calls.append({
|
||||
"call_id": call_id,
|
||||
"name": payload.get("name", ""),
|
||||
"arguments": arguments_str,
|
||||
"item_id": item["id"],
|
||||
"output_index": idx,
|
||||
})
|
||||
emitted_items.append({
|
||||
"type": "function_call",
|
||||
"name": payload.get("name", ""),
|
||||
"arguments": arguments_str,
|
||||
"call_id": call_id,
|
||||
})
|
||||
await _write_event("response.output_item.added", {
|
||||
"type": "response.output_item.added",
|
||||
"output_index": idx,
|
||||
"item": item,
|
||||
})
|
||||
return call_id
|
||||
|
||||
async def _emit_tool_completed(payload: Dict[str, Any]) -> None:
|
||||
"""Emit response.output_item.done (function_call) followed
|
||||
by response.output_item.added (function_call_output)."""
|
||||
nonlocal output_index
|
||||
call_id = payload.get("tool_call_id")
|
||||
result = payload.get("result", "")
|
||||
pending = None
|
||||
if call_id:
|
||||
for i, p in enumerate(pending_tool_calls):
|
||||
if p["call_id"] == call_id:
|
||||
pending = pending_tool_calls.pop(i)
|
||||
break
|
||||
if pending is None:
|
||||
# Completion without a matching start — skip to avoid
|
||||
# emitting orphaned done events.
|
||||
return
|
||||
|
||||
# function_call done
|
||||
done_item = {
|
||||
"id": pending["item_id"],
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"name": pending["name"],
|
||||
"call_id": pending["call_id"],
|
||||
"arguments": pending["arguments"],
|
||||
}
|
||||
await _write_event("response.output_item.done", {
|
||||
"type": "response.output_item.done",
|
||||
"output_index": pending["output_index"],
|
||||
"item": done_item,
|
||||
})
|
||||
|
||||
# function_call_output added (result)
|
||||
result_str = result if isinstance(result, str) else json.dumps(result)
|
||||
output_parts = [{"type": "input_text", "text": result_str}]
|
||||
output_item = {
|
||||
"id": f"fco_{uuid.uuid4().hex[:24]}",
|
||||
"type": "function_call_output",
|
||||
"call_id": pending["call_id"],
|
||||
"output": output_parts,
|
||||
"status": "completed",
|
||||
}
|
||||
idx = output_index
|
||||
output_index += 1
|
||||
emitted_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": pending["call_id"],
|
||||
"output": output_parts,
|
||||
})
|
||||
await _write_event("response.output_item.added", {
|
||||
"type": "response.output_item.added",
|
||||
"output_index": idx,
|
||||
"item": output_item,
|
||||
})
|
||||
await _write_event("response.output_item.done", {
|
||||
"type": "response.output_item.done",
|
||||
"output_index": idx,
|
||||
"item": output_item,
|
||||
})
|
||||
|
||||
# Main drain loop — thread-safe queue fed by agent callbacks.
|
||||
async def _dispatch(it) -> None:
|
||||
"""Route a queue item to the correct SSE emitter.
|
||||
|
||||
Plain strings are text deltas. Tagged tuples with
|
||||
``__tool_started__`` / ``__tool_completed__`` prefixes
|
||||
are tool lifecycle events.
|
||||
"""
|
||||
if isinstance(it, tuple) and len(it) == 2 and isinstance(it[0], str):
|
||||
tag, payload = it
|
||||
if tag == "__tool_started__":
|
||||
await _emit_tool_started(payload)
|
||||
elif tag == "__tool_completed__":
|
||||
await _emit_tool_completed(payload)
|
||||
# Unknown tags are silently ignored (forward-compat).
|
||||
elif isinstance(it, str):
|
||||
await _emit_text_delta(it)
|
||||
# Other types (non-string, non-tuple) are silently dropped.
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
try:
|
||||
item = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
|
||||
except _q.Empty:
|
||||
if agent_task.done():
|
||||
# Drain remaining
|
||||
while True:
|
||||
try:
|
||||
item = stream_q.get_nowait()
|
||||
if item is None:
|
||||
break
|
||||
await _dispatch(item)
|
||||
last_activity = time.monotonic()
|
||||
except _q.Empty:
|
||||
break
|
||||
break
|
||||
if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS:
|
||||
await response.write(b": keepalive\n\n")
|
||||
last_activity = time.monotonic()
|
||||
continue
|
||||
|
||||
if item is None: # EOS sentinel
|
||||
break
|
||||
|
||||
await _dispatch(item)
|
||||
last_activity = time.monotonic()
|
||||
|
||||
# Pick up agent result + usage from the completed task
|
||||
try:
|
||||
result, agent_usage = await agent_task
|
||||
usage = agent_usage or usage
|
||||
# If the agent produced a final_response but no text
|
||||
# deltas were streamed (e.g. some providers only emit
|
||||
# the full response at the end), emit a single fallback
|
||||
# delta so Responses clients still receive a live text part.
|
||||
agent_final = result.get("final_response", "") if isinstance(result, dict) else ""
|
||||
if agent_final and not final_text_parts:
|
||||
await _emit_text_delta(agent_final)
|
||||
if agent_final and not final_response_text:
|
||||
final_response_text = agent_final
|
||||
if isinstance(result, dict) and result.get("error") and not final_response_text:
|
||||
agent_error = result["error"]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Error running agent for streaming responses: %s", e, exc_info=True)
|
||||
agent_error = str(e)
|
||||
|
||||
# Close the message item if it was opened
|
||||
final_response_text = "".join(final_text_parts) or final_response_text
|
||||
if message_opened:
|
||||
await _write_event("response.output_text.done", {
|
||||
"type": "response.output_text.done",
|
||||
"item_id": message_item_id,
|
||||
"output_index": message_output_index,
|
||||
"content_index": 0,
|
||||
"text": final_response_text,
|
||||
"logprobs": [],
|
||||
})
|
||||
msg_done_item = {
|
||||
"id": message_item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": final_response_text}
|
||||
],
|
||||
}
|
||||
await _write_event("response.output_item.done", {
|
||||
"type": "response.output_item.done",
|
||||
"output_index": message_output_index,
|
||||
"item": msg_done_item,
|
||||
})
|
||||
|
||||
# Always append a final message item in the completed
|
||||
# response envelope so clients that only parse the terminal
|
||||
# payload still see the assistant text. This mirrors the
|
||||
# shape produced by _extract_output_items in the batch path.
|
||||
final_items: List[Dict[str, Any]] = list(emitted_items)
|
||||
final_items.append({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": final_response_text or (agent_error or "")}
|
||||
],
|
||||
})
|
||||
|
||||
if agent_error:
|
||||
failed_env = _envelope("failed")
|
||||
failed_env["output"] = final_items
|
||||
failed_env["error"] = {"message": agent_error, "type": "server_error"}
|
||||
failed_env["usage"] = {
|
||||
"input_tokens": usage.get("input_tokens", 0),
|
||||
"output_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
}
|
||||
await _write_event("response.failed", {
|
||||
"type": "response.failed",
|
||||
"response": failed_env,
|
||||
})
|
||||
else:
|
||||
completed_env = _envelope("completed")
|
||||
completed_env["output"] = final_items
|
||||
completed_env["usage"] = {
|
||||
"input_tokens": usage.get("input_tokens", 0),
|
||||
"output_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
}
|
||||
await _write_event("response.completed", {
|
||||
"type": "response.completed",
|
||||
"response": completed_env,
|
||||
})
|
||||
|
||||
# Persist for future chaining / GET retrieval, mirroring
|
||||
# the batch path behavior.
|
||||
if store:
|
||||
full_history = list(conversation_history)
|
||||
full_history.append({"role": "user", "content": user_message})
|
||||
if isinstance(result, dict) and result.get("messages"):
|
||||
full_history.extend(result["messages"])
|
||||
else:
|
||||
full_history.append({"role": "assistant", "content": final_response_text})
|
||||
self._response_store.put(response_id, {
|
||||
"response": completed_env,
|
||||
"conversation_history": full_history,
|
||||
"instructions": instructions,
|
||||
"session_id": session_id,
|
||||
})
|
||||
if conversation:
|
||||
self._response_store.set_conversation(conversation, response_id)
|
||||
|
||||
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
|
||||
# Client disconnected — interrupt the agent so it stops
|
||||
# making upstream LLM calls, then cancel the task.
|
||||
agent = agent_ref[0] if agent_ref else None
|
||||
if agent is not None:
|
||||
try:
|
||||
agent.interrupt("SSE client disconnected")
|
||||
except Exception:
|
||||
pass
|
||||
if not agent_task.done():
|
||||
agent_task.cancel()
|
||||
try:
|
||||
await agent_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
logger.info("SSE client disconnected; interrupted agent task %s", response_id)
|
||||
|
||||
return response
|
||||
|
||||
async def _handle_responses(self, request: "web.Request") -> "web.Response":
|
||||
"""POST /v1/responses — OpenAI Responses API format."""
|
||||
auth_err = self._check_auth(request)
|
||||
@@ -1013,11 +1460,13 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if previous_response_id:
|
||||
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
|
||||
|
||||
stored_session_id = None
|
||||
if not conversation_history and previous_response_id:
|
||||
stored = self._response_store.get(previous_response_id)
|
||||
if stored is None:
|
||||
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
|
||||
conversation_history = list(stored.get("conversation_history", []))
|
||||
stored_session_id = stored.get("session_id")
|
||||
# If no instructions provided, carry forward from previous
|
||||
if instructions is None:
|
||||
instructions = stored.get("instructions")
|
||||
@@ -1035,8 +1484,83 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if body.get("truncation") == "auto" and len(conversation_history) > 100:
|
||||
conversation_history = conversation_history[-100:]
|
||||
|
||||
# Run the agent (with Idempotency-Key support)
|
||||
session_id = str(uuid.uuid4())
|
||||
# Reuse session from previous_response_id chain so the dashboard
|
||||
# groups the entire conversation under one session entry.
|
||||
session_id = stored_session_id or str(uuid.uuid4())
|
||||
|
||||
stream = bool(body.get("stream", False))
|
||||
if stream:
|
||||
# Streaming branch — emit OpenAI Responses SSE events as the
|
||||
# agent runs so frontends can render text deltas and tool
|
||||
# calls in real time. See _write_sse_responses for details.
|
||||
import queue as _q
|
||||
_stream_q: _q.Queue = _q.Queue()
|
||||
|
||||
def _on_delta(delta):
|
||||
# None from the agent is a CLI box-close signal, not EOS.
|
||||
# Forwarding would kill the SSE stream prematurely; the
|
||||
# SSE writer detects completion via agent_task.done().
|
||||
if delta is not None:
|
||||
_stream_q.put(delta)
|
||||
|
||||
def _on_tool_progress(event_type, name, preview, args, **kwargs):
|
||||
"""Queue non-start tool progress events if needed in future.
|
||||
|
||||
The structured Responses stream uses ``tool_start_callback``
|
||||
and ``tool_complete_callback`` for exact call-id correlation,
|
||||
so progress events are currently ignored here.
|
||||
"""
|
||||
return
|
||||
|
||||
def _on_tool_start(tool_call_id, function_name, function_args):
|
||||
"""Queue a started tool for live function_call streaming."""
|
||||
_stream_q.put(("__tool_started__", {
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": function_name,
|
||||
"arguments": function_args or {},
|
||||
}))
|
||||
|
||||
def _on_tool_complete(tool_call_id, function_name, function_args, function_result):
|
||||
"""Queue a completed tool result for live function_call_output streaming."""
|
||||
_stream_q.put(("__tool_completed__", {
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": function_name,
|
||||
"arguments": function_args or {},
|
||||
"result": function_result,
|
||||
}))
|
||||
|
||||
agent_ref = [None]
|
||||
agent_task = asyncio.ensure_future(self._run_agent(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=instructions,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=_on_delta,
|
||||
tool_progress_callback=_on_tool_progress,
|
||||
tool_start_callback=_on_tool_start,
|
||||
tool_complete_callback=_on_tool_complete,
|
||||
agent_ref=agent_ref,
|
||||
))
|
||||
|
||||
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
||||
model_name = body.get("model", self._model_name)
|
||||
created_at = int(time.time())
|
||||
|
||||
return await self._write_sse_responses(
|
||||
request=request,
|
||||
response_id=response_id,
|
||||
model=model_name,
|
||||
created_at=created_at,
|
||||
stream_q=_stream_q,
|
||||
agent_task=agent_task,
|
||||
agent_ref=agent_ref,
|
||||
conversation_history=conversation_history,
|
||||
user_message=user_message,
|
||||
instructions=instructions,
|
||||
conversation=conversation,
|
||||
store=store,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
async def _compute_response():
|
||||
return await self._run_agent(
|
||||
@@ -1111,6 +1635,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"response": response_data,
|
||||
"conversation_history": full_history,
|
||||
"instructions": instructions,
|
||||
"session_id": session_id,
|
||||
})
|
||||
# Update conversation mapping so the next request with the same
|
||||
# conversation name automatically chains to this response
|
||||
@@ -1464,6 +1989,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
session_id: Optional[str] = None,
|
||||
stream_delta_callback=None,
|
||||
tool_progress_callback=None,
|
||||
tool_start_callback=None,
|
||||
tool_complete_callback=None,
|
||||
agent_ref: Optional[list] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
@@ -1485,6 +2012,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
session_id=session_id,
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
tool_start_callback=tool_start_callback,
|
||||
tool_complete_callback=tool_complete_callback,
|
||||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
@@ -1621,10 +2150,12 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if previous_response_id:
|
||||
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
|
||||
|
||||
stored_session_id = None
|
||||
if not conversation_history and previous_response_id:
|
||||
stored = self._response_store.get(previous_response_id)
|
||||
if stored:
|
||||
conversation_history = list(stored.get("conversation_history", []))
|
||||
stored_session_id = stored.get("session_id")
|
||||
if instructions is None:
|
||||
instructions = stored.get("instructions")
|
||||
|
||||
@@ -1643,7 +2174,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
)
|
||||
conversation_history.append({"role": msg["role"], "content": str(content)})
|
||||
|
||||
session_id = body.get("session_id") or run_id
|
||||
session_id = body.get("session_id") or stored_session_id or run_id
|
||||
ephemeral_system_prompt = instructions
|
||||
|
||||
async def _run_and_close():
|
||||
@@ -1783,6 +2314,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
self._app = web.Application(middlewares=mws)
|
||||
self._app["api_server_adapter"] = self
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
|
||||
self._app.router.add_get("/v1/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/models", self._handle_models)
|
||||
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
||||
|
||||
@@ -1624,6 +1624,21 @@ class BasePlatformAdapter(ABC):
|
||||
# streaming already delivered the text (already_sent=True) or
|
||||
# when the message was queued behind an active agent. Log at
|
||||
# DEBUG to avoid noisy warnings for expected behavior.
|
||||
#
|
||||
# Suppress stale response when the session was interrupted by a
|
||||
# new message that hasn't been consumed yet. The pending message
|
||||
# is processed by the pending-message handler below (#8221/#2483).
|
||||
if (
|
||||
response
|
||||
and interrupt_event.is_set()
|
||||
and session_key in self._pending_messages
|
||||
):
|
||||
logger.info(
|
||||
"[%s] Suppressing stale response for interrupted session %s",
|
||||
self.name,
|
||||
session_key,
|
||||
)
|
||||
response = None
|
||||
if not response:
|
||||
logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
|
||||
if response:
|
||||
|
||||
@@ -224,6 +224,21 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
host = "localhost"
|
||||
return f"http://{host}:{self.webhook_port}{self.webhook_path}"
|
||||
|
||||
@property
|
||||
def _webhook_register_url(self) -> str:
|
||||
"""Webhook URL registered with BlueBubbles, including the password as
|
||||
a query param so inbound webhook POSTs carry credentials.
|
||||
|
||||
BlueBubbles posts events to the exact URL registered via
|
||||
``/api/v1/webhook``. Its webhook registration API does not support
|
||||
custom headers, so embedding the password in the URL is the only
|
||||
way to authenticate inbound webhooks without disabling auth.
|
||||
"""
|
||||
base = self._webhook_url
|
||||
if self.password:
|
||||
return f"{base}?password={quote(self.password, safe='')}"
|
||||
return base
|
||||
|
||||
async def _find_registered_webhooks(self, url: str) -> list:
|
||||
"""Return list of BB webhook entries matching *url*."""
|
||||
try:
|
||||
@@ -245,7 +260,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
webhook_url = self._webhook_url
|
||||
webhook_url = self._webhook_register_url
|
||||
|
||||
# Crash resilience — reuse an existing registration if present
|
||||
existing = await self._find_registered_webhooks(webhook_url)
|
||||
@@ -257,7 +272,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
payload = {
|
||||
"url": webhook_url,
|
||||
"events": ["new-message", "updated-message", "message"],
|
||||
"events": ["new-message", "updated-message"],
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -292,7 +307,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
webhook_url = self._webhook_url
|
||||
webhook_url = self._webhook_register_url
|
||||
removed = False
|
||||
|
||||
try:
|
||||
@@ -604,35 +619,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
# Tapback reactions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_reaction(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_guid: str,
|
||||
reaction: str,
|
||||
part_index: int = 0,
|
||||
) -> SendResult:
|
||||
"""Send a tapback reaction (requires Private API helper)."""
|
||||
if not self._private_api_enabled or not self._helper_connected:
|
||||
return SendResult(
|
||||
success=False, error="Private API helper not connected"
|
||||
)
|
||||
guid = await self._resolve_chat_guid(chat_id)
|
||||
if not guid:
|
||||
return SendResult(success=False, error=f"Chat not found: {chat_id}")
|
||||
try:
|
||||
res = await self._api_post(
|
||||
"/api/v1/message/react",
|
||||
{
|
||||
"chatGuid": guid,
|
||||
"selectedMessageGuid": message_guid,
|
||||
"reaction": reaction,
|
||||
"partIndex": part_index,
|
||||
},
|
||||
)
|
||||
return SendResult(success=True, raw_response=res)
|
||||
except Exception as exc:
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Chat info
|
||||
# ------------------------------------------------------------------
|
||||
@@ -864,6 +850,12 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
payload.get("chat_guid"),
|
||||
payload.get("guid"),
|
||||
)
|
||||
# Fallback: BlueBubbles v1.9+ webhook payloads omit top-level chatGuid;
|
||||
# the chat GUID is nested under data.chats[0].guid instead.
|
||||
if not chat_guid:
|
||||
_chats = record.get("chats") or []
|
||||
if _chats and isinstance(_chats[0], dict):
|
||||
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
|
||||
chat_identifier = self._value(
|
||||
record.get("chatIdentifier"),
|
||||
record.get("identifier"),
|
||||
|
||||
@@ -21,7 +21,6 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
+145
-28
@@ -10,7 +10,6 @@ Uses discord.py library for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
@@ -19,7 +18,6 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1381,6 +1379,68 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
||||
|
||||
async def send_animation(
|
||||
self,
|
||||
chat_id: str,
|
||||
animation_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an animated GIF natively as a Discord file attachment."""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
if not is_safe_url(animation_url):
|
||||
logger.warning("[%s] Blocked unsafe animation URL during Discord send_animation", self.name)
|
||||
return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata)
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
|
||||
# Download the GIF and send as a Discord file attachment
|
||||
# (Discord renders .gif attachments as auto-playing animations inline)
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
async with session.get(animation_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"Failed to download animation: HTTP {resp.status}")
|
||||
|
||||
animation_data = await resp.read()
|
||||
|
||||
import io
|
||||
file = discord.File(io.BytesIO(animation_data), filename="animation.gif")
|
||||
|
||||
msg = await channel.send(
|
||||
content=caption if caption else None,
|
||||
file=file,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp",
|
||||
self.name,
|
||||
exc_info=True,
|
||||
)
|
||||
return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata)
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"[%s] Failed to send animation attachment, falling back to URL: %s",
|
||||
self.name,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1698,6 +1758,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
async def slash_update(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, "/update", "Update initiated~")
|
||||
|
||||
@tree.command(name="restart", description="Gracefully restart the Hermes gateway")
|
||||
async def slash_restart(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, "/restart", "Restart requested~")
|
||||
|
||||
@tree.command(name="approve", description="Approve a pending dangerous command")
|
||||
@discord.app_commands.describe(scope="Optional: 'all', 'session', 'always', 'all session', 'all always'")
|
||||
async def slash_approve(interaction: discord.Interaction, scope: str = ""):
|
||||
@@ -1738,46 +1802,90 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
async def slash_btw(interaction: discord.Interaction, question: str):
|
||||
await self._run_simple_slash(interaction, f"/btw {question}")
|
||||
|
||||
# Register installed skills as native slash commands (parity with
|
||||
# Telegram, which uses telegram_menu_commands() in commands.py).
|
||||
# Discord allows up to 100 application commands globally.
|
||||
_DISCORD_CMD_LIMIT = 100
|
||||
# Register skills under a single /skill command group with category
|
||||
# subcommand groups. This uses 1 top-level slot instead of N,
|
||||
# supporting up to 25 categories × 25 skills = 625 skills.
|
||||
self._register_skill_group(tree)
|
||||
|
||||
def _register_skill_group(self, tree) -> None:
|
||||
"""Register a ``/skill`` command group with category subcommand groups.
|
||||
|
||||
Skills are organized by their directory category under ``SKILLS_DIR``.
|
||||
Each category becomes a subcommand group; root-level skills become
|
||||
direct subcommands. Discord supports 25 subcommand groups × 25
|
||||
subcommands each = 625 skills — well beyond the old 100-command cap.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.commands import discord_skill_commands
|
||||
from hermes_cli.commands import discord_skill_commands_by_category
|
||||
|
||||
existing_names = {cmd.name for cmd in tree.get_commands()}
|
||||
remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names))
|
||||
existing_names = set()
|
||||
try:
|
||||
existing_names = {cmd.name for cmd in tree.get_commands()}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
skill_entries, skipped = discord_skill_commands(
|
||||
max_slots=remaining_slots,
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=existing_names,
|
||||
)
|
||||
|
||||
for discord_name, description, cmd_key in skill_entries:
|
||||
# Closure factory to capture cmd_key per iteration
|
||||
def _make_skill_handler(_key: str):
|
||||
async def _skill_slash(interaction: discord.Interaction, args: str = ""):
|
||||
await self._run_simple_slash(interaction, f"{_key} {args}".strip())
|
||||
return _skill_slash
|
||||
if not categories and not uncategorized:
|
||||
return
|
||||
|
||||
handler = _make_skill_handler(cmd_key)
|
||||
handler.__name__ = f"skill_{discord_name.replace('-', '_')}"
|
||||
skill_group = discord.app_commands.Group(
|
||||
name="skill",
|
||||
description="Run a Hermes skill",
|
||||
)
|
||||
|
||||
# ── Helper: build a callback for a skill command key ──
|
||||
def _make_handler(_key: str):
|
||||
@discord.app_commands.describe(args="Optional arguments for the skill")
|
||||
async def _handler(interaction: discord.Interaction, args: str = ""):
|
||||
await self._run_simple_slash(interaction, f"{_key} {args}".strip())
|
||||
_handler.__name__ = f"skill_{_key.lstrip('/').replace('-', '_')}"
|
||||
return _handler
|
||||
|
||||
# ── Uncategorized (root-level) skills → direct subcommands ──
|
||||
for discord_name, description, cmd_key in uncategorized:
|
||||
cmd = discord.app_commands.Command(
|
||||
name=discord_name,
|
||||
description=description,
|
||||
callback=handler,
|
||||
description=description or f"Run the {discord_name} skill",
|
||||
callback=_make_handler(cmd_key),
|
||||
)
|
||||
discord.app_commands.describe(args="Optional arguments for the skill")(cmd)
|
||||
tree.add_command(cmd)
|
||||
skill_group.add_command(cmd)
|
||||
|
||||
if skipped:
|
||||
# ── Category subcommand groups ──
|
||||
for cat_name in sorted(categories):
|
||||
cat_desc = f"{cat_name.replace('-', ' ').title()} skills"
|
||||
if len(cat_desc) > 100:
|
||||
cat_desc = cat_desc[:97] + "..."
|
||||
cat_group = discord.app_commands.Group(
|
||||
name=cat_name,
|
||||
description=cat_desc,
|
||||
parent=skill_group,
|
||||
)
|
||||
for discord_name, description, cmd_key in categories[cat_name]:
|
||||
cmd = discord.app_commands.Command(
|
||||
name=discord_name,
|
||||
description=description or f"Run the {discord_name} skill",
|
||||
callback=_make_handler(cmd_key),
|
||||
)
|
||||
cat_group.add_command(cmd)
|
||||
|
||||
tree.add_command(skill_group)
|
||||
|
||||
total = sum(len(v) for v in categories.values()) + len(uncategorized)
|
||||
logger.info(
|
||||
"[%s] Registered /skill group: %d skill(s) across %d categories"
|
||||
" + %d uncategorized",
|
||||
self.name, total, len(categories), len(uncategorized),
|
||||
)
|
||||
if hidden:
|
||||
logger.warning(
|
||||
"[%s] Discord slash command limit reached (%d): %d skill(s) not registered",
|
||||
self.name, _DISCORD_CMD_LIMIT, skipped,
|
||||
"[%s] %d skill(s) not registered (Discord subcommand limits)",
|
||||
self.name, hidden,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc)
|
||||
logger.warning("[%s] Failed to register /skill group: %s", self.name, exc)
|
||||
|
||||
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
||||
"""Build a MessageEvent from a Discord slash command interaction."""
|
||||
@@ -2476,6 +2584,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
_parent_id = str(getattr(_chan, "parent_id", "") or "")
|
||||
_chan_id = str(getattr(_chan, "id", ""))
|
||||
_skills = self._resolve_channel_skills(_chan_id, _parent_id or None)
|
||||
|
||||
reply_to_id = None
|
||||
reply_to_text = None
|
||||
if message.reference:
|
||||
reply_to_id = str(message.reference.message_id)
|
||||
if message.reference.resolved:
|
||||
reply_to_text = getattr(message.reference.resolved, "content", None) or None
|
||||
|
||||
event = MessageEvent(
|
||||
text=event_text,
|
||||
message_type=msg_type,
|
||||
@@ -2484,7 +2600,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
message_id=str(message.id),
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
reply_to_message_id=str(message.reference.message_id) if message.reference else None,
|
||||
reply_to_message_id=reply_to_id,
|
||||
reply_to_text=reply_to_text,
|
||||
timestamp=message.created_at,
|
||||
auto_skill=_skills,
|
||||
)
|
||||
|
||||
+109
-87
@@ -72,7 +72,10 @@ try:
|
||||
UpdateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
|
||||
from lark_oapi.ws import Client as FeishuWSClient
|
||||
|
||||
@@ -80,6 +83,7 @@ try:
|
||||
except ImportError:
|
||||
FEISHU_AVAILABLE = False
|
||||
lark = None # type: ignore[assignment]
|
||||
CallBackCard = None # type: ignore[assignment]
|
||||
P2CardActionTriggerResponse = None # type: ignore[assignment]
|
||||
EventDispatcherHandler = None # type: ignore[assignment]
|
||||
FeishuWSClient = None # type: ignore[assignment]
|
||||
@@ -169,6 +173,19 @@ _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS = 30 # max seconds to read request
|
||||
_FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25 # consecutive error responses before WARNING log
|
||||
_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 # anomaly tracker TTL (6 hours) — matches openclaw
|
||||
_FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup window (15 min)
|
||||
|
||||
_APPROVAL_CHOICE_MAP: Dict[str, str] = {
|
||||
"approve_once": "once",
|
||||
"approve_session": "session",
|
||||
"approve_always": "always",
|
||||
"deny": "deny",
|
||||
}
|
||||
_APPROVAL_LABEL_MAP: Dict[str, str] = {
|
||||
"once": "Approved once",
|
||||
"session": "Approved for session",
|
||||
"always": "Approved permanently",
|
||||
"deny": "Denied",
|
||||
}
|
||||
_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
|
||||
_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
|
||||
_FEISHU_ACK_EMOJI = "OK"
|
||||
@@ -430,14 +447,6 @@ def _build_markdown_post_payload(content: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def parse_feishu_post_content(raw_content: str) -> FeishuPostParseResult:
|
||||
try:
|
||||
parsed = json.loads(raw_content) if raw_content else {}
|
||||
except json.JSONDecodeError:
|
||||
return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
|
||||
return parse_feishu_post_payload(parsed)
|
||||
|
||||
|
||||
def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
|
||||
resolved = _resolve_post_payload(payload)
|
||||
if not resolved:
|
||||
@@ -1498,14 +1507,12 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
logger.warning("[Feishu] send_exec_approval failed: %s", exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def _update_approval_card(
|
||||
self, message_id: str, label: str, user_name: str, choice: str,
|
||||
) -> None:
|
||||
"""Replace the approval card with a resolved status card."""
|
||||
if not self._client or not message_id:
|
||||
return
|
||||
@staticmethod
|
||||
def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:
|
||||
"""Build raw card JSON for a resolved approval action."""
|
||||
icon = "❌" if choice == "deny" else "✅"
|
||||
card = {
|
||||
label = _APPROVAL_LABEL_MAP.get(choice, "Resolved")
|
||||
return {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"title": {"content": f"{icon} {label}", "tag": "plain_text"},
|
||||
@@ -1518,13 +1525,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
},
|
||||
],
|
||||
}
|
||||
try:
|
||||
payload = json.dumps(card, ensure_ascii=False)
|
||||
body = self._build_update_message_body(msg_type="interactive", content=payload)
|
||||
request = self._build_update_message_request(message_id=message_id, request_body=body)
|
||||
await asyncio.to_thread(self._client.im.v1.message.update, request)
|
||||
except Exception as exc:
|
||||
logger.warning("[Feishu] Failed to update approval card %s: %s", message_id, exc)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
@@ -1853,20 +1853,82 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
future.add_done_callback(self._log_background_failure)
|
||||
|
||||
def _on_card_action_trigger(self, data: Any) -> Any:
|
||||
"""Schedule Feishu card actions on the adapter loop and acknowledge immediately."""
|
||||
"""Handle card-action callback from the Feishu SDK (synchronous).
|
||||
|
||||
For approval actions: parses the event once, returns the resolved card
|
||||
inline (the only reliable way to sync all clients), and schedules a
|
||||
lightweight async method to actually unblock the agent.
|
||||
|
||||
For other card actions: delegates to ``_handle_card_action_event``.
|
||||
"""
|
||||
loop = self._loop
|
||||
if loop is None or bool(getattr(loop, "is_closed", lambda: False)()):
|
||||
if not self._loop_accepts_callbacks(loop):
|
||||
logger.warning("[Feishu] Dropping card action before adapter loop is ready")
|
||||
else:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._handle_card_action_event(data),
|
||||
loop,
|
||||
)
|
||||
future.add_done_callback(self._log_background_failure)
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
event = getattr(data, "event", None)
|
||||
action = getattr(event, "action", None)
|
||||
action_value = getattr(action, "value", {}) or {}
|
||||
hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None
|
||||
|
||||
if hermes_action:
|
||||
return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)
|
||||
|
||||
self._submit_on_loop(loop, self._handle_card_action_event(data))
|
||||
if P2CardActionTriggerResponse is None:
|
||||
return None
|
||||
return P2CardActionTriggerResponse()
|
||||
|
||||
@staticmethod
|
||||
def _loop_accepts_callbacks(loop: Any) -> bool:
|
||||
"""Return True when the adapter loop can accept thread-safe submissions."""
|
||||
return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
|
||||
|
||||
def _submit_on_loop(self, loop: Any, coro: Any) -> None:
|
||||
"""Schedule background work on the adapter loop with shared failure logging."""
|
||||
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
future.add_done_callback(self._log_background_failure)
|
||||
|
||||
def _handle_approval_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any:
|
||||
"""Schedule approval resolution and build the synchronous callback response."""
|
||||
approval_id = action_value.get("approval_id")
|
||||
if approval_id is None:
|
||||
logger.debug("[Feishu] Card action missing approval_id, ignoring")
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny")
|
||||
|
||||
operator = getattr(event, "operator", None)
|
||||
open_id = str(getattr(operator, "open_id", "") or "")
|
||||
user_name = self._get_cached_sender_name(open_id) or open_id
|
||||
|
||||
self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name))
|
||||
|
||||
if P2CardActionTriggerResponse is None:
|
||||
return None
|
||||
response = P2CardActionTriggerResponse()
|
||||
if CallBackCard is not None:
|
||||
card = CallBackCard()
|
||||
card.type = "raw"
|
||||
card.data = self._build_resolved_approval_card(choice=choice, user_name=user_name)
|
||||
response.card = card
|
||||
return response
|
||||
|
||||
async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:
|
||||
"""Pop approval state and unblock the waiting agent thread."""
|
||||
state = self._approval_state.pop(approval_id, None)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
|
||||
return
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
count = resolve_gateway_approval(state["session_key"], choice)
|
||||
logger.info(
|
||||
"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)",
|
||||
count, state["session_key"], choice, user_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
|
||||
|
||||
async def _handle_reaction_event(self, event_type: str, data: Any) -> None:
|
||||
"""Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event."""
|
||||
if not self._client:
|
||||
@@ -1958,51 +2020,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
action_tag = str(getattr(action, "tag", "") or "button")
|
||||
action_value = getattr(action, "value", {}) or {}
|
||||
|
||||
# --- Exec approval button intercept ---
|
||||
hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None
|
||||
if hermes_action:
|
||||
approval_id = action_value.get("approval_id")
|
||||
state = self._approval_state.pop(approval_id, None)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
|
||||
return
|
||||
|
||||
choice_map = {
|
||||
"approve_once": "once",
|
||||
"approve_session": "session",
|
||||
"approve_always": "always",
|
||||
"deny": "deny",
|
||||
}
|
||||
choice = choice_map.get(hermes_action, "deny")
|
||||
|
||||
label_map = {
|
||||
"once": "Approved once",
|
||||
"session": "Approved for session",
|
||||
"always": "Approved permanently",
|
||||
"deny": "Denied",
|
||||
}
|
||||
label = label_map.get(choice, "Resolved")
|
||||
|
||||
# Resolve sender name for the status card
|
||||
sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None)
|
||||
sender_profile = await self._resolve_sender_profile(sender_id)
|
||||
user_name = sender_profile.get("user_name") or open_id
|
||||
|
||||
# Resolve the approval — unblocks the agent thread
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
count = resolve_gateway_approval(state["session_key"], choice)
|
||||
logger.info(
|
||||
"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)",
|
||||
count, state["session_key"], choice, user_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
|
||||
|
||||
# Update the card to show the decision
|
||||
await self._update_approval_card(state.get("message_id", ""), label, user_name, choice)
|
||||
return
|
||||
|
||||
synthetic_text = f"/card {action_tag}"
|
||||
if action_value:
|
||||
try:
|
||||
@@ -2688,12 +2705,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT)
|
||||
return MessageType.TEXT
|
||||
|
||||
def _normalize_inbound_text(self, text: str) -> str:
|
||||
"""Strip Feishu mention placeholders from inbound text."""
|
||||
text = _MENTION_RE.sub(" ", text or "")
|
||||
text = _MULTISPACE_RE.sub(" ", text)
|
||||
return text.strip()
|
||||
|
||||
async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str:
|
||||
if not cached_path or not media_type.startswith("text/"):
|
||||
return ""
|
||||
@@ -2911,6 +2922,19 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
"user_id_alt": union_id,
|
||||
}
|
||||
|
||||
def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]:
|
||||
"""Return a cached sender name only while its TTL is still valid."""
|
||||
if not sender_id:
|
||||
return None
|
||||
cached = self._sender_name_cache.get(sender_id)
|
||||
if cached is None:
|
||||
return None
|
||||
name, expire_at = cached
|
||||
if time.time() < expire_at:
|
||||
return name
|
||||
self._sender_name_cache.pop(sender_id, None)
|
||||
return None
|
||||
|
||||
async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]:
|
||||
"""Fetch the sender's display name from the Feishu contact API with a 10-minute cache.
|
||||
|
||||
@@ -2923,11 +2947,9 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if not trimmed:
|
||||
return None
|
||||
now = time.time()
|
||||
cached = self._sender_name_cache.get(trimmed)
|
||||
if cached is not None:
|
||||
name, expire_at = cached
|
||||
if now < expire_at:
|
||||
return name
|
||||
cached_name = self._get_cached_sender_name(trimmed)
|
||||
if cached_name is not None:
|
||||
return cached_name
|
||||
try:
|
||||
from lark_oapi.api.contact.v3 import GetUserRequest # lazy import
|
||||
if trimmed.startswith("ou_"):
|
||||
|
||||
+18
-59
@@ -25,7 +25,6 @@ Environment variables:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -730,6 +729,14 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def stop_typing(self, chat_id: str) -> None:
|
||||
"""Stop the Matrix typing indicator."""
|
||||
if self._client:
|
||||
try:
|
||||
await self._client.set_typing(RoomID(chat_id), timeout=0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def edit_message(
|
||||
self, chat_id: str, message_id: str, content: str
|
||||
) -> SendResult:
|
||||
@@ -959,6 +966,16 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
sync_data = await client.sync(
|
||||
since=next_batch, timeout=30000,
|
||||
)
|
||||
|
||||
# nio returns SyncError objects (not exceptions) for auth
|
||||
# failures like M_UNKNOWN_TOKEN. Detect and stop immediately.
|
||||
_sync_msg = getattr(sync_data, "message", None)
|
||||
if _sync_msg and isinstance(_sync_msg, str):
|
||||
_lower = _sync_msg.lower()
|
||||
if "m_unknown_token" in _lower or "unknown_token" in _lower:
|
||||
logger.error("Matrix: permanent auth error from sync: %s — stopping", _sync_msg)
|
||||
return
|
||||
|
||||
if isinstance(sync_data, dict):
|
||||
# Update joined rooms from sync response.
|
||||
rooms_join = sync_data.get("rooms", {}).get("join", {})
|
||||
@@ -1612,52 +1629,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.warning("Matrix: redact error: %s", exc)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Room history
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def fetch_room_history(
|
||||
self,
|
||||
room_id: str,
|
||||
limit: int = 50,
|
||||
start: str = "",
|
||||
) -> list:
|
||||
"""Fetch recent messages from a room."""
|
||||
if not self._client:
|
||||
return []
|
||||
try:
|
||||
resp = await self._client.get_messages(
|
||||
RoomID(room_id),
|
||||
direction=PaginationDirection.BACKWARD,
|
||||
from_token=SyncToken(start) if start else None,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: get_messages failed for %s: %s", room_id, exc)
|
||||
return []
|
||||
|
||||
if not resp:
|
||||
return []
|
||||
|
||||
events = getattr(resp, "chunk", []) or (resp.get("chunk", []) if isinstance(resp, dict) else [])
|
||||
messages = []
|
||||
for event in reversed(events):
|
||||
body = ""
|
||||
content = getattr(event, "content", None)
|
||||
if content:
|
||||
if hasattr(content, "body"):
|
||||
body = content.body or ""
|
||||
elif isinstance(content, dict):
|
||||
body = content.get("body", "")
|
||||
messages.append({
|
||||
"event_id": str(getattr(event, "event_id", "")),
|
||||
"sender": str(getattr(event, "sender", "")),
|
||||
"body": body,
|
||||
"timestamp": getattr(event, "timestamp", 0) or getattr(event, "server_timestamp", 0),
|
||||
"type": type(event).__name__,
|
||||
})
|
||||
return messages
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Room creation & management
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1761,18 +1732,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def send_emote(
|
||||
self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an emote message (/me style action)."""
|
||||
return await self._send_simple_message(chat_id, text, "m.emote")
|
||||
|
||||
async def send_notice(
|
||||
self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a notice message (bot-appropriate, non-alerting)."""
|
||||
return await self._send_simple_message(chat_id, text, "m.notice")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -781,21 +780,6 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
# Typing Indicators
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _start_typing_indicator(self, chat_id: str) -> None:
|
||||
"""Start a typing indicator loop for a chat."""
|
||||
if chat_id in self._typing_tasks:
|
||||
return # Already running
|
||||
|
||||
async def _typing_loop():
|
||||
try:
|
||||
while True:
|
||||
await self.send_typing(chat_id)
|
||||
await asyncio.sleep(TYPING_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
|
||||
|
||||
async def _stop_typing_indicator(self, chat_id: str) -> None:
|
||||
"""Stop a typing indicator loop for a chat."""
|
||||
task = self._typing_tasks.pop(chat_id, None)
|
||||
|
||||
@@ -1916,9 +1916,20 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
# 9) Convert blockquotes: > at line start → protect > from escaping
|
||||
# Handle both regular blockquotes (> text) and expandable blockquotes
|
||||
# (Telegram MarkdownV2: **> for expandable start, || to end the quote)
|
||||
def _convert_blockquote(m):
|
||||
prefix = m.group(1) # >, >>, >>>, **>, or **>> etc.
|
||||
content = m.group(2)
|
||||
# Check if content ends with || (expandable blockquote end marker)
|
||||
# In this case, preserve the trailing || unescaped for Telegram
|
||||
if prefix.startswith('**') and content.endswith('||'):
|
||||
return _ph(f'{prefix} {_escape_mdv2(content[:-2])}||')
|
||||
return _ph(f'{prefix} {_escape_mdv2(content)}')
|
||||
|
||||
text = re.sub(
|
||||
r'^(>{1,3}) (.+)$',
|
||||
lambda m: _ph(m.group(1) + ' ' + _escape_mdv2(m.group(2))),
|
||||
r'^((?:\*\*)?>{1,3}) (.+)$',
|
||||
_convert_blockquote,
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
@@ -1991,6 +2002,27 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _telegram_ignored_threads(self) -> set[int]:
|
||||
raw = self.config.extra.get("ignored_threads")
|
||||
if raw is None:
|
||||
raw = os.getenv("TELEGRAM_IGNORED_THREADS", "")
|
||||
|
||||
if isinstance(raw, list):
|
||||
values = raw
|
||||
else:
|
||||
values = str(raw).split(",")
|
||||
|
||||
ignored: set[int] = set()
|
||||
for value in values:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
ignored.add(int(text))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value)
|
||||
return ignored
|
||||
|
||||
def _compile_mention_patterns(self) -> List[re.Pattern]:
|
||||
"""Compile optional regex wake-word patterns for group triggers."""
|
||||
patterns = self.config.extra.get("mention_patterns")
|
||||
@@ -2102,6 +2134,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
if not self._is_group_chat(message):
|
||||
return True
|
||||
thread_id = getattr(message, "message_thread_id", None)
|
||||
if thread_id is not None:
|
||||
try:
|
||||
if int(thread_id) in self._telegram_ignored_threads():
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
|
||||
if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
|
||||
return True
|
||||
if not self._telegram_require_mention():
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
@@ -204,6 +203,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
"wecom_callback",
|
||||
"weixin",
|
||||
"bluebubbles",
|
||||
"qqbot",
|
||||
):
|
||||
return await self._deliver_cross_platform(
|
||||
deliver_type, content, delivery
|
||||
|
||||
@@ -37,7 +37,6 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
+701
-60
@@ -573,6 +573,7 @@ class GatewayRunner:
|
||||
self._running_agents: Dict[str, Any] = {}
|
||||
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
|
||||
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
|
||||
self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce)
|
||||
|
||||
# Cache AIAgent instances per session to preserve prompt caching.
|
||||
# Without this, a new AIAgent is created per message, rebuilding the
|
||||
@@ -1329,26 +1330,100 @@ class GatewayRunner:
|
||||
merge_pending_message_event(adapter._pending_messages, session_key, event)
|
||||
|
||||
async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool:
|
||||
if not self._draining:
|
||||
return False
|
||||
# --- Draining case (gateway restarting/stopping) ---
|
||||
if self._draining:
|
||||
adapter = self.adapters.get(event.source.platform)
|
||||
if not adapter:
|
||||
return True
|
||||
|
||||
thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
|
||||
if self._queue_during_drain_enabled():
|
||||
self._queue_or_replace_pending_event(session_key, event)
|
||||
message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back."
|
||||
else:
|
||||
message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
|
||||
|
||||
await adapter._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=message,
|
||||
reply_to=event.message_id,
|
||||
metadata=thread_meta,
|
||||
)
|
||||
return True
|
||||
|
||||
# --- Normal busy case (agent actively running a task) ---
|
||||
# The user sent a message while the agent is working. Interrupt the
|
||||
# agent immediately so it stops the current tool-calling loop and
|
||||
# processes the new message. The pending message is stored in the
|
||||
# adapter so the base adapter picks it up once the interrupted run
|
||||
# returns. A brief ack tells the user what's happening (debounced
|
||||
# to avoid spam when they fire multiple messages quickly).
|
||||
|
||||
adapter = self.adapters.get(event.source.platform)
|
||||
if not adapter:
|
||||
return True
|
||||
return False # let default path handle it
|
||||
|
||||
# Store the message so it's processed as the next turn after the
|
||||
# interrupt causes the current run to exit.
|
||||
from gateway.platforms.base import merge_pending_message_event
|
||||
merge_pending_message_event(adapter._pending_messages, session_key, event)
|
||||
|
||||
# Interrupt the running agent — this aborts in-flight tool calls and
|
||||
# causes the agent loop to exit at the next check point.
|
||||
running_agent = self._running_agents.get(session_key)
|
||||
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
|
||||
try:
|
||||
running_agent.interrupt(event.text)
|
||||
except Exception:
|
||||
pass # don't let interrupt failure block the ack
|
||||
|
||||
# Debounce: only send an acknowledgment once every 30 seconds per session
|
||||
# to avoid spamming the user when they send multiple messages quickly
|
||||
_BUSY_ACK_COOLDOWN = 30
|
||||
now = time.time()
|
||||
last_ack = self._busy_ack_ts.get(session_key, 0)
|
||||
if now - last_ack < _BUSY_ACK_COOLDOWN:
|
||||
return True # interrupt sent, ack already delivered recently
|
||||
|
||||
self._busy_ack_ts[session_key] = now
|
||||
|
||||
# Build a status-rich acknowledgment
|
||||
status_parts = []
|
||||
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
|
||||
try:
|
||||
summary = running_agent.get_activity_summary()
|
||||
iteration = summary.get("api_call_count", 0)
|
||||
max_iter = summary.get("max_iterations", 0)
|
||||
current_tool = summary.get("current_tool")
|
||||
start_ts = self._running_agents_ts.get(session_key, 0)
|
||||
if start_ts:
|
||||
elapsed_min = int((now - start_ts) / 60)
|
||||
if elapsed_min > 0:
|
||||
status_parts.append(f"{elapsed_min} min elapsed")
|
||||
if max_iter:
|
||||
status_parts.append(f"iteration {iteration}/{max_iter}")
|
||||
if current_tool:
|
||||
status_parts.append(f"running: {current_tool}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status_detail = f" ({', '.join(status_parts)})" if status_parts else ""
|
||||
message = (
|
||||
f"⚡ Interrupting current task{status_detail}. "
|
||||
f"I'll respond to your message shortly."
|
||||
)
|
||||
|
||||
thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
|
||||
if self._queue_during_drain_enabled():
|
||||
self._queue_or_replace_pending_event(session_key, event)
|
||||
message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back."
|
||||
else:
|
||||
message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
|
||||
try:
|
||||
await adapter._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=message,
|
||||
reply_to=event.message_id,
|
||||
metadata=thread_meta,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send busy-ack: %s", e)
|
||||
|
||||
await adapter._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=message,
|
||||
reply_to=event.message_id,
|
||||
metadata=thread_meta,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]:
|
||||
@@ -1391,6 +1466,65 @@ class GatewayRunner:
|
||||
except Exception as e:
|
||||
logger.debug("Failed interrupting agent during shutdown: %s", e)
|
||||
|
||||
async def _notify_active_sessions_of_shutdown(self) -> None:
|
||||
"""Send a notification to every chat with an active agent.
|
||||
|
||||
Called at the very start of stop() — adapters are still connected so
|
||||
messages can be delivered. Best-effort: individual send failures are
|
||||
logged and swallowed so they never block the shutdown sequence.
|
||||
"""
|
||||
active = self._snapshot_running_agents()
|
||||
if not active:
|
||||
return
|
||||
|
||||
action = "restarting" if self._restart_requested else "shutting down"
|
||||
hint = (
|
||||
"Your current task will be interrupted. "
|
||||
"Send any message after restart to resume where it left off."
|
||||
if self._restart_requested
|
||||
else "Your current task will be interrupted."
|
||||
)
|
||||
msg = f"⚠️ Gateway {action} — {hint}"
|
||||
|
||||
notified: set = set()
|
||||
for session_key in active:
|
||||
# Parse platform + chat_id from the session key.
|
||||
# Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]
|
||||
parts = session_key.split(":")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
platform_str = parts[2]
|
||||
chat_id = parts[4]
|
||||
|
||||
# Deduplicate: one notification per chat, even if multiple
|
||||
# sessions (different users/threads) share the same chat.
|
||||
dedup_key = (platform_str, chat_id)
|
||||
if dedup_key in notified:
|
||||
continue
|
||||
|
||||
try:
|
||||
platform = Platform(platform_str)
|
||||
adapter = self.adapters.get(platform)
|
||||
if not adapter:
|
||||
continue
|
||||
|
||||
# Include thread_id if present so the message lands in the
|
||||
# correct forum topic / thread.
|
||||
thread_id = parts[5] if len(parts) > 5 else None
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
|
||||
await adapter.send(chat_id, msg, metadata=metadata)
|
||||
notified.add(dedup_key)
|
||||
logger.info(
|
||||
"Sent shutdown notification to %s:%s",
|
||||
platform_str, chat_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to send shutdown notification to %s:%s: %s",
|
||||
platform_str, chat_id, e,
|
||||
)
|
||||
|
||||
def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:
|
||||
for agent in active_agents.values():
|
||||
try:
|
||||
@@ -1416,6 +1550,106 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_STUCK_LOOP_THRESHOLD = 3 # restarts while active before auto-suspend
|
||||
_STUCK_LOOP_FILE = ".restart_failure_counts"
|
||||
|
||||
def _increment_restart_failure_counts(self, active_session_keys: set) -> None:
|
||||
"""Increment restart-failure counters for sessions active at shutdown.
|
||||
|
||||
Persists to a JSON file so counters survive across restarts.
|
||||
Sessions NOT in active_session_keys are removed (they completed
|
||||
successfully, so the loop is broken).
|
||||
"""
|
||||
import json
|
||||
|
||||
path = _hermes_home / self._STUCK_LOOP_FILE
|
||||
try:
|
||||
counts = json.loads(path.read_text()) if path.exists() else {}
|
||||
except Exception:
|
||||
counts = {}
|
||||
|
||||
# Increment active sessions, remove inactive ones (loop broken)
|
||||
new_counts = {}
|
||||
for key in active_session_keys:
|
||||
new_counts[key] = counts.get(key, 0) + 1
|
||||
# Keep any entries that are still above 0 even if not active now
|
||||
# (they might become active again next restart)
|
||||
|
||||
try:
|
||||
path.write_text(json.dumps(new_counts))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _suspend_stuck_loop_sessions(self) -> int:
|
||||
"""Suspend sessions that have been active across too many restarts.
|
||||
|
||||
Returns the number of sessions suspended. Called on gateway startup
|
||||
AFTER suspend_recently_active() to catch the stuck-loop pattern:
|
||||
session loads → agent gets stuck → gateway restarts → repeat.
|
||||
"""
|
||||
import json
|
||||
|
||||
path = _hermes_home / self._STUCK_LOOP_FILE
|
||||
if not path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
counts = json.loads(path.read_text())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
suspended = 0
|
||||
stuck_keys = [k for k, v in counts.items() if v >= self._STUCK_LOOP_THRESHOLD]
|
||||
|
||||
for session_key in stuck_keys:
|
||||
try:
|
||||
entry = self.session_store._entries.get(session_key)
|
||||
if entry and not entry.suspended:
|
||||
entry.suspended = True
|
||||
suspended += 1
|
||||
logger.warning(
|
||||
"Auto-suspended stuck session %s (active across %d "
|
||||
"consecutive restarts — likely a stuck loop)",
|
||||
session_key[:30], counts[session_key],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if suspended:
|
||||
try:
|
||||
self.session_store._save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear the file — counters start fresh after suspension
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return suspended
|
||||
|
||||
def _clear_restart_failure_count(self, session_key: str) -> None:
|
||||
"""Clear the restart-failure counter for a session that completed OK.
|
||||
|
||||
Called after a successful agent turn to signal the loop is broken.
|
||||
"""
|
||||
import json
|
||||
|
||||
path = _hermes_home / self._STUCK_LOOP_FILE
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
counts = json.loads(path.read_text())
|
||||
if session_key in counts:
|
||||
del counts[session_key]
|
||||
if counts:
|
||||
path.write_text(json.dumps(counts))
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _launch_detached_restart_command(self) -> None:
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -1499,6 +1733,7 @@ class GatewayRunner:
|
||||
"WECOM_CALLBACK_ALLOWED_USERS",
|
||||
"WEIXIN_ALLOWED_USERS",
|
||||
"BLUEBUBBLES_ALLOWED_USERS",
|
||||
"QQ_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
@@ -1512,7 +1747,8 @@ class GatewayRunner:
|
||||
"WECOM_ALLOW_ALL_USERS",
|
||||
"WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
"WEIXIN_ALLOW_ALL_USERS",
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS")
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS",
|
||||
"QQ_ALLOW_ALL_USERS")
|
||||
)
|
||||
if not _any_allowlist and not _allow_all:
|
||||
logger.warning(
|
||||
@@ -1557,6 +1793,17 @@ class GatewayRunner:
|
||||
except Exception as e:
|
||||
logger.warning("Session suspension on startup failed: %s", e)
|
||||
|
||||
# Stuck-loop detection (#7536): if a session has been active across
|
||||
# 3+ consecutive restarts, it's probably stuck in a loop (the same
|
||||
# history keeps causing the agent to hang). Auto-suspend it so the
|
||||
# user gets a clean slate on the next message.
|
||||
try:
|
||||
stuck = self._suspend_stuck_loop_sessions()
|
||||
if stuck:
|
||||
logger.warning("Auto-suspended %d stuck-loop session(s)", stuck)
|
||||
except Exception as e:
|
||||
logger.debug("Stuck-loop detection failed: %s", e)
|
||||
|
||||
connected_count = 0
|
||||
enabled_platform_count = 0
|
||||
startup_nonretryable_errors: list[str] = []
|
||||
@@ -2016,6 +2263,10 @@ class GatewayRunner:
|
||||
self._running = False
|
||||
self._draining = True
|
||||
|
||||
# Notify all chats with active agents BEFORE draining.
|
||||
# Adapters are still connected here, so messages can be sent.
|
||||
await self._notify_active_sessions_of_shutdown()
|
||||
|
||||
timeout = self._restart_drain_timeout
|
||||
active_agents, timed_out = await self._drain_active_agents(timeout)
|
||||
if timed_out:
|
||||
@@ -2061,6 +2312,8 @@ class GatewayRunner:
|
||||
self._running_agents.clear()
|
||||
self._pending_messages.clear()
|
||||
self._pending_approvals.clear()
|
||||
if hasattr(self, '_busy_ack_ts'):
|
||||
self._busy_ack_ts.clear()
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Global cleanup: kill any remaining tool subprocesses not tied
|
||||
@@ -2086,12 +2339,31 @@ class GatewayRunner:
|
||||
|
||||
# Write a clean-shutdown marker so the next startup knows this
|
||||
# wasn't a crash. suspend_recently_active() only needs to run
|
||||
# after unexpected exits — graceful shutdowns already drain
|
||||
# active agents, so there's no stuck-session risk.
|
||||
try:
|
||||
(_hermes_home / ".clean_shutdown").touch()
|
||||
except Exception:
|
||||
pass
|
||||
# after unexpected exits. However, if the drain timed out and
|
||||
# agents were force-interrupted, their sessions may be in an
|
||||
# incomplete state (trailing tool response, no final assistant
|
||||
# message). Skip the marker in that case so the next startup
|
||||
# suspends those sessions — giving users a clean slate instead
|
||||
# of resuming a half-finished tool loop.
|
||||
if not timed_out:
|
||||
try:
|
||||
(_hermes_home / ".clean_shutdown").touch()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.info(
|
||||
"Skipping .clean_shutdown marker — drain timed out with "
|
||||
"interrupted agents; next startup will suspend recently "
|
||||
"active sessions."
|
||||
)
|
||||
|
||||
# Track sessions that were active at shutdown for stuck-loop
|
||||
# detection (#7536). On each restart, the counter increments
|
||||
# for sessions that were running. If a session hits the
|
||||
# threshold (3 consecutive restarts while active), the next
|
||||
# startup auto-suspends it — breaking the loop.
|
||||
if active_agents:
|
||||
self._increment_restart_failure_counts(set(active_agents.keys()))
|
||||
|
||||
if self._restart_requested and self._restart_via_service:
|
||||
self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
@@ -2255,8 +2527,15 @@ class GatewayRunner:
|
||||
return None
|
||||
return BlueBubblesAdapter(config)
|
||||
|
||||
elif platform == Platform.QQBOT:
|
||||
from gateway.platforms.qqbot import QQAdapter, check_qq_requirements
|
||||
if not check_qq_requirements():
|
||||
logger.warning("QQBot: aiohttp/httpx missing or QQ_APP_ID/QQ_CLIENT_SECRET not configured")
|
||||
return None
|
||||
return QQAdapter(config)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
@@ -2296,6 +2575,7 @@ class GatewayRunner:
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOWED_USERS",
|
||||
}
|
||||
platform_allow_all_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
@@ -2313,6 +2593,7 @@ class GatewayRunner:
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
|
||||
}
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
@@ -2517,6 +2798,7 @@ class GatewayRunner:
|
||||
)
|
||||
del self._running_agents[_quick_key]
|
||||
self._running_agents_ts.pop(_quick_key, None)
|
||||
self._busy_ack_ts.pop(_quick_key, None)
|
||||
|
||||
if _quick_key in self._running_agents:
|
||||
if event.get_command() == "status":
|
||||
@@ -2546,11 +2828,8 @@ class GatewayRunner:
|
||||
self._pending_messages.pop(_quick_key, None)
|
||||
if _quick_key in self._running_agents:
|
||||
del self._running_agents[_quick_key]
|
||||
# Mark session suspended so the next message starts fresh
|
||||
# instead of resuming the stuck context (#7536).
|
||||
self.session_store.suspend_session(_quick_key)
|
||||
logger.info("HARD STOP for session %s — suspended, session lock released", _quick_key[:20])
|
||||
return "⚡ Force-stopped. The session is suspended — your next message will start fresh."
|
||||
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20])
|
||||
return "⚡ Stopped. You can continue this session."
|
||||
|
||||
# /reset and /new must bypass the running-agent guard so they
|
||||
# actually dispatch as commands instead of being queued as user
|
||||
@@ -3585,6 +3864,12 @@ class GatewayRunner:
|
||||
_response_time, _api_calls, _resp_len,
|
||||
)
|
||||
|
||||
# Successful turn — clear any stuck-loop counter for this session.
|
||||
# This ensures the counter only accumulates across CONSECUTIVE
|
||||
# restarts where the session was active (never completed).
|
||||
if session_key:
|
||||
self._clear_restart_failure_count(session_key)
|
||||
|
||||
# Surface error details when the agent failed silently (final_response=None)
|
||||
if not response and agent_result.get("failed"):
|
||||
error_detail = agent_result.get("error", "unknown error")
|
||||
@@ -3691,14 +3976,11 @@ class GatewayRunner:
|
||||
# intermediate reasoning) so sessions can be resumed with full context
|
||||
# and transcripts are useful for debugging and training data.
|
||||
#
|
||||
# IMPORTANT: When the agent failed before producing any response
|
||||
# (e.g. context-overflow 400), do NOT persist the user's message.
|
||||
# IMPORTANT: When the agent failed (e.g. context-overflow 400,
|
||||
# compression exhausted), do NOT persist the user's message.
|
||||
# Persisting it would make the session even larger, causing the
|
||||
# same failure on the next attempt — an infinite loop. (#1630)
|
||||
agent_failed_early = (
|
||||
agent_result.get("failed")
|
||||
and not agent_result.get("final_response")
|
||||
)
|
||||
# same failure on the next attempt — an infinite loop. (#1630, #9893)
|
||||
agent_failed_early = bool(agent_result.get("failed"))
|
||||
if agent_failed_early:
|
||||
logger.info(
|
||||
"Skipping transcript persistence for failed request in "
|
||||
@@ -3706,6 +3988,24 @@ class GatewayRunner:
|
||||
session_entry.session_id,
|
||||
)
|
||||
|
||||
# When compression is exhausted, the session is permanently too
|
||||
# large to process. Auto-reset it so the next message starts
|
||||
# fresh instead of replaying the same oversized context in an
|
||||
# infinite fail loop. (#9893)
|
||||
if agent_result.get("compression_exhausted") and session_entry and session_key:
|
||||
logger.info(
|
||||
"Auto-resetting session %s after compression exhaustion.",
|
||||
session_entry.session_id,
|
||||
)
|
||||
self.session_store.reset_session(session_key)
|
||||
self._evict_cached_agent(session_key)
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
response = (response or "") + (
|
||||
"\n\n🔄 Session auto-reset — the conversation exceeded the "
|
||||
"maximum context size and could not be compressed further. "
|
||||
"Your next message will start a fresh session."
|
||||
)
|
||||
|
||||
ts = datetime.now().isoformat()
|
||||
|
||||
# If this is a fresh session (no history), write the full tool
|
||||
@@ -3813,6 +4113,8 @@ class GatewayRunner:
|
||||
_hist_len = len(history) if 'history' in locals() else 0
|
||||
if status_code == 401:
|
||||
status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials."
|
||||
elif status_code == 402:
|
||||
status_hint = " Your API balance or quota is exhausted. Check your provider dashboard."
|
||||
elif status_code == 429:
|
||||
# Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit
|
||||
_err_body = getattr(e, "response", None)
|
||||
@@ -3963,6 +4265,11 @@ class GatewayRunner:
|
||||
_cached = self._agent_cache.get(session_key)
|
||||
_old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None
|
||||
if _old_agent is not None:
|
||||
try:
|
||||
if hasattr(_old_agent, "shutdown_memory_provider"):
|
||||
_old_agent.shutdown_memory_provider()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(_old_agent, "close"):
|
||||
_old_agent.close()
|
||||
@@ -4120,9 +4427,7 @@ class GatewayRunner:
|
||||
only through normal command dispatch (no running agent) or as a
|
||||
fallback. Force-clean the session lock in all cases for safety.
|
||||
|
||||
When there IS a running/pending agent, the session is also marked
|
||||
as *suspended* so the next message starts a fresh session instead
|
||||
of resuming the stuck context (#7536).
|
||||
The session is preserved so the user can continue the conversation.
|
||||
"""
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
@@ -4133,17 +4438,15 @@ class GatewayRunner:
|
||||
# Force-clean the sentinel so the session is unlocked.
|
||||
if session_key in self._running_agents:
|
||||
del self._running_agents[session_key]
|
||||
self.session_store.suspend_session(session_key)
|
||||
logger.info("HARD STOP (pending) for session %s — suspended, sentinel cleared", session_key[:20])
|
||||
return "⚡ Force-stopped. The agent was still starting — your next message will start fresh."
|
||||
logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20])
|
||||
return "⚡ Stopped. The agent hadn't started yet — you can continue this session."
|
||||
if agent:
|
||||
agent.interrupt("Stop requested")
|
||||
# Force-clean the session lock so a truly hung agent doesn't
|
||||
# keep it locked forever.
|
||||
if session_key in self._running_agents:
|
||||
del self._running_agents[session_key]
|
||||
self.session_store.suspend_session(session_key)
|
||||
return "⚡ Force-stopped. Your next message will start a fresh session."
|
||||
return "⚡ Stopped. You can continue this session."
|
||||
else:
|
||||
return "No active task to stop."
|
||||
|
||||
@@ -6303,7 +6606,7 @@ class GatewayRunner:
|
||||
"""Handle /reload-mcp command -- disconnect and reconnect all MCP servers."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
|
||||
|
||||
# Capture old server names before shutdown
|
||||
with _lock:
|
||||
@@ -6476,7 +6779,7 @@ class GatewayRunner:
|
||||
Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP,
|
||||
Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX,
|
||||
Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK,
|
||||
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL,
|
||||
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL,
|
||||
})
|
||||
|
||||
async def _handle_debug_command(self, event: MessageEvent) -> str:
|
||||
@@ -7399,6 +7702,263 @@ class GatewayRunner:
|
||||
with _lock:
|
||||
self._agent_cache.pop(session_key, None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Proxy mode: forward messages to a remote Hermes API server
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_proxy_url(self) -> Optional[str]:
|
||||
"""Return the proxy URL if proxy mode is configured, else None.
|
||||
|
||||
Checks GATEWAY_PROXY_URL env var first (convenient for Docker),
|
||||
then ``gateway.proxy_url`` in config.yaml.
|
||||
"""
|
||||
url = os.getenv("GATEWAY_PROXY_URL", "").strip()
|
||||
if url:
|
||||
return url.rstrip("/")
|
||||
cfg = _load_gateway_config()
|
||||
url = (cfg.get("gateway") or {}).get("proxy_url", "").strip()
|
||||
if url:
|
||||
return url.rstrip("/")
|
||||
return None
|
||||
|
||||
async def _run_agent_via_proxy(
|
||||
self,
|
||||
message: str,
|
||||
context_prompt: str,
|
||||
history: List[Dict[str, Any]],
|
||||
source: "SessionSource",
|
||||
session_id: str,
|
||||
session_key: str = None,
|
||||
event_message_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Forward the message to a remote Hermes API server instead of
|
||||
running a local AIAgent.
|
||||
|
||||
When ``GATEWAY_PROXY_URL`` (or ``gateway.proxy_url`` in config.yaml)
|
||||
is set, the gateway becomes a thin relay: it handles platform I/O
|
||||
(encryption, threading, media) and delegates all agent work to the
|
||||
remote server via ``POST /v1/chat/completions`` with SSE streaming.
|
||||
|
||||
This lets a Docker container handle Matrix E2EE while the actual
|
||||
agent runs on the host with full access to local files, memory,
|
||||
skills, and a unified session store.
|
||||
"""
|
||||
try:
|
||||
from aiohttp import ClientSession as _AioClientSession, ClientTimeout
|
||||
except ImportError:
|
||||
return {
|
||||
"final_response": "⚠️ Proxy mode requires aiohttp. Install with: pip install aiohttp",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
proxy_url = self._get_proxy_url()
|
||||
if not proxy_url:
|
||||
return {
|
||||
"final_response": "⚠️ Proxy URL not configured (GATEWAY_PROXY_URL or gateway.proxy_url)",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip()
|
||||
|
||||
# Build messages in OpenAI chat format --------------------------
|
||||
#
|
||||
# The remote api_server can maintain session continuity via
|
||||
# X-Hermes-Session-Id, so it loads its own history. We only
|
||||
# need to send the current user message. If the remote has
|
||||
# no history for this session yet, include what we have locally
|
||||
# so the first exchange has context.
|
||||
#
|
||||
# We always include the current message. For history, send a
|
||||
# compact version (text-only user/assistant turns) — the remote
|
||||
# handles tool replay and system prompts.
|
||||
api_messages: List[Dict[str, str]] = []
|
||||
|
||||
if context_prompt:
|
||||
api_messages.append({"role": "system", "content": context_prompt})
|
||||
|
||||
for msg in history:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
if role in ("user", "assistant") and content:
|
||||
api_messages.append({"role": role, "content": content})
|
||||
|
||||
api_messages.append({"role": "user", "content": message})
|
||||
|
||||
# HTTP headers ---------------------------------------------------
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if proxy_key:
|
||||
headers["Authorization"] = f"Bearer {proxy_key}"
|
||||
if session_id:
|
||||
headers["X-Hermes-Session-Id"] = session_id
|
||||
|
||||
body = {
|
||||
"model": "hermes-agent",
|
||||
"messages": api_messages,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
# Set up platform streaming if available -------------------------
|
||||
_stream_consumer = None
|
||||
_scfg = getattr(getattr(self, "config", None), "streaming", None)
|
||||
if _scfg is None:
|
||||
from gateway.config import StreamingConfig
|
||||
_scfg = StreamingConfig()
|
||||
|
||||
platform_key = _platform_config_key(source.platform)
|
||||
user_config = _load_gateway_config()
|
||||
from gateway.display_config import resolve_display_setting
|
||||
_plat_streaming = resolve_display_setting(
|
||||
user_config, platform_key, "streaming"
|
||||
)
|
||||
_streaming_enabled = (
|
||||
_scfg.enabled and _scfg.transport != "off"
|
||||
if _plat_streaming is None
|
||||
else bool(_plat_streaming)
|
||||
)
|
||||
|
||||
if source.thread_id:
|
||||
_thread_metadata: Optional[Dict[str, Any]] = {"thread_id": source.thread_id}
|
||||
else:
|
||||
_thread_metadata = None
|
||||
|
||||
if _streaming_enabled:
|
||||
try:
|
||||
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
|
||||
from gateway.config import Platform
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter:
|
||||
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True)
|
||||
_effective_cursor = _scfg.cursor if _adapter_supports_edit else ""
|
||||
if source.platform == Platform.MATRIX:
|
||||
_effective_cursor = ""
|
||||
_consumer_cfg = StreamConsumerConfig(
|
||||
edit_interval=_scfg.edit_interval,
|
||||
buffer_threshold=_scfg.buffer_threshold,
|
||||
cursor=_effective_cursor,
|
||||
)
|
||||
_stream_consumer = GatewayStreamConsumer(
|
||||
adapter=_adapter,
|
||||
chat_id=source.chat_id,
|
||||
config=_consumer_cfg,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception as _sc_err:
|
||||
logger.debug("Proxy: could not set up stream consumer: %s", _sc_err)
|
||||
|
||||
# Run the stream consumer task in the background
|
||||
stream_task = None
|
||||
if _stream_consumer:
|
||||
stream_task = asyncio.create_task(_stream_consumer.run())
|
||||
|
||||
# Send typing indicator
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter:
|
||||
try:
|
||||
await _adapter.send_typing(source.chat_id, metadata=_thread_metadata)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Make the HTTP request with SSE streaming -----------------------
|
||||
full_response = ""
|
||||
_start = time.time()
|
||||
|
||||
try:
|
||||
_timeout = ClientTimeout(total=0, sock_read=1800)
|
||||
async with _AioClientSession(timeout=_timeout) as session:
|
||||
async with session.post(
|
||||
f"{proxy_url}/v1/chat/completions",
|
||||
json=body,
|
||||
headers=headers,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
error_text = await resp.text()
|
||||
logger.warning(
|
||||
"Proxy error (%d) from %s: %s",
|
||||
resp.status, proxy_url, error_text[:500],
|
||||
)
|
||||
return {
|
||||
"final_response": f"⚠️ Proxy error ({resp.status}): {error_text[:300]}",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
# Parse SSE stream
|
||||
buffer = ""
|
||||
async for chunk in resp.content.iter_any():
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
buffer += text
|
||||
|
||||
# Process complete SSE lines
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
choices = obj.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
full_response += content
|
||||
if _stream_consumer:
|
||||
_stream_consumer.on_delta(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Proxy connection error to %s: %s", proxy_url, e)
|
||||
if not full_response:
|
||||
return {
|
||||
"final_response": f"⚠️ Proxy connection error: {e}",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
# Partial response — return what we got
|
||||
finally:
|
||||
# Finalize stream consumer
|
||||
if _stream_consumer:
|
||||
_stream_consumer.finish()
|
||||
if stream_task:
|
||||
try:
|
||||
await asyncio.wait_for(stream_task, timeout=5.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
stream_task.cancel()
|
||||
|
||||
_elapsed = time.time() - _start
|
||||
logger.info(
|
||||
"proxy response: url=%s session=%s time=%.1fs response=%d chars",
|
||||
proxy_url, (session_id or "")[:20], _elapsed, len(full_response),
|
||||
)
|
||||
|
||||
return {
|
||||
"final_response": full_response or "(No response from remote agent)",
|
||||
"messages": [
|
||||
{"role": "user", "content": message},
|
||||
{"role": "assistant", "content": full_response},
|
||||
],
|
||||
"api_calls": 1,
|
||||
"tools": [],
|
||||
"history_offset": len(history),
|
||||
"session_id": session_id,
|
||||
"response_previewed": _stream_consumer is not None and bool(full_response),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_agent(
|
||||
self,
|
||||
message: str,
|
||||
@@ -7422,6 +7982,18 @@ class GatewayRunner:
|
||||
This is run in a thread pool to not block the event loop.
|
||||
Supports interruption via new messages.
|
||||
"""
|
||||
# ---- Proxy mode: delegate to remote API server ----
|
||||
if self._get_proxy_url():
|
||||
return await self._run_agent_via_proxy(
|
||||
message=message,
|
||||
context_prompt=context_prompt,
|
||||
history=history,
|
||||
source=source,
|
||||
session_id=session_id,
|
||||
session_key=session_key,
|
||||
event_message_id=event_message_id,
|
||||
)
|
||||
|
||||
from run_agent import AIAgent
|
||||
import queue
|
||||
|
||||
@@ -7816,13 +8388,19 @@ class GatewayRunner:
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter:
|
||||
# Platforms that don't support editing sent messages
|
||||
# (e.g. WeChat) must not show a cursor in intermediate
|
||||
# sends — the cursor would be permanently visible because
|
||||
# it can never be edited away. Use an empty cursor for
|
||||
# such platforms so streaming still delivers the final
|
||||
# response, just without the typing indicator.
|
||||
# (e.g. QQ, WeChat) should skip streaming entirely —
|
||||
# without edit support, the consumer sends a partial
|
||||
# first message that can never be updated, resulting in
|
||||
# duplicate messages (partial + final).
|
||||
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True)
|
||||
_effective_cursor = _scfg.cursor if _adapter_supports_edit else ""
|
||||
if not _adapter_supports_edit:
|
||||
raise RuntimeError("skip streaming for non-editable platform")
|
||||
_effective_cursor = _scfg.cursor
|
||||
# Some Matrix clients render the streaming cursor
|
||||
# as a visible tofu/white-box artifact. Keep
|
||||
# streaming text on Matrix, but suppress the cursor.
|
||||
if source.platform == Platform.MATRIX:
|
||||
_effective_cursor = ""
|
||||
_consumer_cfg = StreamConsumerConfig(
|
||||
edit_interval=_scfg.edit_interval,
|
||||
buffer_threshold=_scfg.buffer_threshold,
|
||||
@@ -7880,6 +8458,12 @@ class GatewayRunner:
|
||||
cached = _cache.get(session_key)
|
||||
if cached and cached[1] == _sig:
|
||||
agent = cached[0]
|
||||
# Reset activity timestamp so the inactivity timeout
|
||||
# handler doesn't see stale idle time from the previous
|
||||
# turn and immediately kill this agent. (#9051)
|
||||
agent._last_activity_ts = time.time()
|
||||
agent._last_activity_desc = "starting new turn (cached)"
|
||||
agent._api_call_count = 0
|
||||
logger.debug("Reusing cached agent for session %s", session_key)
|
||||
|
||||
if agent is None:
|
||||
@@ -8092,6 +8676,21 @@ class GatewayRunner:
|
||||
if _msn:
|
||||
message = _msn + "\n\n" + message
|
||||
|
||||
# Auto-continue: if the loaded history ends with a tool result,
|
||||
# the previous agent turn was interrupted mid-work (gateway
|
||||
# restart, crash, SIGTERM). Prepend a system note so the model
|
||||
# finishes processing the pending tool results before addressing
|
||||
# the user's new message. (#4493)
|
||||
if agent_history and agent_history[-1].get("role") == "tool":
|
||||
message = (
|
||||
"[System note: Your previous turn was interrupted before you could "
|
||||
"process the last tool result(s). The conversation history contains "
|
||||
"tool outputs you haven't responded to yet. Please finish processing "
|
||||
"those results and summarize what was accomplished, then address the "
|
||||
"user's new message below.]\n\n"
|
||||
+ message
|
||||
)
|
||||
|
||||
_approval_session_key = session_key or ""
|
||||
_approval_session_token = set_current_session_key(_approval_session_key)
|
||||
register_gateway_notify(_approval_session_key, _approval_notify_sync)
|
||||
@@ -8126,6 +8725,8 @@ class GatewayRunner:
|
||||
"final_response": error_msg,
|
||||
"messages": result.get("messages", []),
|
||||
"api_calls": result.get("api_calls", 0),
|
||||
"failed": result.get("failed", False),
|
||||
"compression_exhausted": result.get("compression_exhausted", False),
|
||||
"tools": tools_holder[0] or [],
|
||||
"history_offset": len(agent_history),
|
||||
"last_prompt_tokens": _last_prompt_toks,
|
||||
@@ -8630,15 +9231,11 @@ class GatewayRunner:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Stream consumer wait before queued message failed: %s", e)
|
||||
_response_previewed = bool(result.get("response_previewed"))
|
||||
_already_streamed = bool(
|
||||
_sc
|
||||
and (
|
||||
getattr(_sc, "final_response_sent", False)
|
||||
or (
|
||||
_response_previewed
|
||||
and getattr(_sc, "already_sent", False)
|
||||
)
|
||||
or getattr(_sc, "already_sent", False)
|
||||
)
|
||||
)
|
||||
first_response = result.get("final_response", "")
|
||||
@@ -8722,13 +9319,9 @@ class GatewayRunner:
|
||||
# them even if streaming had sent earlier partial output.
|
||||
_sc = stream_consumer_holder[0]
|
||||
if _sc and isinstance(response, dict) and not response.get("failed"):
|
||||
_response_previewed = bool(response.get("response_previewed"))
|
||||
if (
|
||||
getattr(_sc, "final_response_sent", False)
|
||||
or (
|
||||
_response_previewed
|
||||
and getattr(_sc, "already_sent", False)
|
||||
)
|
||||
or getattr(_sc, "already_sent", False)
|
||||
):
|
||||
response["already_sent"] = True
|
||||
|
||||
@@ -8903,8 +9496,41 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
|
||||
runner = GatewayRunner(config)
|
||||
|
||||
# Track whether a signal initiated the shutdown (vs. internal request).
|
||||
# When an unexpected SIGTERM kills the gateway, we exit non-zero so
|
||||
# systemd's Restart=on-failure revives the process. systemctl stop
|
||||
# is safe: systemd tracks stop-requested state independently of exit
|
||||
# code, so Restart= never fires for a deliberate stop.
|
||||
_signal_initiated_shutdown = False
|
||||
|
||||
# Set up signal handlers
|
||||
def shutdown_signal_handler():
|
||||
nonlocal _signal_initiated_shutdown
|
||||
_signal_initiated_shutdown = True
|
||||
logger.info("Received SIGTERM/SIGINT — initiating shutdown")
|
||||
# Diagnostic: log all hermes-related processes so we can identify
|
||||
# what triggered the signal (hermes update, hermes gateway restart,
|
||||
# a stale detached subprocess, etc.).
|
||||
try:
|
||||
import subprocess as _sp
|
||||
_ps = _sp.run(
|
||||
["ps", "aux"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
_hermes_procs = [
|
||||
line for line in _ps.stdout.splitlines()
|
||||
if ("hermes" in line.lower() or "gateway" in line.lower())
|
||||
and str(os.getpid()) not in line.split()[1:2] # exclude self
|
||||
]
|
||||
if _hermes_procs:
|
||||
logger.warning(
|
||||
"Shutdown diagnostic — other hermes processes running:\n %s",
|
||||
"\n ".join(_hermes_procs),
|
||||
)
|
||||
else:
|
||||
logger.info("Shutdown diagnostic — no other hermes processes found")
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.create_task(runner.stop())
|
||||
|
||||
def restart_signal_handler():
|
||||
@@ -8974,6 +9600,21 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
if runner.exit_code is not None:
|
||||
raise SystemExit(runner.exit_code)
|
||||
|
||||
# When a signal (SIGTERM/SIGINT) caused the shutdown and it wasn't a
|
||||
# planned restart (/restart, /update, SIGUSR1), exit non-zero so
|
||||
# systemd's Restart=on-failure revives the process. This covers:
|
||||
# - hermes update killing the gateway mid-work
|
||||
# - External kill commands
|
||||
# - WSL2/container runtime sending unexpected signals
|
||||
# systemctl stop is safe: systemd tracks "stop requested" state
|
||||
# independently of exit code, so Restart= never fires for it.
|
||||
if _signal_initiated_shutdown and not runner._restart_requested:
|
||||
logger.info(
|
||||
"Exiting with code 1 (signal-initiated shutdown without restart "
|
||||
"request) so systemd Restart=on-failure can revive the gateway."
|
||||
)
|
||||
return False # → sys.exit(1) in the caller
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import hashlib
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
+18
-2
@@ -266,9 +266,25 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
|
||||
|
||||
|
||||
def remove_pid_file() -> None:
|
||||
"""Remove the gateway PID file if it exists."""
|
||||
"""Remove the gateway PID file, but only if it belongs to this process.
|
||||
|
||||
During --replace handoffs, the old process's atexit handler can fire AFTER
|
||||
the new process has written its own PID file. Blindly removing the file
|
||||
would delete the new process's record, leaving the gateway running with no
|
||||
PID file (invisible to ``get_running_pid()``).
|
||||
"""
|
||||
try:
|
||||
_get_pid_path().unlink(missing_ok=True)
|
||||
path = _get_pid_path()
|
||||
record = _read_json_file(path)
|
||||
if record is not None:
|
||||
try:
|
||||
file_pid = int(record["pid"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
file_pid = None
|
||||
if file_pid is not None and file_pid != os.getpid():
|
||||
# PID file belongs to a different process — leave it alone.
|
||||
return
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+160
-1
@@ -64,6 +64,18 @@ class GatewayStreamConsumer:
|
||||
# progressive edits for the remainder of the stream.
|
||||
_MAX_FLOOD_STRIKES = 3
|
||||
|
||||
# Reasoning/thinking tags that models emit inline in content.
|
||||
# Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and
|
||||
# run_agent.py _strip_think_blocks() tag variants.
|
||||
_OPEN_THINK_TAGS = (
|
||||
"<REASONING_SCRATCHPAD>", "<think>", "<reasoning>",
|
||||
"<THINKING>", "<thinking>", "<thought>",
|
||||
)
|
||||
_CLOSE_THINK_TAGS = (
|
||||
"</REASONING_SCRATCHPAD>", "</think>", "</reasoning>",
|
||||
"</THINKING>", "</thinking>", "</thought>",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: Any,
|
||||
@@ -88,6 +100,10 @@ class GatewayStreamConsumer:
|
||||
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
||||
self._final_response_sent = False
|
||||
|
||||
# Think-block filter state (mirrors CLI's _stream_delta tag suppression)
|
||||
self._in_think_block = False
|
||||
self._think_buffer = ""
|
||||
|
||||
@property
|
||||
def already_sent(self) -> bool:
|
||||
"""True if at least one message was sent or edited during the run."""
|
||||
@@ -132,6 +148,112 @@ class GatewayStreamConsumer:
|
||||
"""Signal that the stream is complete."""
|
||||
self._queue.put(_DONE)
|
||||
|
||||
# ── Think-block filtering ────────────────────────────────────────
|
||||
# Models like MiniMax emit inline <think>...</think> blocks in their
|
||||
# content. The CLI's _stream_delta suppresses these via a state
|
||||
# machine; we do the same here so gateway users never see raw
|
||||
# reasoning tags. The agent also strips them from the final
|
||||
# response (run_agent.py _strip_think_blocks), but the stream
|
||||
# consumer sends intermediate edits before that stripping happens.
|
||||
|
||||
def _filter_and_accumulate(self, text: str) -> None:
|
||||
"""Add a text delta to the accumulated buffer, suppressing think blocks.
|
||||
|
||||
Uses a state machine that tracks whether we are inside a
|
||||
reasoning/thinking block. Text inside such blocks is silently
|
||||
discarded. Partial tags at buffer boundaries are held back in
|
||||
``_think_buffer`` until enough characters arrive to decide.
|
||||
"""
|
||||
buf = self._think_buffer + text
|
||||
self._think_buffer = ""
|
||||
|
||||
while buf:
|
||||
if self._in_think_block:
|
||||
# Look for the earliest closing tag
|
||||
best_idx = -1
|
||||
best_len = 0
|
||||
for tag in self._CLOSE_THINK_TAGS:
|
||||
idx = buf.find(tag)
|
||||
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
||||
best_idx = idx
|
||||
best_len = len(tag)
|
||||
|
||||
if best_len:
|
||||
# Found closing tag — discard block, process remainder
|
||||
self._in_think_block = False
|
||||
buf = buf[best_idx + best_len:]
|
||||
else:
|
||||
# No closing tag yet — hold tail that could be a
|
||||
# partial closing tag prefix, discard the rest.
|
||||
max_tag = max(len(t) for t in self._CLOSE_THINK_TAGS)
|
||||
self._think_buffer = buf[-max_tag:] if len(buf) > max_tag else buf
|
||||
return
|
||||
else:
|
||||
# Look for earliest opening tag at a block boundary
|
||||
# (start of text / preceded by newline + optional whitespace).
|
||||
# This prevents false positives when models *mention* tags
|
||||
# in prose (e.g. "the <think> tag is used for…").
|
||||
best_idx = -1
|
||||
best_len = 0
|
||||
for tag in self._OPEN_THINK_TAGS:
|
||||
search_start = 0
|
||||
while True:
|
||||
idx = buf.find(tag, search_start)
|
||||
if idx == -1:
|
||||
break
|
||||
# Block-boundary check (mirrors cli.py logic)
|
||||
if idx == 0:
|
||||
is_boundary = (
|
||||
not self._accumulated
|
||||
or self._accumulated.endswith("\n")
|
||||
)
|
||||
else:
|
||||
preceding = buf[:idx]
|
||||
last_nl = preceding.rfind("\n")
|
||||
if last_nl == -1:
|
||||
is_boundary = (
|
||||
(not self._accumulated
|
||||
or self._accumulated.endswith("\n"))
|
||||
and preceding.strip() == ""
|
||||
)
|
||||
else:
|
||||
is_boundary = preceding[last_nl + 1:].strip() == ""
|
||||
|
||||
if is_boundary and (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
|
||||
|
||||
if best_len:
|
||||
# Emit text before the tag, enter think block
|
||||
self._accumulated += buf[:best_idx]
|
||||
self._in_think_block = True
|
||||
buf = buf[best_idx + best_len:]
|
||||
else:
|
||||
# No opening tag — check for a partial tag at the tail
|
||||
held_back = 0
|
||||
for tag in self._OPEN_THINK_TAGS:
|
||||
for i in range(1, len(tag)):
|
||||
if buf.endswith(tag[:i]) and i > held_back:
|
||||
held_back = i
|
||||
if held_back:
|
||||
self._accumulated += buf[:-held_back]
|
||||
self._think_buffer = buf[-held_back:]
|
||||
else:
|
||||
self._accumulated += buf
|
||||
return
|
||||
|
||||
def _flush_think_buffer(self) -> None:
|
||||
"""Flush any held-back partial-tag buffer into accumulated text.
|
||||
|
||||
Called when the stream ends (got_done) so that partial text that
|
||||
was held back waiting for a possible opening tag is not lost.
|
||||
"""
|
||||
if self._think_buffer and not self._in_think_block:
|
||||
self._accumulated += self._think_buffer
|
||||
self._think_buffer = ""
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Async task that drains the queue and edits the platform message."""
|
||||
# Platform message length limit — leave room for cursor + formatting
|
||||
@@ -156,10 +278,16 @@ class GatewayStreamConsumer:
|
||||
if isinstance(item, tuple) and len(item) == 2 and item[0] is _COMMENTARY:
|
||||
commentary_text = item[1]
|
||||
break
|
||||
self._accumulated += item
|
||||
self._filter_and_accumulate(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Flush any held-back partial-tag buffer on stream end
|
||||
# so trailing text that was waiting for a potential open
|
||||
# tag is not lost.
|
||||
if got_done:
|
||||
self._flush_think_buffer()
|
||||
|
||||
# Decide whether to flush an edit
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_edit_time
|
||||
@@ -280,6 +408,14 @@ class GatewayStreamConsumer:
|
||||
await self._send_or_edit(self._accumulated)
|
||||
except Exception:
|
||||
pass
|
||||
# If we delivered any content before being cancelled, mark the
|
||||
# final response as sent so the gateway's already_sent check
|
||||
# doesn't trigger a duplicate message. The 5-second
|
||||
# stream_task timeout (gateway/run.py) can cancel us while
|
||||
# waiting on a slow Telegram API call — without this flag the
|
||||
# gateway falls through to the normal send path.
|
||||
if self._already_sent:
|
||||
self._final_response_sent = True
|
||||
except Exception as e:
|
||||
logger.error("Stream consumer error: %s", e)
|
||||
|
||||
@@ -491,8 +627,31 @@ class GatewayStreamConsumer:
|
||||
# Media files are delivered as native attachments after the stream
|
||||
# finishes (via _deliver_media_from_response in gateway/run.py).
|
||||
text = self._clean_for_display(text)
|
||||
# A bare streaming cursor is not meaningful user-visible content and
|
||||
# can render as a stray tofu/white-box message on some clients.
|
||||
visible_without_cursor = text
|
||||
if self.cfg.cursor:
|
||||
visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "")
|
||||
_visible_stripped = visible_without_cursor.strip()
|
||||
if not _visible_stripped:
|
||||
return True # cursor-only / whitespace-only update
|
||||
if not text.strip():
|
||||
return True # nothing to send is "success"
|
||||
# Guard: do not create a brand-new standalone message when the only
|
||||
# visible content is a handful of characters alongside the streaming
|
||||
# cursor. During rapid tool-calling the model often emits 1-2 tokens
|
||||
# before switching to tool calls; the resulting "X ▉" message risks
|
||||
# leaving the cursor permanently visible if the follow-up edit (to
|
||||
# strip the cursor on segment break) is rate-limited by the platform.
|
||||
# This was reported on Telegram, Matrix, and other clients where the
|
||||
# ▉ block character renders as a visible white box ("tofu").
|
||||
# Existing messages (edits) are unaffected — only first sends gated.
|
||||
_MIN_NEW_MSG_CHARS = 4
|
||||
if (self._message_id is None
|
||||
and self.cfg.cursor
|
||||
and self.cfg.cursor in text
|
||||
and len(_visible_stripped) < _MIN_NEW_MSG_CHARS):
|
||||
return True # too short for a standalone message — accumulate more
|
||||
try:
|
||||
if self._message_id is not None:
|
||||
if self._edit_supported:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Hermes Agent Has Had "Routines" Since March
|
||||
|
||||
Anthropic just announced [Claude Code Routines](https://claude.com/blog/introducing-routines-in-claude-code) — scheduled tasks, GitHub event triggers, and API-triggered agent runs. Bundled prompt + repo + connectors, running on their infrastructure.
|
||||
|
||||
It's a good feature. We shipped it two months ago.
|
||||
|
||||
---
|
||||
|
||||
## The Three Trigger Types — Side by Side
|
||||
|
||||
Claude Code Routines offers three ways to trigger an automation:
|
||||
|
||||
**1. Scheduled (cron)**
|
||||
> "Every night at 2am: pull the top bug from Linear, attempt a fix, and open a draft PR."
|
||||
|
||||
Hermes equivalent — works today:
|
||||
```bash
|
||||
hermes cron create "0 2 * * *" \
|
||||
"Pull the top bug from the issue tracker, attempt a fix, and open a draft PR." \
|
||||
--name "Nightly bug fix" \
|
||||
--deliver telegram
|
||||
```
|
||||
|
||||
**2. GitHub Events (webhook)**
|
||||
> "Flag PRs that touch the /auth-provider module and post to #auth-changes."
|
||||
|
||||
Hermes equivalent — works today:
|
||||
```bash
|
||||
hermes webhook subscribe auth-watch \
|
||||
--events "pull_request" \
|
||||
--prompt "PR #{pull_request.number}: {pull_request.title} by {pull_request.user.login}. Check if it touches the auth-provider module. If yes, summarize the changes." \
|
||||
--deliver slack
|
||||
```
|
||||
|
||||
**3. API Triggers**
|
||||
> "Read the alert payload, find the owning service, post a triage summary to #oncall."
|
||||
|
||||
Hermes equivalent — works today:
|
||||
```bash
|
||||
hermes webhook subscribe alert-triage \
|
||||
--prompt "Alert: {alert.name} — Severity: {alert.severity}. Find the owning service, investigate, and post a triage summary with proposed first steps." \
|
||||
--deliver slack
|
||||
```
|
||||
|
||||
Every use case in their blog post — backlog triage, docs drift, deploy verification, alert correlation, library porting, bespoke PR review — has a working Hermes implementation. No new features needed. It's been shipping since March 2026.
|
||||
|
||||
---
|
||||
|
||||
## What's Different
|
||||
|
||||
| | Claude Code Routines | Hermes Agent |
|
||||
|---|---|---|
|
||||
| **Scheduled tasks** | ✅ Schedule-based | ✅ Any cron expression + human-readable intervals |
|
||||
| **GitHub triggers** | ✅ PR, issue, push events | ✅ Any GitHub event via webhook subscriptions |
|
||||
| **API triggers** | ✅ POST to unique endpoint | ✅ POST to webhook routes with HMAC auth |
|
||||
| **MCP connectors** | ✅ Native connectors | ✅ Full MCP client support |
|
||||
| **Script pre-processing** | ❌ | ✅ Python scripts run before agent, inject context |
|
||||
| **Skill chaining** | ❌ | ✅ Load multiple skills per automation |
|
||||
| **Daily limit** | 5-25 runs/day | **Unlimited** |
|
||||
| **Model choice** | Claude only | **Any model** — Claude, GPT, Gemini, DeepSeek, Qwen, local |
|
||||
| **Delivery targets** | GitHub comments | Telegram, Discord, Slack, SMS, email, GitHub comments, webhooks, local files |
|
||||
| **Infrastructure** | Anthropic's servers | **Your infrastructure** — VPS, home server, laptop |
|
||||
| **Data residency** | Anthropic's cloud | **Your machines** |
|
||||
| **Cost** | Pro/Max/Team/Enterprise subscription | Your API key, your rates |
|
||||
| **Open source** | No | **Yes** — MIT license |
|
||||
|
||||
---
|
||||
|
||||
## Things Hermes Does That Routines Can't
|
||||
|
||||
### Script Injection
|
||||
|
||||
Run a Python script *before* the agent. The script's stdout becomes context. The script handles mechanical work (fetching, diffing, computing); the agent handles reasoning.
|
||||
|
||||
```bash
|
||||
hermes cron create "every 1h" \
|
||||
"If CHANGE DETECTED, summarize what changed. If NO_CHANGE, respond with [SILENT]." \
|
||||
--script ~/.hermes/scripts/watch-site.py \
|
||||
--name "Pricing monitor" \
|
||||
--deliver telegram
|
||||
```
|
||||
|
||||
The `[SILENT]` pattern means you only get notified when something actually happens. No spam.
|
||||
|
||||
### Multi-Skill Workflows
|
||||
|
||||
Chain specialized skills together. Each skill teaches the agent a specific capability, and the prompt ties them together.
|
||||
|
||||
```bash
|
||||
hermes cron create "0 8 * * *" \
|
||||
"Search arXiv for papers on language model reasoning. Save the top 3 as Obsidian notes." \
|
||||
--skills "arxiv,obsidian" \
|
||||
--name "Paper digest"
|
||||
```
|
||||
|
||||
### Deliver Anywhere
|
||||
|
||||
One automation, any destination:
|
||||
|
||||
```bash
|
||||
--deliver telegram # Telegram home channel
|
||||
--deliver discord # Discord home channel
|
||||
--deliver slack # Slack channel
|
||||
--deliver sms:+15551234567 # Text message
|
||||
--deliver telegram:-1001234567890:42 # Specific Telegram forum topic
|
||||
--deliver local # Save to file, no notification
|
||||
```
|
||||
|
||||
### Model-Agnostic
|
||||
|
||||
Your nightly triage can run on Claude. Your deploy verification can run on GPT. Your cost-sensitive monitors can run on DeepSeek or a local model. Same automation system, any backend.
|
||||
|
||||
---
|
||||
|
||||
## The Limits Tell the Story
|
||||
|
||||
Claude Code Routines: **5 routines per day** on Pro. **25 on Enterprise.** That's their ceiling.
|
||||
|
||||
Hermes has no daily limit. Run 500 automations a day if you want. The only constraint is your API budget, and you choose which models to use for which tasks.
|
||||
|
||||
A nightly backlog triage on Sonnet costs roughly $0.02-0.05. A monitoring check on DeepSeek costs fractions of a cent. You control the economics.
|
||||
|
||||
---
|
||||
|
||||
## Get Started
|
||||
|
||||
Hermes Agent is open source and free. The automation infrastructure — cron scheduler, webhook platform, skill system, multi-platform delivery — is built in.
|
||||
|
||||
```bash
|
||||
pip install hermes-agent
|
||||
hermes setup
|
||||
```
|
||||
|
||||
Set up a scheduled task in 30 seconds:
|
||||
```bash
|
||||
hermes cron create "0 9 * * 1" \
|
||||
"Generate a weekly AI news digest. Search the web for major announcements, trending repos, and notable papers. Keep it under 500 words with links." \
|
||||
--name "Weekly digest" \
|
||||
--deliver telegram
|
||||
```
|
||||
|
||||
Set up a GitHub webhook in 60 seconds:
|
||||
```bash
|
||||
hermes gateway setup # enable webhooks
|
||||
hermes webhook subscribe pr-review \
|
||||
--events "pull_request" \
|
||||
--prompt "Review PR #{pull_request.number}: {pull_request.title}" \
|
||||
--skills "github-code-review" \
|
||||
--deliver github_comment
|
||||
```
|
||||
|
||||
Full automation templates gallery: [hermes-agent.nousresearch.com/docs/guides/automation-templates](https://hermes-agent.nousresearch.com/docs/guides/automation-templates)
|
||||
|
||||
Documentation: [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com)
|
||||
|
||||
GitHub: [github.com/NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent)
|
||||
|
||||
---
|
||||
|
||||
*Hermes Agent is built by [Nous Research](https://nousresearch.com). Open source, model-agnostic, runs on your infrastructure.*
|
||||
+82
-35
@@ -167,6 +167,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
inference_base_url="https://api.moonshot.cn/v1",
|
||||
api_key_env_vars=("KIMI_CN_API_KEY",),
|
||||
),
|
||||
"arcee": ProviderConfig(
|
||||
id="arcee",
|
||||
name="Arcee AI",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://api.arcee.ai/api/v1",
|
||||
api_key_env_vars=("ARCEEAI_API_KEY",),
|
||||
base_url_env_var="ARCEE_BASE_URL",
|
||||
),
|
||||
"minimax": ProviderConfig(
|
||||
id="minimax",
|
||||
name="MiniMax",
|
||||
@@ -216,7 +224,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
),
|
||||
"ai-gateway": ProviderConfig(
|
||||
id="ai-gateway",
|
||||
name="AI Gateway",
|
||||
name="Vercel AI Gateway",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://ai-gateway.vercel.sh/v1",
|
||||
api_key_env_vars=("AI_GATEWAY_API_KEY",),
|
||||
@@ -375,13 +383,16 @@ def _resolve_api_key_provider_secret(
|
||||
# Z.AI has separate billing for general vs coding plans, and global vs China
|
||||
# endpoints. A key that works on one may return "Insufficient balance" on
|
||||
# another. We probe at setup time and store the working endpoint.
|
||||
# Each entry lists candidate models to try in order — newer coding plan accounts
|
||||
# may only have access to recent models (glm-5.1, glm-5v-turbo) while older
|
||||
# ones still use glm-4.7.
|
||||
|
||||
ZAI_ENDPOINTS = [
|
||||
# (id, base_url, default_model, label)
|
||||
("global", "https://api.z.ai/api/paas/v4", "glm-5", "Global"),
|
||||
("cn", "https://open.bigmodel.cn/api/paas/v4", "glm-5", "China"),
|
||||
("coding-global", "https://api.z.ai/api/coding/paas/v4", "glm-4.7", "Global (Coding Plan)"),
|
||||
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", "glm-4.7", "China (Coding Plan)"),
|
||||
# (id, base_url, probe_models, label)
|
||||
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
|
||||
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
|
||||
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
|
||||
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
|
||||
]
|
||||
|
||||
|
||||
@@ -389,35 +400,37 @@ def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str
|
||||
"""Probe z.ai endpoints to find one that accepts this API key.
|
||||
|
||||
Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the
|
||||
first working endpoint, or None if all fail.
|
||||
first working endpoint, or None if all fail. For endpoints with multiple
|
||||
candidate models, tries each in order and returns the first that succeeds.
|
||||
"""
|
||||
for ep_id, base_url, model, label in ZAI_ENDPOINTS:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"max_tokens": 1,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.debug("Z.AI endpoint probe: %s (%s) OK", ep_id, base_url)
|
||||
return {
|
||||
"id": ep_id,
|
||||
"base_url": base_url,
|
||||
"model": model,
|
||||
"label": label,
|
||||
}
|
||||
logger.debug("Z.AI endpoint probe: %s returned %s", ep_id, resp.status_code)
|
||||
except Exception as exc:
|
||||
logger.debug("Z.AI endpoint probe: %s failed: %s", ep_id, exc)
|
||||
for ep_id, base_url, probe_models, label in ZAI_ENDPOINTS:
|
||||
for model in probe_models:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"max_tokens": 1,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model)
|
||||
return {
|
||||
"id": ep_id,
|
||||
"base_url": base_url,
|
||||
"model": model,
|
||||
"label": label,
|
||||
}
|
||||
logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code)
|
||||
except Exception as exc:
|
||||
logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc)
|
||||
return None
|
||||
|
||||
|
||||
@@ -900,6 +913,7 @@ def resolve_provider(
|
||||
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
|
||||
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
|
||||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
"github": "copilot", "github-copilot": "copilot",
|
||||
@@ -2253,7 +2267,40 @@ def resolve_nous_runtime_credentials(
|
||||
# =============================================================================
|
||||
|
||||
def get_nous_auth_status() -> Dict[str, Any]:
|
||||
"""Status snapshot for `hermes status` output."""
|
||||
"""Status snapshot for `hermes status` output.
|
||||
|
||||
Checks the credential pool first (where the dashboard device-code flow
|
||||
and ``hermes auth`` store credentials), then falls back to the legacy
|
||||
auth-store provider state.
|
||||
"""
|
||||
# Check credential pool first — the dashboard device-code flow saves
|
||||
# here but may not have written to the auth store yet.
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("nous")
|
||||
if pool and pool.has_credentials():
|
||||
entry = pool.select()
|
||||
if entry is not None:
|
||||
access_token = (
|
||||
getattr(entry, "access_token", None)
|
||||
or getattr(entry, "runtime_api_key", "")
|
||||
)
|
||||
if access_token:
|
||||
return {
|
||||
"logged_in": True,
|
||||
"portal_base_url": getattr(entry, "portal_base_url", None)
|
||||
or getattr(entry, "base_url", None),
|
||||
"inference_base_url": getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "base_url", None),
|
||||
"access_token": access_token,
|
||||
"access_expires_at": getattr(entry, "expires_at", None),
|
||||
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
|
||||
"has_refresh_token": bool(getattr(entry, "refresh_token", None)),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to auth-store provider state
|
||||
state = get_provider_auth_state("nous")
|
||||
if not state:
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,6 @@ Pure display functions with no HermesCLI state dependency.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
@@ -75,12 +75,12 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
if not hasattr(cli, "_secret_deadline"):
|
||||
cli._secret_deadline = 0
|
||||
try:
|
||||
value = getpass.getpass(f"{prompt} (hidden, Enter to skip): ")
|
||||
value = getpass.getpass(f"{prompt} (hidden, ESC or empty Enter to skip): ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
value = ""
|
||||
|
||||
if not value:
|
||||
cprint(f"\n{_DIM} ⏭ Secret entry cancelled{_RST}")
|
||||
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
|
||||
return {
|
||||
"success": True,
|
||||
"reason": "cancelled",
|
||||
@@ -133,7 +133,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
cli._app.invalidate()
|
||||
|
||||
if not value:
|
||||
cprint(f"\n{_DIM} ⏭ Secret entry cancelled{_RST}")
|
||||
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
|
||||
return {
|
||||
"success": True,
|
||||
"reason": "cancelled",
|
||||
|
||||
@@ -6,7 +6,6 @@ mcp_config.py, and memory_setup.py.
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
+244
-82
@@ -12,6 +12,9 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -190,52 +193,6 @@ def resolve_command(name: str) -> CommandDef | None:
|
||||
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
|
||||
|
||||
|
||||
def rebuild_lookups() -> None:
|
||||
"""Rebuild all derived lookup dicts from the current COMMAND_REGISTRY.
|
||||
|
||||
Called after plugin commands are registered so they appear in help,
|
||||
autocomplete, gateway dispatch, Telegram menu, and Slack mapping.
|
||||
"""
|
||||
global GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
_COMMAND_LOOKUP.clear()
|
||||
_COMMAND_LOOKUP.update(_build_command_lookup())
|
||||
|
||||
COMMANDS.clear()
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not cmd.gateway_only:
|
||||
COMMANDS[f"/{cmd.name}"] = _build_description(cmd)
|
||||
for alias in cmd.aliases:
|
||||
COMMANDS[f"/{alias}"] = f"{cmd.description} (alias for /{cmd.name})"
|
||||
|
||||
COMMANDS_BY_CATEGORY.clear()
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not cmd.gateway_only:
|
||||
cat = COMMANDS_BY_CATEGORY.setdefault(cmd.category, {})
|
||||
cat[f"/{cmd.name}"] = COMMANDS[f"/{cmd.name}"]
|
||||
for alias in cmd.aliases:
|
||||
cat[f"/{alias}"] = COMMANDS[f"/{alias}"]
|
||||
|
||||
SUBCOMMANDS.clear()
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.subcommands:
|
||||
SUBCOMMANDS[f"/{cmd.name}"] = list(cmd.subcommands)
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
key = f"/{cmd.name}"
|
||||
if key in SUBCOMMANDS or not cmd.args_hint:
|
||||
continue
|
||||
m = _PIPE_SUBS_RE.search(cmd.args_hint)
|
||||
if m:
|
||||
SUBCOMMANDS[key] = m.group(0).split("|")
|
||||
|
||||
GATEWAY_KNOWN_COMMANDS = frozenset(
|
||||
name
|
||||
for cmd in COMMAND_REGISTRY
|
||||
if not cmd.cli_only or cmd.gateway_config_gate
|
||||
for name in (cmd.name, *cmd.aliases)
|
||||
)
|
||||
|
||||
|
||||
def _build_description(cmd: CommandDef) -> str:
|
||||
"""Build a CLI-facing description string including usage hint."""
|
||||
if cmd.args_hint:
|
||||
@@ -625,6 +582,116 @@ def discord_skill_commands(
|
||||
)
|
||||
|
||||
|
||||
def discord_skill_commands_by_category(
|
||||
reserved_names: set[str],
|
||||
) -> tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]], int]:
|
||||
"""Return skill entries organized by category for Discord ``/skill`` subcommand groups.
|
||||
|
||||
Skills whose directory is nested at least 2 levels under ``SKILLS_DIR``
|
||||
(e.g. ``creative/ascii-art/SKILL.md``) are grouped by their top-level
|
||||
category. Root-level skills (e.g. ``dogfood/SKILL.md``) are returned as
|
||||
*uncategorized* — the caller should register them as direct subcommands
|
||||
of the ``/skill`` group.
|
||||
|
||||
The same filtering as :func:`discord_skill_commands` is applied: hub
|
||||
skills excluded, per-platform disabled excluded, names clamped.
|
||||
|
||||
Returns:
|
||||
``(categories, uncategorized, hidden_count)``
|
||||
|
||||
- *categories*: ``{category_name: [(name, description, cmd_key), ...]}``
|
||||
- *uncategorized*: ``[(name, description, cmd_key), ...]``
|
||||
- *hidden_count*: skills dropped due to Discord group limits
|
||||
(25 subcommand groups, 25 subcommands per group)
|
||||
"""
|
||||
from pathlib import Path as _P
|
||||
|
||||
_platform_disabled: set[str] = set()
|
||||
try:
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
_platform_disabled = get_disabled_skill_names(platform="discord")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Collect raw skill data --------------------------------------------------
|
||||
categories: dict[str, list[tuple[str, str, str]]] = {}
|
||||
uncategorized: list[tuple[str, str, str]] = []
|
||||
_names_used: set[str] = set(reserved_names)
|
||||
hidden = 0
|
||||
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
_skills_dir = SKILLS_DIR.resolve()
|
||||
_hub_dir = (SKILLS_DIR / ".hub").resolve()
|
||||
skill_cmds = get_skill_commands()
|
||||
|
||||
for cmd_key in sorted(skill_cmds):
|
||||
info = skill_cmds[cmd_key]
|
||||
skill_path = info.get("skill_md_path", "")
|
||||
if not skill_path:
|
||||
continue
|
||||
sp = _P(skill_path).resolve()
|
||||
# Skip skills outside SKILLS_DIR or from the hub
|
||||
if not str(sp).startswith(str(_skills_dir)):
|
||||
continue
|
||||
if str(sp).startswith(str(_hub_dir)):
|
||||
continue
|
||||
|
||||
skill_name = info.get("name", "")
|
||||
if skill_name in _platform_disabled:
|
||||
continue
|
||||
|
||||
raw_name = cmd_key.lstrip("/")
|
||||
# Clamp to 32 chars (Discord limit)
|
||||
discord_name = raw_name[:32]
|
||||
if discord_name in _names_used:
|
||||
continue
|
||||
_names_used.add(discord_name)
|
||||
|
||||
desc = info.get("description", "")
|
||||
if len(desc) > 100:
|
||||
desc = desc[:97] + "..."
|
||||
|
||||
# Determine category from the relative path within SKILLS_DIR.
|
||||
# e.g. creative/ascii-art/SKILL.md → parts = ("creative", "ascii-art")
|
||||
try:
|
||||
rel = sp.parent.relative_to(_skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
parts = rel.parts
|
||||
if len(parts) >= 2:
|
||||
cat = parts[0]
|
||||
categories.setdefault(cat, []).append((discord_name, desc, cmd_key))
|
||||
else:
|
||||
uncategorized.append((discord_name, desc, cmd_key))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Enforce Discord limits: 25 subcommand groups, 25 subcommands each ------
|
||||
_MAX_GROUPS = 25
|
||||
_MAX_PER_GROUP = 25
|
||||
|
||||
trimmed_categories: dict[str, list[tuple[str, str, str]]] = {}
|
||||
group_count = 0
|
||||
for cat in sorted(categories):
|
||||
if group_count >= _MAX_GROUPS:
|
||||
hidden += len(categories[cat])
|
||||
continue
|
||||
entries = categories[cat][:_MAX_PER_GROUP]
|
||||
hidden += max(0, len(categories[cat]) - _MAX_PER_GROUP)
|
||||
trimmed_categories[cat] = entries
|
||||
group_count += 1
|
||||
|
||||
# Uncategorized skills also count against the 25 top-level limit
|
||||
remaining_slots = _MAX_GROUPS - group_count
|
||||
if len(uncategorized) > remaining_slots:
|
||||
hidden += len(uncategorized) - remaining_slots
|
||||
uncategorized = uncategorized[:remaining_slots]
|
||||
|
||||
return trimmed_categories, uncategorized, hidden
|
||||
|
||||
|
||||
def slack_subcommand_map() -> dict[str, str]:
|
||||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||||
|
||||
@@ -656,6 +723,10 @@ class SlashCommandCompleter(Completer):
|
||||
) -> None:
|
||||
self._skill_commands_provider = skill_commands_provider
|
||||
self._command_filter = command_filter
|
||||
# Cached project file list for fuzzy @ completions
|
||||
self._file_cache: list[str] = []
|
||||
self._file_cache_time: float = 0.0
|
||||
self._file_cache_cwd: str = ""
|
||||
|
||||
def _command_allowed(self, slash_command: str) -> bool:
|
||||
if self._command_filter is None:
|
||||
@@ -773,8 +844,7 @@ class SlashCommandCompleter(Completer):
|
||||
return None
|
||||
return word
|
||||
|
||||
@staticmethod
|
||||
def _context_completions(word: str, limit: int = 30):
|
||||
def _context_completions(self, word: str, limit: int = 30):
|
||||
"""Yield Claude Code-style @ context completions.
|
||||
|
||||
Bare ``@`` or ``@partial`` shows static references and matching
|
||||
@@ -840,46 +910,138 @@ class SlashCommandCompleter(Completer):
|
||||
count += 1
|
||||
return
|
||||
|
||||
# Bare @ or @partial — show matching files/folders from cwd
|
||||
# Bare @ or @partial — fuzzy project-wide file search
|
||||
query = word[1:] # strip the @
|
||||
if not query:
|
||||
search_dir, match_prefix = ".", ""
|
||||
else:
|
||||
expanded = os.path.expanduser(query)
|
||||
if expanded.endswith("/"):
|
||||
search_dir, match_prefix = expanded, ""
|
||||
else:
|
||||
search_dir = os.path.dirname(expanded) or "."
|
||||
match_prefix = os.path.basename(expanded)
|
||||
yield from self._fuzzy_file_completions(word, query, limit)
|
||||
|
||||
try:
|
||||
entries = os.listdir(search_dir)
|
||||
except OSError:
|
||||
def _get_project_files(self) -> list[str]:
|
||||
"""Return cached list of project files (refreshed every 5s)."""
|
||||
cwd = os.getcwd()
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._file_cache
|
||||
and self._file_cache_cwd == cwd
|
||||
and now - self._file_cache_time < 5.0
|
||||
):
|
||||
return self._file_cache
|
||||
|
||||
files: list[str] = []
|
||||
# Try rg first (fast, respects .gitignore), then fd, then find.
|
||||
for cmd in [
|
||||
["rg", "--files", "--sortr=modified", cwd],
|
||||
["rg", "--files", cwd],
|
||||
["fd", "--type", "f", "--base-directory", cwd],
|
||||
]:
|
||||
tool = cmd[0]
|
||||
if not shutil.which(tool):
|
||||
continue
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=2,
|
||||
cwd=cwd,
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
raw = proc.stdout.strip().split("\n")
|
||||
# Store relative paths
|
||||
for p in raw[:5000]:
|
||||
rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p
|
||||
files.append(rel)
|
||||
break
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
continue
|
||||
|
||||
self._file_cache = files
|
||||
self._file_cache_time = now
|
||||
self._file_cache_cwd = cwd
|
||||
return files
|
||||
|
||||
@staticmethod
|
||||
def _score_path(filepath: str, query: str) -> int:
|
||||
"""Score a file path against a fuzzy query. Higher = better match."""
|
||||
if not query:
|
||||
return 1 # show everything when query is empty
|
||||
|
||||
filename = os.path.basename(filepath)
|
||||
lower_file = filename.lower()
|
||||
lower_path = filepath.lower()
|
||||
lower_q = query.lower()
|
||||
|
||||
# Exact filename match
|
||||
if lower_file == lower_q:
|
||||
return 100
|
||||
# Filename starts with query
|
||||
if lower_file.startswith(lower_q):
|
||||
return 80
|
||||
# Filename contains query as substring
|
||||
if lower_q in lower_file:
|
||||
return 60
|
||||
# Full path contains query
|
||||
if lower_q in lower_path:
|
||||
return 40
|
||||
# Initials / abbreviation match: e.g. "fo" matches "file_operations"
|
||||
# Check if query chars appear in order in filename
|
||||
qi = 0
|
||||
for c in lower_file:
|
||||
if qi < len(lower_q) and c == lower_q[qi]:
|
||||
qi += 1
|
||||
if qi == len(lower_q):
|
||||
# Bonus if matches land on word boundaries (after _, -, /, .)
|
||||
boundary_hits = 0
|
||||
qi = 0
|
||||
prev = "_" # treat start as boundary
|
||||
for c in lower_file:
|
||||
if qi < len(lower_q) and c == lower_q[qi]:
|
||||
if prev in "_-./":
|
||||
boundary_hits += 1
|
||||
qi += 1
|
||||
prev = c
|
||||
if boundary_hits >= len(lower_q) * 0.5:
|
||||
return 35
|
||||
return 25
|
||||
return 0
|
||||
|
||||
def _fuzzy_file_completions(self, word: str, query: str, limit: int = 20):
|
||||
"""Yield fuzzy file completions for bare @query."""
|
||||
files = self._get_project_files()
|
||||
|
||||
if not query:
|
||||
# No query — show recently modified files (already sorted by mtime)
|
||||
for fp in files[:limit]:
|
||||
is_dir = fp.endswith("/")
|
||||
filename = os.path.basename(fp)
|
||||
kind = "folder" if is_dir else "file"
|
||||
meta = "dir" if is_dir else _file_size_label(
|
||||
os.path.join(os.getcwd(), fp)
|
||||
)
|
||||
yield Completion(
|
||||
f"@{kind}:{fp}",
|
||||
start_position=-len(word),
|
||||
display=filename,
|
||||
display_meta=meta,
|
||||
)
|
||||
return
|
||||
|
||||
count = 0
|
||||
prefix_lower = match_prefix.lower()
|
||||
for entry in sorted(entries):
|
||||
if match_prefix and not entry.lower().startswith(prefix_lower):
|
||||
continue
|
||||
if entry.startswith("."):
|
||||
continue # skip hidden files in bare @ mode
|
||||
if count >= limit:
|
||||
break
|
||||
full_path = os.path.join(search_dir, entry)
|
||||
is_dir = os.path.isdir(full_path)
|
||||
display_path = os.path.relpath(full_path)
|
||||
suffix = "/" if is_dir else ""
|
||||
# Score and rank
|
||||
scored = []
|
||||
for fp in files:
|
||||
s = self._score_path(fp, query)
|
||||
if s > 0:
|
||||
scored.append((s, fp))
|
||||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||||
|
||||
for _, fp in scored[:limit]:
|
||||
is_dir = fp.endswith("/")
|
||||
filename = os.path.basename(fp)
|
||||
kind = "folder" if is_dir else "file"
|
||||
meta = "dir" if is_dir else _file_size_label(full_path)
|
||||
completion = f"@{kind}:{display_path}{suffix}"
|
||||
yield Completion(
|
||||
completion,
|
||||
start_position=-len(word),
|
||||
display=entry + suffix,
|
||||
display_meta=meta,
|
||||
meta = "dir" if is_dir else _file_size_label(
|
||||
os.path.join(os.getcwd(), fp)
|
||||
)
|
||||
yield Completion(
|
||||
f"@{kind}:{fp}",
|
||||
start_position=-len(word),
|
||||
display=filename,
|
||||
display_meta=f"{fp} {meta}" if meta else fp,
|
||||
)
|
||||
count += 1
|
||||
|
||||
def _model_completions(self, sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /model from config aliases + built-in aliases."""
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Shell completion script generation for hermes CLI.
|
||||
|
||||
Walks the live argparse parser tree to generate accurate, always-up-to-date
|
||||
completion scripts — no hardcoded subcommand lists, no extra dependencies.
|
||||
|
||||
Supports bash, zsh, and fish.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]:
|
||||
"""Recursively extract subcommands and flags from a parser.
|
||||
|
||||
Uses _SubParsersAction._choices_actions to get canonical names (no aliases)
|
||||
along with their help text.
|
||||
"""
|
||||
flags: list[str] = []
|
||||
subcommands: dict[str, Any] = {}
|
||||
|
||||
for action in parser._actions:
|
||||
if isinstance(action, argparse._SubParsersAction):
|
||||
# _choices_actions has one entry per canonical name; aliases are
|
||||
# omitted, which keeps completion lists clean.
|
||||
seen: set[str] = set()
|
||||
for pseudo in action._choices_actions:
|
||||
name = pseudo.dest
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
subparser = action.choices.get(name)
|
||||
if subparser is None:
|
||||
continue
|
||||
info = _walk(subparser)
|
||||
info["help"] = _clean(pseudo.help or "")
|
||||
subcommands[name] = info
|
||||
elif action.option_strings:
|
||||
flags.extend(o for o in action.option_strings if o.startswith("-"))
|
||||
|
||||
return {"flags": flags, "subcommands": subcommands}
|
||||
|
||||
|
||||
def _clean(text: str, maxlen: int = 60) -> str:
|
||||
"""Strip shell-unsafe characters and truncate."""
|
||||
return text.replace("'", "").replace('"', "").replace("\\", "")[:maxlen]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_bash(parser: argparse.ArgumentParser) -> str:
|
||||
tree = _walk(parser)
|
||||
top_cmds = " ".join(sorted(tree["subcommands"]))
|
||||
|
||||
cases: list[str] = []
|
||||
for cmd in sorted(tree["subcommands"]):
|
||||
info = tree["subcommands"][cmd]
|
||||
if cmd == "profile" and info["subcommands"]:
|
||||
# Profile subcommand: complete actions, then profile names for
|
||||
# actions that accept a profile argument.
|
||||
subcmds = " ".join(sorted(info["subcommands"]))
|
||||
profile_actions = "use delete show alias rename export"
|
||||
cases.append(
|
||||
f" profile)\n"
|
||||
f" case \"$prev\" in\n"
|
||||
f" profile)\n"
|
||||
f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n"
|
||||
f" return\n"
|
||||
f" ;;\n"
|
||||
f" {profile_actions.replace(' ', '|')})\n"
|
||||
f" COMPREPLY=($(compgen -W \"$(_hermes_profiles)\" -- \"$cur\"))\n"
|
||||
f" return\n"
|
||||
f" ;;\n"
|
||||
f" esac\n"
|
||||
f" ;;"
|
||||
)
|
||||
elif info["subcommands"]:
|
||||
subcmds = " ".join(sorted(info["subcommands"]))
|
||||
cases.append(
|
||||
f" {cmd})\n"
|
||||
f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n"
|
||||
f" return\n"
|
||||
f" ;;"
|
||||
)
|
||||
elif info["flags"]:
|
||||
flags = " ".join(info["flags"])
|
||||
cases.append(
|
||||
f" {cmd})\n"
|
||||
f" COMPREPLY=($(compgen -W \"{flags}\" -- \"$cur\"))\n"
|
||||
f" return\n"
|
||||
f" ;;"
|
||||
)
|
||||
|
||||
cases_str = "\n".join(cases)
|
||||
|
||||
return f"""# Hermes Agent bash completion
|
||||
# Add to ~/.bashrc:
|
||||
# eval "$(hermes completion bash)"
|
||||
|
||||
_hermes_profiles() {{
|
||||
local profiles_dir="$HOME/.hermes/profiles"
|
||||
local profiles="default"
|
||||
if [ -d "$profiles_dir" ]; then
|
||||
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
|
||||
fi
|
||||
echo "$profiles"
|
||||
}}
|
||||
|
||||
_hermes_completion() {{
|
||||
local cur prev
|
||||
COMPREPLY=()
|
||||
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
||||
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
|
||||
|
||||
# Complete profile names after -p / --profile
|
||||
if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $COMP_CWORD -ge 2 ]]; then
|
||||
case "${{COMP_WORDS[1]}}" in
|
||||
{cases_str}
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ $COMP_CWORD -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "{top_cmds}" -- "$cur"))
|
||||
fi
|
||||
}}
|
||||
|
||||
complete -F _hermes_completion hermes
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zsh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_zsh(parser: argparse.ArgumentParser) -> str:
|
||||
tree = _walk(parser)
|
||||
|
||||
top_cmds_lines: list[str] = []
|
||||
for cmd in sorted(tree["subcommands"]):
|
||||
help_text = _clean(tree["subcommands"][cmd].get("help", ""))
|
||||
top_cmds_lines.append(f" '{cmd}:{help_text}'")
|
||||
top_cmds_str = "\n".join(top_cmds_lines)
|
||||
|
||||
sub_cases: list[str] = []
|
||||
for cmd in sorted(tree["subcommands"]):
|
||||
info = tree["subcommands"][cmd]
|
||||
if not info["subcommands"]:
|
||||
continue
|
||||
if cmd == "profile":
|
||||
# Profile subcommand: complete actions, then profile names for
|
||||
# actions that accept a profile argument.
|
||||
sub_lines: list[str] = []
|
||||
for sc in sorted(info["subcommands"]):
|
||||
sh = _clean(info["subcommands"][sc].get("help", ""))
|
||||
sub_lines.append(f" '{sc}:{sh}'")
|
||||
sub_str = "\n".join(sub_lines)
|
||||
sub_cases.append(
|
||||
f" profile)\n"
|
||||
f" case ${{line[2]}} in\n"
|
||||
f" use|delete|show|alias|rename|export)\n"
|
||||
f" _hermes_profiles\n"
|
||||
f" ;;\n"
|
||||
f" *)\n"
|
||||
f" local -a profile_cmds\n"
|
||||
f" profile_cmds=(\n"
|
||||
f"{sub_str}\n"
|
||||
f" )\n"
|
||||
f" _describe 'profile command' profile_cmds\n"
|
||||
f" ;;\n"
|
||||
f" esac\n"
|
||||
f" ;;"
|
||||
)
|
||||
else:
|
||||
sub_lines = []
|
||||
for sc in sorted(info["subcommands"]):
|
||||
sh = _clean(info["subcommands"][sc].get("help", ""))
|
||||
sub_lines.append(f" '{sc}:{sh}'")
|
||||
sub_str = "\n".join(sub_lines)
|
||||
safe = cmd.replace("-", "_")
|
||||
sub_cases.append(
|
||||
f" {cmd})\n"
|
||||
f" local -a {safe}_cmds\n"
|
||||
f" {safe}_cmds=(\n"
|
||||
f"{sub_str}\n"
|
||||
f" )\n"
|
||||
f" _describe '{cmd} command' {safe}_cmds\n"
|
||||
f" ;;"
|
||||
)
|
||||
sub_cases_str = "\n".join(sub_cases)
|
||||
|
||||
return f"""#compdef hermes
|
||||
# Hermes Agent zsh completion
|
||||
# Add to ~/.zshrc:
|
||||
# eval "$(hermes completion zsh)"
|
||||
|
||||
_hermes_profiles() {{
|
||||
local -a profiles
|
||||
profiles=(default)
|
||||
if [[ -d "$HOME/.hermes/profiles" ]]; then
|
||||
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
|
||||
fi
|
||||
_describe 'profile' profiles
|
||||
}}
|
||||
|
||||
_hermes() {{
|
||||
local context state line
|
||||
typeset -A opt_args
|
||||
|
||||
_arguments -C \\
|
||||
'(-h --help){{-h,--help}}[Show help and exit]' \\
|
||||
'(-V --version){{-V,--version}}[Show version and exit]' \\
|
||||
'(-p --profile){{-p,--profile}}[Profile name]:profile:_hermes_profiles' \\
|
||||
'1:command:->commands' \\
|
||||
'*::arg:->args'
|
||||
|
||||
case $state in
|
||||
commands)
|
||||
local -a subcmds
|
||||
subcmds=(
|
||||
{top_cmds_str}
|
||||
)
|
||||
_describe 'hermes command' subcmds
|
||||
;;
|
||||
args)
|
||||
case ${{line[1]}} in
|
||||
{sub_cases_str}
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}}
|
||||
|
||||
_hermes "$@"
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fish
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_fish(parser: argparse.ArgumentParser) -> str:
|
||||
tree = _walk(parser)
|
||||
top_cmds = sorted(tree["subcommands"])
|
||||
top_cmds_str = " ".join(top_cmds)
|
||||
|
||||
lines: list[str] = [
|
||||
"# Hermes Agent fish completion",
|
||||
"# Add to your config:",
|
||||
"# hermes completion fish | source",
|
||||
"",
|
||||
"# Helper: list available profiles",
|
||||
"function __hermes_profiles",
|
||||
" echo default",
|
||||
" if test -d $HOME/.hermes/profiles",
|
||||
" ls $HOME/.hermes/profiles 2>/dev/null",
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
"# Disable file completion by default",
|
||||
"complete -c hermes -f",
|
||||
"",
|
||||
"# Complete profile names after -p / --profile",
|
||||
"complete -c hermes -f -s p -l profile"
|
||||
" -d 'Profile name' -xa '(__hermes_profiles)'",
|
||||
"",
|
||||
"# Top-level subcommands",
|
||||
]
|
||||
|
||||
for cmd in top_cmds:
|
||||
info = tree["subcommands"][cmd]
|
||||
help_text = _clean(info.get("help", ""))
|
||||
lines.append(
|
||||
f"complete -c hermes -f "
|
||||
f"-n 'not __fish_seen_subcommand_from {top_cmds_str}' "
|
||||
f"-a {cmd} -d '{help_text}'"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("# Subcommand completions")
|
||||
|
||||
profile_name_actions = {"use", "delete", "show", "alias", "rename", "export"}
|
||||
|
||||
for cmd in top_cmds:
|
||||
info = tree["subcommands"][cmd]
|
||||
if not info["subcommands"]:
|
||||
continue
|
||||
lines.append(f"# {cmd}")
|
||||
for sc in sorted(info["subcommands"]):
|
||||
sinfo = info["subcommands"][sc]
|
||||
sh = _clean(sinfo.get("help", ""))
|
||||
lines.append(
|
||||
f"complete -c hermes -f "
|
||||
f"-n '__fish_seen_subcommand_from {cmd}' "
|
||||
f"-a {sc} -d '{sh}'"
|
||||
)
|
||||
# For profile subcommand, complete profile names for relevant actions
|
||||
if cmd == "profile":
|
||||
for action in sorted(profile_name_actions):
|
||||
lines.append(
|
||||
f"complete -c hermes -f "
|
||||
f"-n '__fish_seen_subcommand_from {action}; "
|
||||
f"and __fish_seen_subcommand_from profile' "
|
||||
f"-a '(__hermes_profiles)' -d 'Profile name'"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
+128
-2
@@ -45,6 +45,9 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
|
||||
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
|
||||
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
|
||||
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME",
|
||||
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
|
||||
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
@@ -824,6 +827,22 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"ARCEEAI_API_KEY": {
|
||||
"description": "Arcee AI API key",
|
||||
"prompt": "Arcee AI API key",
|
||||
"url": "https://chat.arcee.ai/",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"ARCEE_BASE_URL": {
|
||||
"description": "Arcee AI base URL override",
|
||||
"prompt": "Arcee base URL (leave empty for default)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"MINIMAX_API_KEY": {
|
||||
"description": "MiniMax API key (international)",
|
||||
"prompt": "MiniMax API key",
|
||||
@@ -1176,7 +1195,7 @@ OPTIONAL_ENV_VARS = {
|
||||
"SLACK_BOT_TOKEN": {
|
||||
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
|
||||
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
|
||||
"im:history, im:read, im:write, users:read, files:write",
|
||||
"im:history, im:read, im:write, users:read, files:read, files:write",
|
||||
"prompt": "Slack Bot Token (xoxb-...)",
|
||||
"url": "https://api.slack.com/apps",
|
||||
"password": True,
|
||||
@@ -1315,6 +1334,53 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all BlueBubbles users without allowlist",
|
||||
"prompt": "Allow All BlueBubbles Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_APP_ID": {
|
||||
"description": "QQ Bot App ID from QQ Open Platform (q.qq.com)",
|
||||
"prompt": "QQ App ID",
|
||||
"url": "https://q.qq.com",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_CLIENT_SECRET": {
|
||||
"description": "QQ Bot Client Secret from QQ Open Platform",
|
||||
"prompt": "QQ Client Secret",
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_ALLOWED_USERS": {
|
||||
"description": "Comma-separated QQ user IDs allowed to use the bot",
|
||||
"prompt": "QQ Allowed Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_GROUP_ALLOWED_USERS": {
|
||||
"description": "Comma-separated QQ group IDs allowed to interact with the bot",
|
||||
"prompt": "QQ Group Allowed Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all QQ users without an allowlist (true/false)",
|
||||
"prompt": "Allow All QQ Users",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_HOME_CHANNEL": {
|
||||
"description": "Default QQ channel/group for cron delivery and notifications",
|
||||
"prompt": "QQ Home Channel",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_HOME_CHANNEL_NAME": {
|
||||
"description": "Display name for the QQ home channel",
|
||||
"prompt": "QQ Home Channel Name",
|
||||
"category": "messaging",
|
||||
},
|
||||
"QQ_SANDBOX": {
|
||||
"description": "Enable QQ sandbox mode for development testing (true/false)",
|
||||
"prompt": "QQ Sandbox Mode",
|
||||
"category": "messaging",
|
||||
},
|
||||
"GATEWAY_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
|
||||
"prompt": "Allow all users (true/false)",
|
||||
@@ -1363,6 +1429,22 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"GATEWAY_PROXY_URL": {
|
||||
"description": "URL of a remote Hermes API server to forward messages to (proxy mode). When set, the gateway handles platform I/O only — all agent work is delegated to the remote server. Use for Docker E2EE containers that relay to a host agent. Also configurable via gateway.proxy_url in config.yaml.",
|
||||
"prompt": "Remote Hermes API server URL (e.g. http://192.168.1.100:8642)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"GATEWAY_PROXY_KEY": {
|
||||
"description": "Bearer token for authenticating with the remote Hermes API server (proxy mode). Must match the API_SERVER_KEY on the remote host.",
|
||||
"prompt": "Remote API server auth key",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"WEBHOOK_ENABLED": {
|
||||
"description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
|
||||
"prompt": "Enable webhooks (true/false)",
|
||||
@@ -1656,7 +1738,8 @@ def get_compatible_custom_providers(
|
||||
provider_key = str(entry.get("provider_key", "") or "").strip().lower()
|
||||
name = str(entry.get("name", "") or "").strip().lower()
|
||||
base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower()
|
||||
pair = (name, base_url)
|
||||
model = str(entry.get("model", "") or "").strip().lower()
|
||||
pair = (name, base_url, model)
|
||||
|
||||
if provider_key and provider_key in seen_provider_keys:
|
||||
return
|
||||
@@ -2683,6 +2766,47 @@ def sanitize_env_file() -> int:
|
||||
return fixes
|
||||
|
||||
|
||||
def _check_non_ascii_credential(key: str, value: str) -> str:
|
||||
"""Warn and strip non-ASCII characters from credential values.
|
||||
|
||||
API keys and tokens must be pure ASCII — they are sent as HTTP header
|
||||
values which httpx/httpcore encode as ASCII. Non-ASCII characters
|
||||
(commonly introduced by copy-pasting from rich-text editors or PDFs
|
||||
that substitute lookalike Unicode glyphs for ASCII letters) cause
|
||||
``UnicodeEncodeError: 'ascii' codec can't encode character`` at
|
||||
request time.
|
||||
|
||||
Returns the sanitized (ASCII-only) value. Prints a warning if any
|
||||
non-ASCII characters were found and removed.
|
||||
"""
|
||||
try:
|
||||
value.encode("ascii")
|
||||
return value # all ASCII — nothing to do
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
|
||||
# Build a readable list of the offending characters
|
||||
bad_chars: list[str] = []
|
||||
for i, ch in enumerate(value):
|
||||
if ord(ch) > 127:
|
||||
bad_chars.append(f" position {i}: {ch!r} (U+{ord(ch):04X})")
|
||||
sanitized = value.encode("ascii", errors="ignore").decode("ascii")
|
||||
|
||||
import sys
|
||||
print(
|
||||
f"\n Warning: {key} contains non-ASCII characters that will break API requests.\n"
|
||||
f" This usually happens when copy-pasting from a PDF, rich-text editor,\n"
|
||||
f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n"
|
||||
f"\n"
|
||||
+ "\n".join(f" {line}" for line in bad_chars[:5])
|
||||
+ ("\n ... and more" if len(bad_chars) > 5 else "")
|
||||
+ f"\n\n The non-ASCII characters have been stripped automatically.\n"
|
||||
f" If authentication fails, re-copy the key from the provider's dashboard.\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def save_env_value(key: str, value: str):
|
||||
"""Save or update a value in ~/.hermes/.env."""
|
||||
if is_managed():
|
||||
@@ -2691,6 +2815,8 @@ def save_env_value(key: str, value: str):
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
value = value.replace("\n", "").replace("\r", "")
|
||||
# API keys / tokens must be ASCII — strip non-ASCII with a warning.
|
||||
value = _check_non_ascii_credential(key, value)
|
||||
ensure_hermes_home()
|
||||
env_path = get_env_path()
|
||||
|
||||
|
||||
+86
-3
@@ -8,6 +8,7 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_constants import display_hermes_home
|
||||
@@ -42,6 +43,7 @@ _PROVIDER_ENV_HINTS = (
|
||||
"ZAI_API_KEY",
|
||||
"Z_AI_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"KIMI_CN_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
"KILOCODE_API_KEY",
|
||||
@@ -512,7 +514,87 @@ def run_doctor(args):
|
||||
pass
|
||||
|
||||
_check_gateway_service_linger(issues)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Check: Command installation (hermes bin symlink)
|
||||
# =========================================================================
|
||||
if sys.platform != "win32":
|
||||
print()
|
||||
print(color("◆ Command Installation", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
# Determine the venv entry point location
|
||||
_venv_bin = None
|
||||
for _venv_name in ("venv", ".venv"):
|
||||
_candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes"
|
||||
if _candidate.exists():
|
||||
_venv_bin = _candidate
|
||||
break
|
||||
|
||||
# Determine the expected command link directory (mirrors install.sh logic)
|
||||
_prefix = os.environ.get("PREFIX", "")
|
||||
_is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix
|
||||
if _is_termux_env and _prefix:
|
||||
_cmd_link_dir = Path(_prefix) / "bin"
|
||||
_cmd_link_display = "$PREFIX/bin"
|
||||
else:
|
||||
_cmd_link_dir = Path.home() / ".local" / "bin"
|
||||
_cmd_link_display = "~/.local/bin"
|
||||
_cmd_link = _cmd_link_dir / "hermes"
|
||||
|
||||
if _venv_bin is None:
|
||||
check_warn(
|
||||
"Venv entry point not found",
|
||||
"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')"
|
||||
)
|
||||
manual_issues.append(
|
||||
f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'"
|
||||
)
|
||||
else:
|
||||
check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})")
|
||||
|
||||
# Check the symlink at the command link location
|
||||
if _cmd_link.is_symlink():
|
||||
_target = _cmd_link.resolve()
|
||||
_expected = _venv_bin.resolve()
|
||||
if _target == _expected:
|
||||
check_ok(f"{_cmd_link_display}/hermes → correct target")
|
||||
else:
|
||||
check_warn(
|
||||
f"{_cmd_link_display}/hermes points to wrong target",
|
||||
f"(→ {_target}, expected → {_expected})"
|
||||
)
|
||||
if should_fix:
|
||||
_cmd_link.unlink()
|
||||
_cmd_link.symlink_to(_venv_bin)
|
||||
check_ok(f"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}")
|
||||
fixed_count += 1
|
||||
else:
|
||||
issues.append(f"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'")
|
||||
elif _cmd_link.exists():
|
||||
# It's a regular file, not a symlink — possibly a wrapper script
|
||||
check_ok(f"{_cmd_link_display}/hermes exists (non-symlink)")
|
||||
else:
|
||||
check_fail(
|
||||
f"{_cmd_link_display}/hermes not found",
|
||||
"(hermes command may not work outside the venv)"
|
||||
)
|
||||
if should_fix:
|
||||
_cmd_link_dir.mkdir(parents=True, exist_ok=True)
|
||||
_cmd_link.symlink_to(_venv_bin)
|
||||
check_ok(f"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}")
|
||||
fixed_count += 1
|
||||
|
||||
# Check if the link dir is on PATH
|
||||
_path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
||||
if str(_cmd_link_dir) not in _path_dirs:
|
||||
check_warn(
|
||||
f"{_cmd_link_display} is not on your PATH",
|
||||
"(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")"
|
||||
)
|
||||
manual_issues.append(f"Add {_cmd_link_display} to your PATH")
|
||||
else:
|
||||
issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'")
|
||||
|
||||
# =========================================================================
|
||||
# Check: External tools
|
||||
# =========================================================================
|
||||
@@ -722,13 +804,14 @@ def run_doctor(args):
|
||||
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
|
||||
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
|
||||
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
|
||||
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
|
||||
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
|
||||
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
|
||||
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
|
||||
# MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
|
||||
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
|
||||
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", True),
|
||||
("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
|
||||
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
|
||||
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
|
||||
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
|
||||
("OpenCode Go", ("OPENCODE_GO_API_KEY",), "https://opencode.ai/zen/go/v1/models", "OPENCODE_GO_BASE_URL", True),
|
||||
@@ -748,7 +831,7 @@ def run_doctor(args):
|
||||
print(f" Checking {_pname} API...", end="", flush=True)
|
||||
try:
|
||||
import httpx
|
||||
_base = os.getenv(_base_env, "")
|
||||
_base = os.getenv(_base_env, "") if _base_env else ""
|
||||
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com
|
||||
if not _base and _key.startswith("sk-kimi-"):
|
||||
_base = "https://api.kimi.com/coding/v1"
|
||||
|
||||
@@ -131,6 +131,7 @@ def _configured_platforms() -> list[str]:
|
||||
"wecom": "WECOM_BOT_ID",
|
||||
"wecom_callback": "WECOM_CALLBACK_CORP_ID",
|
||||
"weixin": "WEIXIN_ACCOUNT_ID",
|
||||
"qqbot": "QQ_APP_ID",
|
||||
}
|
||||
return [name for name, env in checks.items() if os.getenv(env)]
|
||||
|
||||
|
||||
@@ -8,11 +8,40 @@ from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
# Env var name suffixes that indicate credential values. These are the
|
||||
# only env vars whose values we sanitize on load — we must not silently
|
||||
# alter arbitrary user env vars, but credentials are known to require
|
||||
# pure ASCII (they become HTTP header values).
|
||||
_CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY")
|
||||
|
||||
|
||||
def _sanitize_loaded_credentials() -> None:
|
||||
"""Strip non-ASCII characters from credential env vars in os.environ.
|
||||
|
||||
Called after dotenv loads so the rest of the codebase never sees
|
||||
non-ASCII API keys. Only touches env vars whose names end with
|
||||
known credential suffixes (``_API_KEY``, ``_TOKEN``, etc.).
|
||||
"""
|
||||
for key, value in list(os.environ.items()):
|
||||
if not any(key.endswith(suffix) for suffix in _CREDENTIAL_SUFFIXES):
|
||||
continue
|
||||
try:
|
||||
value.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
os.environ[key] = value.encode("ascii", errors="ignore").decode("ascii")
|
||||
|
||||
|
||||
def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
|
||||
try:
|
||||
load_dotenv(dotenv_path=path, override=override, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(dotenv_path=path, override=override, encoding="latin-1")
|
||||
# Strip non-ASCII characters from credential env vars that were just
|
||||
# loaded. API keys must be pure ASCII since they're sent as HTTP
|
||||
# header values (httpx encodes headers as ASCII). Non-ASCII chars
|
||||
# typically come from copy-pasting keys from PDFs or rich-text editors
|
||||
# that substitute Unicode lookalike glyphs (e.g. ʋ U+028B for v).
|
||||
_sanitize_loaded_credentials()
|
||||
|
||||
|
||||
def _sanitize_env_file_if_needed(path: Path) -> None:
|
||||
|
||||
+133
-3
@@ -715,7 +715,9 @@ def _detect_venv_dir() -> Path | None:
|
||||
"""Detect the active virtualenv directory.
|
||||
|
||||
Checks ``sys.prefix`` first (works regardless of the directory name),
|
||||
then falls back to probing common directory names under PROJECT_ROOT.
|
||||
then ``VIRTUAL_ENV`` env var (covers uv-managed environments where
|
||||
sys.prefix == sys.base_prefix), then falls back to probing common
|
||||
directory names under PROJECT_ROOT.
|
||||
Returns ``None`` when no virtualenv can be found.
|
||||
"""
|
||||
# If we're running inside a virtualenv, sys.prefix points to it.
|
||||
@@ -724,6 +726,15 @@ def _detect_venv_dir() -> Path | None:
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
|
||||
# uv and some other tools set VIRTUAL_ENV without changing sys.prefix.
|
||||
# This catches `uv run` where sys.prefix == sys.base_prefix but the
|
||||
# environment IS a venv. (#8620)
|
||||
_virtual_env = os.environ.get("VIRTUAL_ENV")
|
||||
if _virtual_env:
|
||||
venv = Path(_virtual_env)
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
|
||||
# Fallback: check common virtualenv directory names under the project root.
|
||||
for candidate in (".venv", "venv"):
|
||||
venv = PROJECT_ROOT / candidate
|
||||
@@ -1128,7 +1139,62 @@ def systemd_restart(system: bool = False):
|
||||
|
||||
pid = get_running_pid()
|
||||
if pid is not None and _request_gateway_self_restart(pid):
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restart requested")
|
||||
# SIGUSR1 sent — the gateway will drain active agents, exit with
|
||||
# code 75, and systemd will restart it after RestartSec (30s).
|
||||
# Wait for the old process to die and the new one to become active
|
||||
# so the CLI doesn't return while the service is still restarting.
|
||||
import time
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
svc = get_service_name()
|
||||
scope_cmd = _systemctl_cmd(system)
|
||||
|
||||
# Phase 1: wait for old process to exit (drain + shutdown)
|
||||
print(f"⏳ {scope_label} service draining active work...")
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
time.sleep(1)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
break # old process is gone
|
||||
else:
|
||||
print(f"⚠ Old process (PID {pid}) still alive after 90s")
|
||||
|
||||
# Phase 2: wait for systemd to start the new process
|
||||
print(f"⏳ Waiting for {svc} to restart...")
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
scope_cmd + ["is-active", svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
# Verify it's a NEW process, not the old one somehow
|
||||
new_pid = get_running_pid()
|
||||
if new_pid and new_pid != pid:
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
time.sleep(2)
|
||||
|
||||
# Timed out — check final state
|
||||
try:
|
||||
result = subprocess.run(
|
||||
scope_cmd + ["is-active", svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
print(f"✓ {scope_label} service restarted")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
f"⚠ {scope_label} service did not become active within 60s.\n"
|
||||
f" Check status: {'sudo ' if system else ''}hermes gateway status\n"
|
||||
f" Check logs: journalctl {'--user ' if not system else ''}-u {svc} --since '2 min ago'"
|
||||
)
|
||||
return
|
||||
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")
|
||||
@@ -1634,7 +1700,7 @@ _PLATFORMS = [
|
||||
" Create an App-Level Token with scope: connections:write → copy xapp-... token",
|
||||
"3. Add Bot Token Scopes: Features → OAuth & Permissions → Scopes",
|
||||
" Required: chat:write, app_mentions:read, channels:history, channels:read,",
|
||||
" groups:history, im:history, im:read, im:write, users:read, files:write",
|
||||
" groups:history, im:history, im:read, im:write, users:read, files:read, files:write",
|
||||
"4. Subscribe to Events: Features → Event Subscriptions → Enable",
|
||||
" Required events: message.im, message.channels, app_mention",
|
||||
" Optional: message.groups (for private channels)",
|
||||
@@ -1913,6 +1979,29 @@ _PLATFORMS = [
|
||||
"help": "Phone number or Apple ID to deliver cron results and notifications to."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "qqbot",
|
||||
"label": "QQ Bot",
|
||||
"emoji": "🐧",
|
||||
"token_var": "QQ_APP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Register a QQ Bot application at q.qq.com",
|
||||
"2. Note your App ID and App Secret from the application page",
|
||||
"3. Enable the required intents (C2C, Group, Guild messages)",
|
||||
"4. Configure sandbox or publish the bot",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "QQ_APP_ID", "prompt": "QQ Bot App ID", "password": False,
|
||||
"help": "Your QQ Bot App ID from q.qq.com."},
|
||||
{"name": "QQ_CLIENT_SECRET", "prompt": "QQ Bot App Secret", "password": True,
|
||||
"help": "Your QQ Bot App Secret from q.qq.com."},
|
||||
{"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Optional — restrict DM access to specific user OpenIDs."},
|
||||
{"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False,
|
||||
"help": "OpenID to deliver cron results and notifications to."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -2841,6 +2930,15 @@ def gateway_command(args):
|
||||
|
||||
elif subcmd == "start":
|
||||
system = getattr(args, 'system', False)
|
||||
start_all = getattr(args, 'all', False)
|
||||
|
||||
if start_all:
|
||||
# Kill all stale gateway processes across all profiles before starting
|
||||
killed = kill_gateway_processes(all_profiles=True)
|
||||
if killed:
|
||||
print(f"✓ Killed {killed} stale gateway process(es) across all profiles")
|
||||
_wait_for_gateway_exit(timeout=10.0, force_after=5.0)
|
||||
|
||||
if is_termux():
|
||||
print("Gateway service start is not supported on Termux because there is no system service manager.")
|
||||
print("Run manually: hermes gateway")
|
||||
@@ -2926,7 +3024,39 @@ def gateway_command(args):
|
||||
# Try service first, fall back to killing and restarting
|
||||
service_available = False
|
||||
system = getattr(args, 'system', False)
|
||||
restart_all = getattr(args, 'all', False)
|
||||
service_configured = False
|
||||
|
||||
if restart_all:
|
||||
# --all: stop every gateway process across all profiles, then start fresh
|
||||
service_stopped = False
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
try:
|
||||
systemd_stop(system=system)
|
||||
service_stopped = True
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
try:
|
||||
launchd_stop()
|
||||
service_stopped = True
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
killed = kill_gateway_processes(all_profiles=True)
|
||||
total = killed + (1 if service_stopped else 0)
|
||||
if total:
|
||||
print(f"✓ Stopped {total} gateway process(es) across all profiles")
|
||||
_wait_for_gateway_exit(timeout=10.0, force_after=5.0)
|
||||
|
||||
# Start the current profile's service fresh
|
||||
print("Starting gateway...")
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
systemd_start(system=system)
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
launchd_start()
|
||||
else:
|
||||
run_gateway(verbose=0)
|
||||
return
|
||||
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
service_configured = True
|
||||
|
||||
+131
-111
@@ -1034,29 +1034,9 @@ def select_provider_and_model(args=None):
|
||||
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
||||
active = "custom"
|
||||
|
||||
provider_labels = {
|
||||
"openrouter": "OpenRouter",
|
||||
"nous": "Nous Portal",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"qwen-oauth": "Qwen OAuth",
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"copilot": "GitHub Copilot",
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Google AI Studio",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"kimi-coding-cn": "Kimi / Moonshot (China)",
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"kilocode": "Kilo Code",
|
||||
"alibaba": "Alibaba Cloud (DashScope)",
|
||||
"huggingface": "Hugging Face",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
"custom": "Custom endpoint",
|
||||
}
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
|
||||
|
||||
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
|
||||
active_label = provider_labels.get(active, active) if active else "none"
|
||||
|
||||
print()
|
||||
@@ -1064,32 +1044,8 @@ def select_provider_and_model(args=None):
|
||||
print(f" Active provider: {active_label}")
|
||||
print()
|
||||
|
||||
# Step 1: Provider selection — top providers shown first, rest behind "More..."
|
||||
top_providers = [
|
||||
("nous", "Nous Portal (Nous Research subscription)"),
|
||||
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
("anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
("openai-codex", "OpenAI Codex"),
|
||||
("qwen-oauth", "Qwen OAuth (reuses local Qwen CLI login)"),
|
||||
("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
|
||||
("huggingface", "Hugging Face Inference Providers (20+ open models)"),
|
||||
]
|
||||
|
||||
extended_providers = [
|
||||
("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
|
||||
("gemini", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
|
||||
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
|
||||
("kimi-coding-cn", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
("minimax", "MiniMax (global direct API)"),
|
||||
("minimax-cn", "MiniMax China (domestic direct API)"),
|
||||
("kilocode", "Kilo Code (Kilo Gateway API)"),
|
||||
("opencode-zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
|
||||
("opencode-go", "OpenCode Go (open models, $10/month subscription)"),
|
||||
("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
|
||||
("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
("xiaomi", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
|
||||
]
|
||||
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
|
||||
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
|
||||
|
||||
def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
|
||||
custom_provider_map = {}
|
||||
@@ -1126,29 +1082,22 @@ def select_provider_and_model(args=None):
|
||||
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
saved_model = provider_info.get("model", "")
|
||||
model_hint = f" — {saved_model}" if saved_model else ""
|
||||
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
|
||||
top_keys = {k for k, _ in top_providers}
|
||||
extended_keys = {k for k, _ in extended_providers}
|
||||
|
||||
# If the active provider is in the extended list, promote it into top
|
||||
if active and active in extended_keys:
|
||||
promoted = [(k, l) for k, l in extended_providers if k == active]
|
||||
extended_providers = [(k, l) for k, l in extended_providers if k != active]
|
||||
top_providers = promoted + top_providers
|
||||
top_keys.add(active)
|
||||
|
||||
# Build the primary menu
|
||||
# Build the menu
|
||||
ordered = []
|
||||
default_idx = 0
|
||||
for key, label in top_providers:
|
||||
for key, label in all_providers:
|
||||
if active and key == active:
|
||||
ordered.append((key, f"{label} ← currently active"))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label))
|
||||
|
||||
ordered.append(("more", "More providers..."))
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
|
||||
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(config.get("custom_providers"))
|
||||
if _has_saved_custom_list:
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider"))
|
||||
ordered.append(("cancel", "Cancel"))
|
||||
|
||||
provider_idx = _prompt_provider_choice(
|
||||
@@ -1160,23 +1109,6 @@ def select_provider_and_model(args=None):
|
||||
|
||||
selected_provider = ordered[provider_idx][0]
|
||||
|
||||
# "More providers..." — show the extended list
|
||||
if selected_provider == "more":
|
||||
ext_ordered = list(extended_providers)
|
||||
ext_ordered.append(("custom", "Custom endpoint (enter URL manually)"))
|
||||
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(config.get("custom_providers"))
|
||||
if _has_saved_custom_list:
|
||||
ext_ordered.append(("remove-custom", "Remove a saved custom provider"))
|
||||
ext_ordered.append(("cancel", "Cancel"))
|
||||
|
||||
ext_idx = _prompt_provider_choice(
|
||||
[label for _, label in ext_ordered], default=0,
|
||||
)
|
||||
if ext_idx is None or ext_ordered[ext_idx][0] == "cancel":
|
||||
print("No change.")
|
||||
return
|
||||
selected_provider = ext_ordered[ext_idx][0]
|
||||
|
||||
# Step 2: Provider-specific setup + model selection
|
||||
if selected_provider == "openrouter":
|
||||
_model_flow_openrouter(config, current_model)
|
||||
@@ -1207,7 +1139,7 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_anthropic(config, current_model)
|
||||
elif selected_provider == "kimi-coding":
|
||||
_model_flow_kimi(config, current_model)
|
||||
elif selected_provider in ("gemini", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi"):
|
||||
elif selected_provider in ("gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi", "arcee"):
|
||||
_model_flow_api_key_provider(config, selected_provider, current_model)
|
||||
|
||||
# ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
|
||||
@@ -1686,6 +1618,10 @@ def _model_flow_custom(config):
|
||||
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
|
||||
|
||||
context_length_str = input("Context length in tokens [leave blank for auto-detect]: ").strip()
|
||||
|
||||
# Prompt for a display name — shown in the provider menu on future runs
|
||||
default_name = _auto_provider_name(effective_url)
|
||||
display_name = input(f"Display name [{default_name}]: ").strip() or default_name
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
@@ -1741,15 +1677,37 @@ def _model_flow_custom(config):
|
||||
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
|
||||
|
||||
# Auto-save to custom_providers so it appears in the menu next time
|
||||
_save_custom_provider(effective_url, effective_key, model_name or "", context_length=context_length)
|
||||
_save_custom_provider(effective_url, effective_key, model_name or "",
|
||||
context_length=context_length, name=display_name)
|
||||
|
||||
|
||||
def _save_custom_provider(base_url, api_key="", model="", context_length=None):
|
||||
def _auto_provider_name(base_url: str) -> str:
|
||||
"""Generate a display name from a custom endpoint URL.
|
||||
|
||||
Returns a human-friendly label like "Local (localhost:11434)" or
|
||||
"RunPod (xyz.runpod.io)". Used as the default when prompting the
|
||||
user for a display name during custom endpoint setup.
|
||||
"""
|
||||
import re
|
||||
clean = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
clean = re.sub(r"/v1/?$", "", clean)
|
||||
name = clean.split("/")[0]
|
||||
if "localhost" in name or "127.0.0.1" in name:
|
||||
name = f"Local ({name})"
|
||||
elif "runpod" in name.lower():
|
||||
name = f"RunPod ({name})"
|
||||
else:
|
||||
name = name.capitalize()
|
||||
return name
|
||||
|
||||
|
||||
def _save_custom_provider(base_url, api_key="", model="", context_length=None,
|
||||
name=None):
|
||||
"""Save a custom endpoint to custom_providers in config.yaml.
|
||||
|
||||
Deduplicates by base_url — if the URL already exists, updates the
|
||||
model name and context_length but doesn't add a duplicate entry.
|
||||
Auto-generates a display name from the URL hostname.
|
||||
Uses *name* when provided, otherwise auto-generates from the URL.
|
||||
"""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
@@ -1777,20 +1735,9 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None):
|
||||
save_config(cfg)
|
||||
return # already saved, updated if needed
|
||||
|
||||
# Auto-generate a name from the URL
|
||||
import re
|
||||
clean = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
# Remove /v1 suffix for cleaner names
|
||||
clean = re.sub(r"/v1/?$", "", clean)
|
||||
# Use hostname:port as the name
|
||||
name = clean.split("/")[0]
|
||||
# Capitalize for readability
|
||||
if "localhost" in name or "127.0.0.1" in name:
|
||||
name = f"Local ({name})"
|
||||
elif "runpod" in name.lower():
|
||||
name = f"RunPod ({name})"
|
||||
else:
|
||||
name = name.capitalize()
|
||||
# Use provided name or auto-generate from URL
|
||||
if not name:
|
||||
name = _auto_provider_name(base_url)
|
||||
|
||||
entry = {"name": name, "base_url": base_url}
|
||||
if api_key:
|
||||
@@ -2696,13 +2643,12 @@ def _run_anthropic_oauth_flow(save_env_value):
|
||||
|
||||
def _model_flow_anthropic(config, current_model=""):
|
||||
"""Flow for Anthropic provider — OAuth subscription, API key, or Claude Code creds."""
|
||||
import os
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
|
||||
_prompt_model_selection, _save_model_choice,
|
||||
deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import (
|
||||
get_env_value, save_env_value, load_config, save_config,
|
||||
save_env_value, load_config, save_config,
|
||||
save_anthropic_api_key,
|
||||
)
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
@@ -4090,7 +4036,40 @@ def cmd_update(args):
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if restart.returncode == 0:
|
||||
restarted_services.append(svc_name)
|
||||
# Verify the service actually survived the
|
||||
# restart. systemctl restart returns 0 even
|
||||
# if the new process crashes immediately.
|
||||
import time as _time
|
||||
_time.sleep(3)
|
||||
verify = subprocess.run(
|
||||
scope_cmd + ["is-active", svc_name],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if verify.stdout.strip() == "active":
|
||||
restarted_services.append(svc_name)
|
||||
else:
|
||||
# Retry once — transient startup failures
|
||||
# (stale module cache, import race) often
|
||||
# resolve on the second attempt.
|
||||
print(f" ⚠ {svc_name} died after restart, retrying...")
|
||||
retry = subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
_time.sleep(3)
|
||||
verify2 = subprocess.run(
|
||||
scope_cmd + ["is-active", svc_name],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if verify2.stdout.strip() == "active":
|
||||
restarted_services.append(svc_name)
|
||||
print(f" ✓ {svc_name} recovered on retry")
|
||||
else:
|
||||
print(
|
||||
f" ✗ {svc_name} failed to stay running after restart.\n"
|
||||
f" Check logs: journalctl --user -u {svc_name} --since '2 min ago'\n"
|
||||
f" Restart manually: systemctl {'--user ' if scope == 'user' else ''}restart {svc_name}"
|
||||
)
|
||||
else:
|
||||
print(f" ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
@@ -4178,6 +4157,8 @@ def _coalesce_session_name_args(argv: list) -> list:
|
||||
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
||||
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
||||
"profile", "dashboard",
|
||||
"honcho", "claw", "plugins", "acp",
|
||||
"webhook", "memory", "dump", "debug", "backup", "import", "completion", "logs",
|
||||
}
|
||||
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
||||
|
||||
@@ -4473,17 +4454,20 @@ def cmd_dashboard(args):
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
allow_public=getattr(args, "insecure", False),
|
||||
)
|
||||
|
||||
|
||||
def cmd_completion(args):
|
||||
def cmd_completion(args, parser=None):
|
||||
"""Print shell completion script."""
|
||||
from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion
|
||||
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
|
||||
shell = getattr(args, "shell", "bash")
|
||||
if shell == "zsh":
|
||||
print(generate_zsh_completion())
|
||||
print(generate_zsh(parser))
|
||||
elif shell == "fish":
|
||||
print(generate_fish(parser))
|
||||
else:
|
||||
print(generate_bash_completion())
|
||||
print(generate_bash(parser))
|
||||
|
||||
|
||||
def cmd_logs(args):
|
||||
@@ -4628,7 +4612,7 @@ For more help on a command:
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi"],
|
||||
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee"],
|
||||
default=None,
|
||||
help="Inference provider (default: auto)"
|
||||
)
|
||||
@@ -4765,6 +4749,7 @@ For more help on a command:
|
||||
# gateway start
|
||||
gateway_start = gateway_subparsers.add_parser("start", help="Start the installed systemd/launchd background service")
|
||||
gateway_start.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
|
||||
gateway_start.add_argument("--all", action="store_true", help="Kill ALL stale gateway processes across all profiles before starting")
|
||||
|
||||
# gateway stop
|
||||
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
|
||||
@@ -4774,6 +4759,7 @@ For more help on a command:
|
||||
# gateway restart
|
||||
gateway_restart = gateway_subparsers.add_parser("restart", help="Restart gateway service")
|
||||
gateway_restart.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
|
||||
gateway_restart.add_argument("--all", action="store_true", help="Kill ALL gateway processes across all profiles before restarting")
|
||||
|
||||
# gateway status
|
||||
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
|
||||
@@ -5963,13 +5949,13 @@ Examples:
|
||||
# =========================================================================
|
||||
completion_parser = subparsers.add_parser(
|
||||
"completion",
|
||||
help="Print shell completion script (bash or zsh)",
|
||||
help="Print shell completion script (bash, zsh, or fish)",
|
||||
)
|
||||
completion_parser.add_argument(
|
||||
"shell", nargs="?", default="bash", choices=["bash", "zsh"],
|
||||
"shell", nargs="?", default="bash", choices=["bash", "zsh", "fish"],
|
||||
help="Shell type (default: bash)",
|
||||
)
|
||||
completion_parser.set_defaults(func=cmd_completion)
|
||||
completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser))
|
||||
|
||||
# =========================================================================
|
||||
# dashboard command
|
||||
@@ -5982,6 +5968,10 @@ Examples:
|
||||
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
|
||||
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
|
||||
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
|
||||
dashboard_parser.add_argument(
|
||||
"--insecure", action="store_true",
|
||||
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# =========================================================================
|
||||
@@ -6056,7 +6046,37 @@ Examples:
|
||||
sys.exit(1)
|
||||
|
||||
_processed_argv = _coalesce_session_name_args(sys.argv[1:])
|
||||
args = parser.parse_args(_processed_argv)
|
||||
|
||||
# ── Defensive subparser routing (bpo-9338 workaround) ───────────
|
||||
# On some Python versions (notably <3.11), argparse fails to route
|
||||
# subcommand tokens when the parent parser has nargs='?' optional
|
||||
# arguments (--continue). The symptom: "unrecognized arguments: model"
|
||||
# even though 'model' is a registered subcommand.
|
||||
#
|
||||
# Fix: when argv contains a token matching a known subcommand, set
|
||||
# subparsers.required=True to force deterministic routing. If that
|
||||
# fails (e.g. 'hermes -c model' where 'model' is consumed as the
|
||||
# session name for --continue), fall back to the default behaviour.
|
||||
import io as _io
|
||||
_known_cmds = set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set()
|
||||
_has_cmd_token = any(t in _known_cmds for t in _processed_argv if not t.startswith("-"))
|
||||
|
||||
if _has_cmd_token:
|
||||
subparsers.required = True
|
||||
_saved_stderr = sys.stderr
|
||||
try:
|
||||
sys.stderr = _io.StringIO()
|
||||
args = parser.parse_args(_processed_argv)
|
||||
sys.stderr = _saved_stderr
|
||||
except SystemExit:
|
||||
sys.stderr = _saved_stderr
|
||||
# Subcommand name was consumed as a flag value (e.g. -c model).
|
||||
# Fall back to optional subparsers so argparse handles it normally.
|
||||
subparsers.required = False
|
||||
args = parser.parse_args(_processed_argv)
|
||||
else:
|
||||
subparsers.required = False
|
||||
args = parser.parse_args(_processed_argv)
|
||||
|
||||
# Handle --version flag
|
||||
if args.version:
|
||||
|
||||
@@ -324,6 +324,9 @@ def cmd_setup(args) -> None:
|
||||
val = _prompt(desc, default=str(effective_default) if effective_default else None)
|
||||
if val:
|
||||
provider_config[key] = val
|
||||
# Also write to .env if this field has an env_var
|
||||
if env_var and env_var not in env_writes:
|
||||
env_writes[env_var] = val
|
||||
|
||||
# Write activation key to config.yaml
|
||||
config["memory"]["provider"] = name
|
||||
@@ -409,12 +412,13 @@ def cmd_status(args) -> None:
|
||||
else:
|
||||
print(f" Status: not available ✗")
|
||||
schema = p.get_config_schema() if hasattr(p, "get_config_schema") else []
|
||||
secrets = [f for f in schema if f.get("secret")]
|
||||
if secrets:
|
||||
# Check all fields that have env_var (both secret and non-secret)
|
||||
required_fields = [f for f in schema if f.get("env_var")]
|
||||
if required_fields:
|
||||
print(f" Missing:")
|
||||
for s in secrets:
|
||||
env_var = s.get("env_var", "")
|
||||
url = s.get("url", "")
|
||||
for f in required_fields:
|
||||
env_var = f.get("env_var", "")
|
||||
url = f.get("url", "")
|
||||
is_set = bool(os.environ.get(env_var))
|
||||
mark = "✓" if is_set else "✗"
|
||||
line = f" {mark} {env_var}"
|
||||
|
||||
@@ -51,6 +51,7 @@ _VENDOR_PREFIXES: dict[str, str] = {
|
||||
"grok": "x-ai",
|
||||
"qwen": "qwen",
|
||||
"mimo": "xiaomi",
|
||||
"trinity": "arcee-ai",
|
||||
"nemotron": "nvidia",
|
||||
"llama": "meta-llama",
|
||||
"step": "stepfun",
|
||||
@@ -94,6 +95,7 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
|
||||
"alibaba",
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"custom",
|
||||
})
|
||||
|
||||
|
||||
+88
-11
@@ -41,7 +41,6 @@ from agent.models_dev import (
|
||||
get_model_capabilities,
|
||||
get_model_info,
|
||||
list_provider_models,
|
||||
search_models_dev,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -706,6 +705,10 @@ def switch_model(
|
||||
error_message=msg,
|
||||
)
|
||||
|
||||
# Apply auto-correction if validation found a closer match
|
||||
if validation.get("corrected_model"):
|
||||
new_model = validation["corrected_model"]
|
||||
|
||||
# --- OpenCode api_mode override ---
|
||||
if target_provider in {"opencode-zen", "opencode-go", "opencode", "opencode-go"}:
|
||||
api_mode = opencode_model_api_mode(target_provider, new_model)
|
||||
@@ -935,6 +938,65 @@ def list_authenticated_providers(
|
||||
seen_slugs.add(pid)
|
||||
seen_slugs.add(hermes_slug)
|
||||
|
||||
# --- 2b. Cross-check canonical provider list ---
|
||||
# Catches providers that are in CANONICAL_PROVIDERS but weren't found
|
||||
# in PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS (keeps /model in sync
|
||||
# with `hermes model`).
|
||||
try:
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS as _canon_provs
|
||||
except ImportError:
|
||||
_canon_provs = []
|
||||
|
||||
for _cp in _canon_provs:
|
||||
if _cp.slug in seen_slugs:
|
||||
continue
|
||||
|
||||
# Check credentials via PROVIDER_REGISTRY (auth.py)
|
||||
_cp_config = _auth_registry.get(_cp.slug)
|
||||
_cp_has_creds = False
|
||||
if _cp_config and _cp_config.api_key_env_vars:
|
||||
_cp_has_creds = any(os.environ.get(ev) for ev in _cp_config.api_key_env_vars)
|
||||
# Also check auth store and credential pool
|
||||
if not _cp_has_creds:
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
_cp_store = _load_auth_store()
|
||||
_cp_providers_store = _cp_store.get("providers", {})
|
||||
_cp_pool_store = _cp_store.get("credential_pool", {})
|
||||
if _cp_store and (
|
||||
_cp.slug in _cp_providers_store
|
||||
or _cp.slug in _cp_pool_store
|
||||
):
|
||||
_cp_has_creds = True
|
||||
except Exception:
|
||||
pass
|
||||
if not _cp_has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
_cp_pool = load_pool(_cp.slug)
|
||||
if _cp_pool.has_credentials():
|
||||
_cp_has_creds = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not _cp_has_creds:
|
||||
continue
|
||||
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
_cp_total = len(_cp_model_ids)
|
||||
_cp_top = _cp_model_ids[:max_models]
|
||||
|
||||
results.append({
|
||||
"slug": _cp.slug,
|
||||
"name": _cp.label,
|
||||
"is_current": _cp.slug == current_provider,
|
||||
"is_user_defined": False,
|
||||
"models": _cp_top,
|
||||
"total_models": _cp_total,
|
||||
"source": "canonical",
|
||||
})
|
||||
seen_slugs.add(_cp.slug)
|
||||
|
||||
# --- 3. User-defined endpoints from config ---
|
||||
if user_providers and isinstance(user_providers, dict):
|
||||
for ep_name, ep_cfg in user_providers.items():
|
||||
@@ -969,7 +1031,17 @@ def list_authenticated_providers(
|
||||
})
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same provider name are grouped into a
|
||||
# single picker row so that e.g. four Ollama Cloud entries
|
||||
# (qwen3-coder, glm-5.1, kimi-k2, minimax-m2.7) appear as one
|
||||
# "Ollama Cloud" row with four models inside instead of four
|
||||
# duplicate "Ollama Cloud" rows. Entries with distinct provider names
|
||||
# still produce separate rows (e.g. Ollama Cloud vs Moonshot).
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
from collections import OrderedDict
|
||||
|
||||
groups: "OrderedDict[str, dict]" = OrderedDict()
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -985,23 +1057,28 @@ def list_authenticated_providers(
|
||||
continue
|
||||
|
||||
slug = custom_provider_slug(display_name)
|
||||
if slug not in groups:
|
||||
groups[slug] = {
|
||||
"name": display_name,
|
||||
"api_url": api_url,
|
||||
"models": [],
|
||||
}
|
||||
default_model = (entry.get("model") or "").strip()
|
||||
if default_model and default_model not in groups[slug]["models"]:
|
||||
groups[slug]["models"].append(default_model)
|
||||
|
||||
for slug, grp in groups.items():
|
||||
if slug in seen_slugs:
|
||||
continue
|
||||
|
||||
models_list = []
|
||||
default_model = (entry.get("model") or "").strip()
|
||||
if default_model:
|
||||
models_list.append(default_model)
|
||||
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"name": grp["name"],
|
||||
"is_current": slug == current_provider,
|
||||
"is_user_defined": True,
|
||||
"models": models_list,
|
||||
"total_models": len(models_list),
|
||||
"models": grp["models"],
|
||||
"total_models": len(grp["models"]),
|
||||
"source": "user-config",
|
||||
"api_url": api_url,
|
||||
"api_url": grp["api_url"],
|
||||
})
|
||||
seen_slugs.add(slug)
|
||||
|
||||
|
||||
+102
-42
@@ -12,7 +12,7 @@ import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from difflib import get_close_matches
|
||||
from typing import Any, Optional
|
||||
from typing import Any, NamedTuple, Optional
|
||||
|
||||
COPILOT_BASE_URL = "https://api.githubcopilot.com"
|
||||
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
|
||||
@@ -29,6 +29,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("qwen/qwen3.6-plus", ""),
|
||||
("anthropic/claude-sonnet-4.5", ""),
|
||||
("anthropic/claude-haiku-4.5", ""),
|
||||
("openrouter/elephant-alpha", "free"),
|
||||
("openai/gpt-5.4", ""),
|
||||
("openai/gpt-5.4-mini", ""),
|
||||
("xiaomi/mimo-v2-pro", ""),
|
||||
@@ -43,6 +44,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("minimax/minimax-m2.7", ""),
|
||||
("minimax/minimax-m2.5", ""),
|
||||
("z-ai/glm-5.1", ""),
|
||||
("z-ai/glm-5v-turbo", ""),
|
||||
("z-ai/glm-5-turbo", ""),
|
||||
("moonshotai/kimi-k2.5", ""),
|
||||
("x-ai/grok-4.20", ""),
|
||||
@@ -88,6 +90,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"minimax/minimax-m2.7",
|
||||
"minimax/minimax-m2.5",
|
||||
"z-ai/glm-5.1",
|
||||
"z-ai/glm-5v-turbo",
|
||||
"z-ai/glm-5-turbo",
|
||||
"moonshotai/kimi-k2.5",
|
||||
"x-ai/grok-4.20-beta",
|
||||
@@ -97,6 +100,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"arcee-ai/trinity-large-thinking",
|
||||
"openai/gpt-5.4-pro",
|
||||
"openai/gpt-5.4-nano",
|
||||
"openrouter/elephant-alpha",
|
||||
],
|
||||
"openai-codex": _codex_curated_models(),
|
||||
"copilot-acp": [
|
||||
@@ -132,6 +136,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"zai": [
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"glm-5v-turbo",
|
||||
"glm-5-turbo",
|
||||
"glm-4.7",
|
||||
"glm-4.5",
|
||||
@@ -200,6 +205,11 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2-flash",
|
||||
],
|
||||
"arcee": [
|
||||
"trinity-large-thinking",
|
||||
"trinity-large-preview",
|
||||
"trinity-mini",
|
||||
],
|
||||
"opencode-zen": [
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.4",
|
||||
@@ -485,30 +495,52 @@ def check_nous_free_tier() -> bool:
|
||||
return False # default to paid on error — don't block users
|
||||
|
||||
|
||||
_PROVIDER_LABELS = {
|
||||
"openrouter": "OpenRouter",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"nous": "Nous Portal",
|
||||
"copilot": "GitHub Copilot",
|
||||
"gemini": "Google AI Studio",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"kimi-coding-cn": "Kimi / Moonshot (China)",
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"anthropic": "Anthropic",
|
||||
"deepseek": "DeepSeek",
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"kilocode": "Kilo Code",
|
||||
"alibaba": "Alibaba Cloud (DashScope)",
|
||||
"qwen-oauth": "Qwen OAuth (Portal)",
|
||||
"huggingface": "Hugging Face",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
"custom": "Custom endpoint",
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical provider list — single source of truth for provider identity.
|
||||
# Every code path that lists, displays, or iterates providers derives from
|
||||
# this list: hermes model, /model, /provider, list_authenticated_providers.
|
||||
#
|
||||
# Fields:
|
||||
# slug — internal provider ID (used in config.yaml, --provider flag)
|
||||
# label — short display name
|
||||
# tui_desc — longer description for the `hermes model` interactive picker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProviderEntry(NamedTuple):
|
||||
slug: str
|
||||
label: str
|
||||
tui_desc: str # detailed description for `hermes model` TUI
|
||||
|
||||
|
||||
CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
|
||||
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
|
||||
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
|
||||
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
|
||||
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
|
||||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
|
||||
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
|
||||
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
|
||||
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
|
||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||
ProviderEntry("kimi-coding", "Kimi / Moonshot", "Kimi / Moonshot (Moonshot AI direct API)"),
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
|
||||
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models — direct API)"),
|
||||
ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"),
|
||||
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
|
||||
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
|
||||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, pay-per-use)"),
|
||||
]
|
||||
|
||||
# Derived dicts — used throughout the codebase
|
||||
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
|
||||
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
"glm": "zai",
|
||||
@@ -528,6 +560,8 @@ _PROVIDER_ALIASES = {
|
||||
"moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn",
|
||||
"moonshot-cn": "kimi-coding-cn",
|
||||
"arcee-ai": "arcee",
|
||||
"arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic",
|
||||
@@ -553,6 +587,9 @@ _PROVIDER_ALIASES = {
|
||||
"huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
"grok": "xai",
|
||||
"x-ai": "xai",
|
||||
"x.ai": "xai",
|
||||
}
|
||||
|
||||
|
||||
@@ -639,13 +676,6 @@ def model_ids(*, force_refresh: bool = False) -> list[str]:
|
||||
return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
|
||||
|
||||
|
||||
def menu_labels(*, force_refresh: bool = False) -> list[str]:
|
||||
"""Return display labels like 'anthropic/claude-opus-4.6 (recommended)'."""
|
||||
labels = []
|
||||
for mid, desc in fetch_openrouter_models(force_refresh=force_refresh):
|
||||
labels.append(f"{mid} ({desc})" if desc else mid)
|
||||
return labels
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -845,23 +875,20 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
|
||||
Each dict has ``id``, ``label``, and ``aliases``.
|
||||
Checks which providers have valid credentials configured.
|
||||
|
||||
Derives the provider list from :data:`CANONICAL_PROVIDERS` (single
|
||||
source of truth shared with ``hermes model``, ``/model``, etc.).
|
||||
"""
|
||||
# Canonical providers in display order
|
||||
_PROVIDER_ORDER = [
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"gemini", "huggingface",
|
||||
"zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
||||
"qwen-oauth", "xiaomi",
|
||||
"opencode-zen", "opencode-go",
|
||||
"ai-gateway", "deepseek", "custom",
|
||||
]
|
||||
# Derive display order from canonical list + custom
|
||||
provider_order = [p.slug for p in CANONICAL_PROVIDERS] + ["custom"]
|
||||
|
||||
# Build reverse alias map
|
||||
aliases_for: dict[str, list[str]] = {}
|
||||
for alias, canonical in _PROVIDER_ALIASES.items():
|
||||
aliases_for.setdefault(canonical, []).append(alias)
|
||||
|
||||
result = []
|
||||
for pid in _PROVIDER_ORDER:
|
||||
for pid in provider_order:
|
||||
label = _PROVIDER_LABELS.get(pid, pid)
|
||||
alias_list = aliases_for.get(pid, [])
|
||||
# Check if this provider has credentials available
|
||||
@@ -1796,6 +1823,17 @@ def validate_requested_model(
|
||||
"message": None,
|
||||
}
|
||||
|
||||
# Auto-correct if the top match is very similar (e.g. typo)
|
||||
auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9)
|
||||
if auto:
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"corrected_model": auto[0],
|
||||
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
|
||||
}
|
||||
|
||||
suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5)
|
||||
suggestion_text = ""
|
||||
if suggestions:
|
||||
@@ -1847,6 +1885,16 @@ def validate_requested_model(
|
||||
"recognized": True,
|
||||
"message": None,
|
||||
}
|
||||
# Auto-correct if the top match is very similar (e.g. typo)
|
||||
auto = get_close_matches(requested_for_lookup, codex_models, n=1, cutoff=0.9)
|
||||
if auto:
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"corrected_model": auto[0],
|
||||
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
|
||||
}
|
||||
suggestions = get_close_matches(requested_for_lookup, codex_models, n=3, cutoff=0.5)
|
||||
suggestion_text = ""
|
||||
if suggestions:
|
||||
@@ -1879,6 +1927,18 @@ def validate_requested_model(
|
||||
# the user may have access to models not shown in the public
|
||||
# listing (e.g. Z.AI Pro/Max plans can use glm-5 on coding
|
||||
# endpoints even though it's not in /models). Warn but allow.
|
||||
|
||||
# Auto-correct if the top match is very similar (e.g. typo)
|
||||
auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9)
|
||||
if auto:
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"corrected_model": auto[0],
|
||||
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
|
||||
}
|
||||
|
||||
suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5)
|
||||
suggestion_text = ""
|
||||
if suggestions:
|
||||
|
||||
@@ -35,6 +35,7 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||
("wecom", PlatformInfo(label="💬 WeCom", default_toolset="hermes-wecom")),
|
||||
("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")),
|
||||
("weixin", PlatformInfo(label="💬 Weixin", default_toolset="hermes-weixin")),
|
||||
("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")),
|
||||
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
|
||||
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
|
||||
])
|
||||
|
||||
+107
-11
@@ -31,7 +31,6 @@ import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
@@ -263,6 +262,53 @@ class PluginContext:
|
||||
self._manager._hooks.setdefault(hook_name, []).append(callback)
|
||||
logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name)
|
||||
|
||||
# -- skill registration -------------------------------------------------
|
||||
|
||||
def register_skill(
|
||||
self,
|
||||
name: str,
|
||||
path: Path,
|
||||
description: str = "",
|
||||
) -> None:
|
||||
"""Register a read-only skill provided by this plugin.
|
||||
|
||||
The skill becomes resolvable as ``'<plugin_name>:<name>'`` via
|
||||
``skill_view()``. It does **not** enter the flat
|
||||
``~/.hermes/skills/`` tree and is **not** listed in the system
|
||||
prompt's ``<available_skills>`` index — plugin skills are
|
||||
opt-in explicit loads only.
|
||||
|
||||
Raises:
|
||||
ValueError: if *name* contains ``':'`` or invalid characters.
|
||||
FileNotFoundError: if *path* does not exist.
|
||||
"""
|
||||
from agent.skill_utils import _NAMESPACE_RE
|
||||
|
||||
if ":" in name:
|
||||
raise ValueError(
|
||||
f"Skill name '{name}' must not contain ':' "
|
||||
f"(the namespace is derived from the plugin name "
|
||||
f"'{self.manifest.name}' automatically)."
|
||||
)
|
||||
if not name or not _NAMESPACE_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+."
|
||||
)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"SKILL.md not found at {path}")
|
||||
|
||||
qualified = f"{self.manifest.name}:{name}"
|
||||
self._manager._plugin_skills[qualified] = {
|
||||
"path": path,
|
||||
"plugin": self.manifest.name,
|
||||
"bare_name": name,
|
||||
"description": description,
|
||||
}
|
||||
logger.debug(
|
||||
"Plugin %s registered skill: %s",
|
||||
self.manifest.name, qualified,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PluginManager
|
||||
@@ -279,6 +325,8 @@ class PluginManager:
|
||||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||||
self._discovered: bool = False
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
# Plugin skill registry: qualified name → metadata dict.
|
||||
self._plugin_skills: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public
|
||||
@@ -555,6 +603,28 @@ class PluginManager:
|
||||
)
|
||||
return result
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Plugin skill lookups
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def find_plugin_skill(self, qualified_name: str) -> Optional[Path]:
|
||||
"""Return the ``Path`` to a plugin skill's SKILL.md, or ``None``."""
|
||||
entry = self._plugin_skills.get(qualified_name)
|
||||
return entry["path"] if entry else None
|
||||
|
||||
def list_plugin_skills(self, plugin_name: str) -> List[str]:
|
||||
"""Return sorted bare names of all skills registered by *plugin_name*."""
|
||||
prefix = f"{plugin_name}:"
|
||||
return sorted(
|
||||
e["bare_name"]
|
||||
for qn, e in self._plugin_skills.items()
|
||||
if qn.startswith(prefix)
|
||||
)
|
||||
|
||||
def remove_plugin_skill(self, qualified_name: str) -> None:
|
||||
"""Remove a stale registry entry (silently ignores missing keys)."""
|
||||
self._plugin_skills.pop(qualified_name, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton & convenience functions
|
||||
@@ -584,18 +654,44 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||||
return get_plugin_manager().invoke_hook(hook_name, **kwargs)
|
||||
|
||||
|
||||
def get_plugin_tool_names() -> Set[str]:
|
||||
"""Return the set of tool names registered by plugins."""
|
||||
return get_plugin_manager()._plugin_tool_names
|
||||
|
||||
def get_pre_tool_call_block_message(
|
||||
tool_name: str,
|
||||
args: Optional[Dict[str, Any]],
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
tool_call_id: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Check ``pre_tool_call`` hooks for a blocking directive.
|
||||
|
||||
def get_plugin_cli_commands() -> Dict[str, dict]:
|
||||
"""Return CLI commands registered by general plugins.
|
||||
Plugins that need to enforce policy (rate limiting, security
|
||||
restrictions, approval workflows) can return::
|
||||
|
||||
Returns a dict of ``{name: {help, setup_fn, handler_fn, ...}}``
|
||||
suitable for wiring into argparse subparsers.
|
||||
{"action": "block", "message": "Reason the tool was blocked"}
|
||||
|
||||
from their ``pre_tool_call`` callback. The first valid block
|
||||
directive wins. Invalid or irrelevant hook return values are
|
||||
silently ignored so existing observer-only hooks are unaffected.
|
||||
"""
|
||||
return dict(get_plugin_manager()._cli_commands)
|
||||
hook_results = invoke_hook(
|
||||
"pre_tool_call",
|
||||
tool_name=tool_name,
|
||||
args=args if isinstance(args, dict) else {},
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
|
||||
for result in hook_results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
if result.get("action") != "block":
|
||||
continue
|
||||
message = result.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_plugin_context_engine():
|
||||
@@ -622,7 +718,7 @@ def get_plugin_toolsets() -> List[tuple]:
|
||||
toolset_tools: Dict[str, List[str]] = {}
|
||||
toolset_plugin: Dict[str, LoadedPlugin] = {}
|
||||
for tool_name in manager._plugin_tool_names:
|
||||
entry = registry._tools.get(tool_name)
|
||||
entry = registry.get_entry(tool_name)
|
||||
if not entry:
|
||||
continue
|
||||
ts = entry.toolset
|
||||
@@ -631,7 +727,7 @@ def get_plugin_toolsets() -> List[tuple]:
|
||||
# Map toolsets back to the plugin that registered them
|
||||
for _name, loaded in manager._plugins.items():
|
||||
for tool_name in loaded.tools_registered:
|
||||
entry = registry._tools.get(tool_name)
|
||||
entry = registry.get_entry(tool_name)
|
||||
if entry and entry.toolset in toolset_tools:
|
||||
toolset_plugin.setdefault(entry.toolset, loaded)
|
||||
|
||||
|
||||
@@ -136,6 +136,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="openai_chat",
|
||||
base_url_env_var="XIAOMI_BASE_URL",
|
||||
),
|
||||
"arcee": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_override="https://api.arcee.ai/api/v1",
|
||||
base_url_env_var="ARCEE_BASE_URL",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +236,10 @@ ALIASES: Dict[str, str] = {
|
||||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
|
||||
# arcee
|
||||
"arcee-ai": "arcee",
|
||||
"arceeai": "arcee",
|
||||
|
||||
# Local server aliases → virtual "local" concept (resolved via user config)
|
||||
"lmstudio": "lmstudio",
|
||||
"lm-studio": "lmstudio",
|
||||
|
||||
@@ -167,6 +167,7 @@ def _resolve_runtime_from_pool_entry(
|
||||
api_mode = "chat_completions"
|
||||
elif provider == "copilot":
|
||||
api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""))
|
||||
base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url
|
||||
else:
|
||||
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
# Honour model.base_url from config.yaml when the configured provider
|
||||
@@ -287,6 +288,9 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
# Resolve the API key from the env var name stored in key_env
|
||||
key_env = str(entry.get("key_env", "") or "").strip()
|
||||
resolved_api_key = os.getenv(key_env, "").strip() if key_env else ""
|
||||
# Fall back to inline api_key when key_env is absent or unresolvable
|
||||
if not resolved_api_key:
|
||||
resolved_api_key = str(entry.get("api_key", "") or "").strip()
|
||||
|
||||
if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}:
|
||||
# Found match by provider key
|
||||
|
||||
+64
-47
@@ -43,14 +43,6 @@ def _model_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
|
||||
if not model_name:
|
||||
return
|
||||
model_cfg = _model_config_dict(config)
|
||||
model_cfg["default"] = model_name
|
||||
config["model"] = model_cfg
|
||||
|
||||
|
||||
def _get_credential_pool_strategies(config: Dict[str, Any]) -> Dict[str, str]:
|
||||
strategies = config.get("credential_pool_strategies")
|
||||
return dict(strategies) if isinstance(strategies, dict) else {}
|
||||
@@ -107,6 +99,7 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
|
||||
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"kimi-coding-cn": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
|
||||
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
|
||||
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
|
||||
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
|
||||
@@ -136,43 +129,6 @@ def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None:
|
||||
agent_cfg["reasoning_effort"] = effort
|
||||
|
||||
|
||||
def _setup_copilot_reasoning_selection(
|
||||
config: Dict[str, Any],
|
||||
model_id: str,
|
||||
prompt_choice,
|
||||
*,
|
||||
catalog: Optional[list[dict[str, Any]]] = None,
|
||||
api_key: str = "",
|
||||
) -> None:
|
||||
from hermes_cli.models import github_model_reasoning_efforts, normalize_copilot_model_id
|
||||
|
||||
normalized_model = normalize_copilot_model_id(
|
||||
model_id,
|
||||
catalog=catalog,
|
||||
api_key=api_key,
|
||||
) or model_id
|
||||
efforts = github_model_reasoning_efforts(normalized_model, catalog=catalog, api_key=api_key)
|
||||
if not efforts:
|
||||
return
|
||||
|
||||
current_effort = _current_reasoning_effort(config)
|
||||
choices = list(efforts) + ["Disable reasoning", f"Keep current ({current_effort or 'default'})"]
|
||||
|
||||
if current_effort == "none":
|
||||
default_idx = len(efforts)
|
||||
elif current_effort in efforts:
|
||||
default_idx = efforts.index(current_effort)
|
||||
elif "medium" in efforts:
|
||||
default_idx = efforts.index("medium")
|
||||
else:
|
||||
default_idx = len(choices) - 1
|
||||
|
||||
effort_idx = prompt_choice("Select reasoning effort:", choices, default_idx)
|
||||
if effort_idx < len(efforts):
|
||||
_set_reasoning_effort(config, efforts[effort_idx])
|
||||
elif effort_idx == len(efforts):
|
||||
_set_reasoning_effort(config, "none")
|
||||
|
||||
|
||||
|
||||
# Import config helpers
|
||||
@@ -820,7 +776,7 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax CN",
|
||||
"anthropic": "Anthropic",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"ai-gateway": "Vercel AI Gateway",
|
||||
"custom": "your custom endpoint",
|
||||
}
|
||||
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
|
||||
@@ -1781,7 +1737,7 @@ def _setup_slack():
|
||||
print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions")
|
||||
print_info(" Required scopes: chat:write, app_mentions:read,")
|
||||
print_info(" channels:history, channels:read, im:history,")
|
||||
print_info(" im:read, im:write, users:read, files:write")
|
||||
print_info(" im:read, im:write, users:read, files:read, files:write")
|
||||
print_info(" Optional for private channels: groups:history")
|
||||
print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable")
|
||||
print_info(" Required events: message.im, message.channels, app_mention")
|
||||
@@ -2013,6 +1969,54 @@ def _setup_wecom_callback():
|
||||
_gw_setup()
|
||||
|
||||
|
||||
def _setup_qqbot():
|
||||
"""Configure QQ Bot gateway."""
|
||||
print_header("QQ Bot")
|
||||
existing = get_env_value("QQ_APP_ID")
|
||||
if existing:
|
||||
print_info("QQ Bot: already configured")
|
||||
if not prompt_yes_no("Reconfigure QQ Bot?", False):
|
||||
return
|
||||
|
||||
print_info("Connects Hermes to QQ via the Official QQ Bot API (v2).")
|
||||
print_info(" Requires a QQ Bot application at q.qq.com")
|
||||
print_info(" Reference: https://bot.q.qq.com/wiki/develop/api-v2/")
|
||||
print()
|
||||
|
||||
app_id = prompt("QQ Bot App ID")
|
||||
if not app_id:
|
||||
print_warning("App ID is required — skipping QQ Bot setup")
|
||||
return
|
||||
save_env_value("QQ_APP_ID", app_id.strip())
|
||||
|
||||
client_secret = prompt("QQ Bot App Secret", password=True)
|
||||
if not client_secret:
|
||||
print_warning("App Secret is required — skipping QQ Bot setup")
|
||||
return
|
||||
save_env_value("QQ_CLIENT_SECRET", client_secret)
|
||||
print_success("QQ Bot credentials saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can DM your bot")
|
||||
print_info(" Use QQ user OpenIDs (found in event payloads)")
|
||||
print()
|
||||
allowed_users = prompt("Allowed user OpenIDs (comma-separated, leave empty for open access)")
|
||||
if allowed_users:
|
||||
save_env_value("QQ_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("QQ Bot allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set — anyone can DM the bot!")
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: OpenID for cron job delivery and notifications.")
|
||||
home_channel = prompt("Home channel OpenID (leave empty to set later)")
|
||||
if home_channel:
|
||||
save_env_value("QQ_HOME_CHANNEL", home_channel)
|
||||
|
||||
print()
|
||||
print_success("QQ Bot configured!")
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
"""Configure BlueBubbles iMessage gateway."""
|
||||
print_header("BlueBubbles (iMessage)")
|
||||
@@ -2078,6 +2082,15 @@ def _setup_bluebubbles():
|
||||
print_info(" Install: https://docs.bluebubbles.app/helper-bundle/installation")
|
||||
|
||||
|
||||
def _setup_qqbot():
|
||||
"""Configure QQ Bot (Official API v2) via standard platform setup."""
|
||||
from hermes_cli.gateway import _PLATFORMS
|
||||
qq_platform = next((p for p in _PLATFORMS if p["key"] == "qqbot"), None)
|
||||
if qq_platform:
|
||||
from hermes_cli.gateway import _setup_standard_platform
|
||||
_setup_standard_platform(qq_platform)
|
||||
|
||||
|
||||
def _setup_webhooks():
|
||||
"""Configure webhook integration."""
|
||||
print_header("Webhooks")
|
||||
@@ -2141,6 +2154,7 @@ _GATEWAY_PLATFORMS = [
|
||||
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
|
||||
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
|
||||
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles),
|
||||
("QQ Bot", "QQ_APP_ID", _setup_qqbot),
|
||||
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
|
||||
]
|
||||
|
||||
@@ -2192,6 +2206,7 @@ def setup_gateway(config: dict):
|
||||
or get_env_value("WECOM_BOT_ID")
|
||||
or get_env_value("WEIXIN_ACCOUNT_ID")
|
||||
or get_env_value("BLUEBUBBLES_SERVER_URL")
|
||||
or get_env_value("QQ_APP_ID")
|
||||
or get_env_value("WEBHOOK_ENABLED")
|
||||
)
|
||||
if any_messaging:
|
||||
@@ -2213,6 +2228,8 @@ def setup_gateway(config: dict):
|
||||
missing_home.append("Slack")
|
||||
if get_env_value("BLUEBUBBLES_SERVER_URL") and not get_env_value("BLUEBUBBLES_HOME_CHANNEL"):
|
||||
missing_home.append("BlueBubbles")
|
||||
if get_env_value("QQ_APP_ID") and not get_env_value("QQ_HOME_CHANNEL"):
|
||||
missing_home.append("QQBot")
|
||||
|
||||
if missing_home:
|
||||
print()
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import List, Optional, Set
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.platforms import PLATFORMS as _PLATFORMS, platform_label
|
||||
from hermes_cli.platforms import PLATFORMS as _PLATFORMS
|
||||
|
||||
# Backward-compatible view: {key: label_string} so existing code that
|
||||
# iterates ``PLATFORMS.items()`` or calls ``PLATFORMS.get(key)`` keeps
|
||||
|
||||
+102
-9
@@ -32,6 +32,12 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
||||
response_border: "#FFD700" # Response box border (ANSI)
|
||||
session_label: "#DAA520" # Session label color
|
||||
session_border: "#8B8682" # Session ID dim color
|
||||
status_bar_bg: "#1a1a2e" # TUI status/usage bar background
|
||||
voice_status_bg: "#1a1a2e" # TUI voice status background
|
||||
completion_menu_bg: "#1a1a2e" # Completion menu background
|
||||
completion_menu_current_bg: "#333355" # Active completion row background
|
||||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
|
||||
completion_menu_meta_current_bg: "#333355" # Active completion meta background
|
||||
|
||||
# Spinner: customize the animated spinner during API calls
|
||||
spinner:
|
||||
@@ -87,6 +93,8 @@ BUILT-IN SKINS
|
||||
- ``ares`` — Crimson/bronze war-god theme with custom spinner wings
|
||||
- ``mono`` — Clean grayscale monochrome
|
||||
- ``slate`` — Cool blue developer-focused theme
|
||||
- ``daylight`` — Light background theme with dark text and blue accents
|
||||
- ``warm-lightmode`` — Warm brown/gold text for light terminal backgrounds
|
||||
|
||||
USER SKINS
|
||||
==========
|
||||
@@ -126,10 +134,6 @@ class SkinConfig:
|
||||
"""Get a color value with fallback."""
|
||||
return self.colors.get(key, fallback)
|
||||
|
||||
def get_spinner_list(self, key: str) -> List[str]:
|
||||
"""Get a spinner list (faces, verbs, etc.)."""
|
||||
return self.spinner.get(key, [])
|
||||
|
||||
def get_spinner_wings(self) -> List[Tuple[str, str]]:
|
||||
"""Get spinner wing pairs, or empty list if none."""
|
||||
raw = self.spinner.get("wings", [])
|
||||
@@ -308,6 +312,80 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
"tool_prefix": "┊",
|
||||
},
|
||||
"daylight": {
|
||||
"name": "daylight",
|
||||
"description": "Light theme for bright terminals with dark text and cool blue accents",
|
||||
"colors": {
|
||||
"banner_border": "#2563EB",
|
||||
"banner_title": "#0F172A",
|
||||
"banner_accent": "#1D4ED8",
|
||||
"banner_dim": "#475569",
|
||||
"banner_text": "#111827",
|
||||
"ui_accent": "#2563EB",
|
||||
"ui_label": "#0F766E",
|
||||
"ui_ok": "#15803D",
|
||||
"ui_error": "#B91C1C",
|
||||
"ui_warn": "#B45309",
|
||||
"prompt": "#111827",
|
||||
"input_rule": "#93C5FD",
|
||||
"response_border": "#2563EB",
|
||||
"session_label": "#1D4ED8",
|
||||
"session_border": "#64748B",
|
||||
"status_bar_bg": "#E5EDF8",
|
||||
"voice_status_bg": "#E5EDF8",
|
||||
"completion_menu_bg": "#F8FAFC",
|
||||
"completion_menu_current_bg": "#DBEAFE",
|
||||
"completion_menu_meta_bg": "#EEF2FF",
|
||||
"completion_menu_meta_current_bg": "#BFDBFE",
|
||||
},
|
||||
"spinner": {},
|
||||
"branding": {
|
||||
"agent_name": "Hermes Agent",
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"help_header": "[?] Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
},
|
||||
"warm-lightmode": {
|
||||
"name": "warm-lightmode",
|
||||
"description": "Warm light mode — dark brown/gold text for light terminal backgrounds",
|
||||
"colors": {
|
||||
"banner_border": "#8B6914",
|
||||
"banner_title": "#5C3D11",
|
||||
"banner_accent": "#8B4513",
|
||||
"banner_dim": "#8B7355",
|
||||
"banner_text": "#2C1810",
|
||||
"ui_accent": "#8B4513",
|
||||
"ui_label": "#5C3D11",
|
||||
"ui_ok": "#2E7D32",
|
||||
"ui_error": "#C62828",
|
||||
"ui_warn": "#E65100",
|
||||
"prompt": "#2C1810",
|
||||
"input_rule": "#8B6914",
|
||||
"response_border": "#8B6914",
|
||||
"session_label": "#5C3D11",
|
||||
"session_border": "#A0845C",
|
||||
"status_bar_bg": "#F5F0E8",
|
||||
"voice_status_bg": "#F5F0E8",
|
||||
"completion_menu_bg": "#F5EFE0",
|
||||
"completion_menu_current_bg": "#E8DCC8",
|
||||
"completion_menu_meta_bg": "#F0E8D8",
|
||||
"completion_menu_meta_current_bg": "#DFCFB0",
|
||||
},
|
||||
"spinner": {},
|
||||
"branding": {
|
||||
"agent_name": "Hermes Agent",
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! \u2695",
|
||||
"response_label": " \u2695 Hermes ",
|
||||
"prompt_symbol": "\u276f ",
|
||||
"help_header": "(^_^)? Available Commands",
|
||||
},
|
||||
"tool_prefix": "\u250a",
|
||||
},
|
||||
"poseidon": {
|
||||
"name": "poseidon",
|
||||
"description": "Ocean-god theme — deep blue and seafoam",
|
||||
@@ -689,6 +767,12 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
||||
label = skin.get_color("ui_label", title)
|
||||
warn = skin.get_color("ui_warn", "#FF8C00")
|
||||
error = skin.get_color("ui_error", "#FF6B6B")
|
||||
status_bg = skin.get_color("status_bar_bg", "#1a1a2e")
|
||||
voice_bg = skin.get_color("voice_status_bg", status_bg)
|
||||
menu_bg = skin.get_color("completion_menu_bg", "#1a1a2e")
|
||||
menu_current_bg = skin.get_color("completion_menu_current_bg", "#333355")
|
||||
menu_meta_bg = skin.get_color("completion_menu_meta_bg", menu_bg)
|
||||
menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg)
|
||||
|
||||
return {
|
||||
"input-area": prompt,
|
||||
@@ -696,13 +780,20 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
||||
"prompt": prompt,
|
||||
"prompt-working": f"{dim} italic",
|
||||
"hint": f"{dim} italic",
|
||||
"status-bar": f"bg:{status_bg} {text}",
|
||||
"status-bar-strong": f"bg:{status_bg} {title} bold",
|
||||
"status-bar-dim": f"bg:{status_bg} {dim}",
|
||||
"status-bar-good": f"bg:{status_bg} {skin.get_color('ui_ok', '#8FBC8F')} bold",
|
||||
"status-bar-warn": f"bg:{status_bg} {warn} bold",
|
||||
"status-bar-bad": f"bg:{status_bg} {skin.get_color('banner_accent', warn)} bold",
|
||||
"status-bar-critical": f"bg:{status_bg} {error} bold",
|
||||
"input-rule": input_rule,
|
||||
"image-badge": f"{label} bold",
|
||||
"completion-menu": f"bg:#1a1a2e {text}",
|
||||
"completion-menu.completion": f"bg:#1a1a2e {text}",
|
||||
"completion-menu.completion.current": f"bg:#333355 {title}",
|
||||
"completion-menu.meta.completion": f"bg:#1a1a2e {dim}",
|
||||
"completion-menu.meta.completion.current": f"bg:#333355 {label}",
|
||||
"completion-menu": f"bg:{menu_bg} {text}",
|
||||
"completion-menu.completion": f"bg:{menu_bg} {text}",
|
||||
"completion-menu.completion.current": f"bg:{menu_current_bg} {title}",
|
||||
"completion-menu.meta.completion": f"bg:{menu_meta_bg} {dim}",
|
||||
"completion-menu.meta.completion.current": f"bg:{menu_meta_current_bg} {label}",
|
||||
"clarify-border": input_rule,
|
||||
"clarify-title": f"{title} bold",
|
||||
"clarify-question": f"{text} bold",
|
||||
@@ -720,4 +811,6 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
||||
"approval-cmd": f"{dim} italic",
|
||||
"approval-choice": dim,
|
||||
"approval-selected": f"{title} bold",
|
||||
"voice-status": f"bg:{voice_bg} {label}",
|
||||
"voice-status-recording": f"bg:{voice_bg} {error} bold",
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ def show_status(args):
|
||||
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
|
||||
"Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
|
||||
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"),
|
||||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
|
||||
+2
-4
@@ -1,7 +1,7 @@
|
||||
"""Random tips shown at CLI session start to help users discover features."""
|
||||
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tip corpus — one-liners covering slash commands, CLI flags, config,
|
||||
@@ -346,6 +346,4 @@ def get_random_tip(exclude_recent: int = 0) -> str:
|
||||
return random.choice(TIPS)
|
||||
|
||||
|
||||
def get_tip_count() -> int:
|
||||
"""Return the total number of tips available."""
|
||||
return len(TIPS)
|
||||
|
||||
|
||||
+42
-18
@@ -63,6 +63,7 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("clarify", "❓ Clarifying Questions", "clarify"),
|
||||
("delegation", "👥 Task Delegation", "delegate_task"),
|
||||
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
|
||||
("messaging", "📨 Cross-Platform Messaging", "send_message"),
|
||||
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
|
||||
("homeassistant", "🏠 Home Assistant", "smart home device control"),
|
||||
]
|
||||
@@ -121,6 +122,7 @@ TOOL_CATEGORIES = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
"tag": "Managed OpenAI TTS billed to your subscription",
|
||||
"env_vars": [],
|
||||
"tts_provider": "openai",
|
||||
@@ -130,13 +132,15 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Edge TTS",
|
||||
"tag": "Free - no API key needed",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Good quality, no API key needed",
|
||||
"env_vars": [],
|
||||
"tts_provider": "edge",
|
||||
},
|
||||
{
|
||||
"name": "OpenAI TTS",
|
||||
"tag": "Premium - high quality voices",
|
||||
"badge": "paid",
|
||||
"tag": "High quality voices",
|
||||
"env_vars": [
|
||||
{"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"},
|
||||
],
|
||||
@@ -144,7 +148,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "ElevenLabs",
|
||||
"tag": "Premium - most natural voices",
|
||||
"badge": "paid",
|
||||
"tag": "Most natural voices",
|
||||
"env_vars": [
|
||||
{"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"},
|
||||
],
|
||||
@@ -152,7 +157,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Mistral (Voxtral TTS)",
|
||||
"tag": "Multilingual, native Opus, needs MISTRAL_API_KEY",
|
||||
"badge": "paid",
|
||||
"tag": "Multilingual, native Opus",
|
||||
"env_vars": [
|
||||
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
|
||||
],
|
||||
@@ -168,6 +174,7 @@ TOOL_CATEGORIES = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
"tag": "Managed Firecrawl billed to your subscription",
|
||||
"web_backend": "firecrawl",
|
||||
"env_vars": [],
|
||||
@@ -177,7 +184,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Firecrawl Cloud",
|
||||
"tag": "Hosted service - search, extract, and crawl",
|
||||
"badge": "★ recommended",
|
||||
"tag": "Full-featured search, extract, and crawl",
|
||||
"web_backend": "firecrawl",
|
||||
"env_vars": [
|
||||
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
|
||||
@@ -185,7 +193,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Exa",
|
||||
"tag": "AI-native search and contents",
|
||||
"badge": "paid",
|
||||
"tag": "Neural search with semantic understanding",
|
||||
"web_backend": "exa",
|
||||
"env_vars": [
|
||||
{"key": "EXA_API_KEY", "prompt": "Exa API key", "url": "https://exa.ai"},
|
||||
@@ -193,7 +202,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Parallel",
|
||||
"tag": "AI-native search and extract",
|
||||
"badge": "paid",
|
||||
"tag": "AI-powered search and extract",
|
||||
"web_backend": "parallel",
|
||||
"env_vars": [
|
||||
{"key": "PARALLEL_API_KEY", "prompt": "Parallel API key", "url": "https://parallel.ai"},
|
||||
@@ -201,7 +211,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Tavily",
|
||||
"tag": "AI-native search, extract, and crawl",
|
||||
"badge": "free tier",
|
||||
"tag": "Search, extract, and crawl — 1000 free searches/mo",
|
||||
"web_backend": "tavily",
|
||||
"env_vars": [
|
||||
{"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"},
|
||||
@@ -209,7 +220,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Firecrawl Self-Hosted",
|
||||
"tag": "Free - run your own instance",
|
||||
"badge": "free · self-hosted",
|
||||
"tag": "Run your own Firecrawl instance (Docker)",
|
||||
"web_backend": "firecrawl",
|
||||
"env_vars": [
|
||||
{"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"},
|
||||
@@ -223,6 +235,7 @@ TOOL_CATEGORIES = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
"tag": "Managed FAL image generation billed to your subscription",
|
||||
"env_vars": [],
|
||||
"requires_nous_auth": True,
|
||||
@@ -231,6 +244,7 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "FAL.ai",
|
||||
"badge": "paid",
|
||||
"tag": "FLUX 2 Pro with auto-upscaling",
|
||||
"env_vars": [
|
||||
{"key": "FAL_KEY", "prompt": "FAL API key", "url": "https://fal.ai/dashboard/keys"},
|
||||
@@ -244,6 +258,7 @@ TOOL_CATEGORIES = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "Nous Subscription (Browser Use cloud)",
|
||||
"badge": "subscription",
|
||||
"tag": "Managed Browser Use billed to your subscription",
|
||||
"env_vars": [],
|
||||
"browser_provider": "browser-use",
|
||||
@@ -254,14 +269,16 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Local Browser",
|
||||
"tag": "Free headless Chromium (no API key needed)",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Headless Chromium, no API key needed",
|
||||
"env_vars": [],
|
||||
"browser_provider": "local",
|
||||
"post_setup": "agent_browser",
|
||||
},
|
||||
{
|
||||
"name": "Browserbase",
|
||||
"tag": "Cloud browser with stealth & proxies",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with stealth and proxies",
|
||||
"env_vars": [
|
||||
{"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
|
||||
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
|
||||
@@ -271,6 +288,7 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Browser Use",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
|
||||
@@ -280,6 +298,7 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Firecrawl",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
|
||||
@@ -289,7 +308,8 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
{
|
||||
"name": "Camofox",
|
||||
"tag": "Local anti-detection browser (Firefox/Camoufox)",
|
||||
"badge": "free · local",
|
||||
"tag": "Anti-detection browser (Firefox/Camoufox)",
|
||||
"env_vars": [
|
||||
{"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377",
|
||||
"url": "https://github.com/jo-inc/camofox-browser"},
|
||||
@@ -362,7 +382,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
|
||||
|
||||
elif post_setup_key == "camofox":
|
||||
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camoufox-browser"
|
||||
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser"
|
||||
if not camofox_dir.exists() and shutil.which("npm"):
|
||||
_print_info(" Installing Camofox browser server...")
|
||||
import subprocess
|
||||
@@ -376,7 +396,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_warning(" npm install failed - run manually: npm install")
|
||||
if camofox_dir.exists():
|
||||
_print_info(" Start the Camofox server:")
|
||||
_print_info(" npx @askjo/camoufox-browser")
|
||||
_print_info(" npx @askjo/camofox-browser")
|
||||
_print_info(" First run downloads the Camoufox engine (~300MB)")
|
||||
_print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
|
||||
elif not shutil.which("npm"):
|
||||
@@ -426,6 +446,8 @@ def _get_enabled_platforms() -> List[str]:
|
||||
enabled.append("slack")
|
||||
if get_env_value("WHATSAPP_ENABLED"):
|
||||
enabled.append("whatsapp")
|
||||
if get_env_value("QQ_APP_ID"):
|
||||
enabled.append("qqbot")
|
||||
return enabled
|
||||
|
||||
|
||||
@@ -836,7 +858,8 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
# Plain text labels only (no ANSI codes in menu items)
|
||||
provider_choices = []
|
||||
for p in providers:
|
||||
tag = f" ({p['tag']})" if p.get("tag") else ""
|
||||
badge = f" [{p['badge']}]" if p.get("badge") else ""
|
||||
tag = f" — {p['tag']}" if p.get("tag") else ""
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
@@ -846,7 +869,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
else:
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{tag}{configured}")
|
||||
provider_choices.append(f"{p['name']}{badge}{tag}{configured}")
|
||||
|
||||
# Add skip option
|
||||
provider_choices.append("Skip — keep defaults / configure later")
|
||||
@@ -1102,7 +1125,8 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
|
||||
provider_choices = []
|
||||
for p in providers:
|
||||
tag = f" ({p['tag']})" if p.get("tag") else ""
|
||||
badge = f" [{p['badge']}]" if p.get("badge") else ""
|
||||
tag = f" — {p['tag']}" if p.get("tag") else ""
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
@@ -1112,7 +1136,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
else:
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{tag}{configured}")
|
||||
provider_choices.append(f"{p['name']}{badge}{tag}{configured}")
|
||||
|
||||
default_idx = _detect_active_provider_index(providers, config)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ Provides options for:
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
+307
-38
@@ -10,6 +10,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -48,7 +49,7 @@ from gateway.status import get_running_pid, read_runtime_status
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
@@ -85,6 +86,44 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints that do NOT require the session token. Everything else under
|
||||
# /api/ is gated by the auth middleware below. Keep this list minimal —
|
||||
# only truly non-sensitive, read-only endpoints belong here.
|
||||
# ---------------------------------------------------------------------------
|
||||
_PUBLIC_API_PATHS: frozenset = frozenset({
|
||||
"/api/status",
|
||||
"/api/config/defaults",
|
||||
"/api/config/schema",
|
||||
"/api/model/info",
|
||||
})
|
||||
|
||||
|
||||
def _require_token(request: Request) -> None:
|
||||
"""Validate the ephemeral session token. Raises 401 on mismatch.
|
||||
|
||||
Uses ``hmac.compare_digest`` to prevent timing side-channels.
|
||||
"""
|
||||
auth = request.headers.get("authorization", "")
|
||||
expected = f"Bearer {_SESSION_TOKEN}"
|
||||
if not hmac.compare_digest(auth.encode(), expected.encode()):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
"""Require the session token on all /api/ routes except the public list."""
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
|
||||
auth = request.headers.get("authorization", "")
|
||||
expected = f"Bearer {_SESSION_TOKEN}"
|
||||
if not hmac.compare_digest(auth.encode(), expected.encode()):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Unauthorized"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config schema — auto-generated from DEFAULT_CONFIG
|
||||
@@ -97,6 +136,11 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
|
||||
"category": "general",
|
||||
},
|
||||
"model_context_length": {
|
||||
"type": "number",
|
||||
"description": "Context window override (0 = auto-detect from model metadata)",
|
||||
"category": "general",
|
||||
},
|
||||
"terminal.backend": {
|
||||
"type": "select",
|
||||
"description": "Terminal execution backend",
|
||||
@@ -247,6 +291,17 @@ def _build_schema_from_config(
|
||||
|
||||
CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG)
|
||||
|
||||
# Inject virtual fields that don't live in DEFAULT_CONFIG but are surfaced
|
||||
# by the normalize/denormalize cycle. Insert model_context_length right after
|
||||
# the "model" key so it renders adjacent in the frontend.
|
||||
_mcl_entry = _SCHEMA_OVERRIDES["model_context_length"]
|
||||
_ordered_schema: Dict[str, Dict[str, Any]] = {}
|
||||
for _k, _v in CONFIG_SCHEMA.items():
|
||||
_ordered_schema[_k] = _v
|
||||
if _k == "model":
|
||||
_ordered_schema["model_context_length"] = _mcl_entry
|
||||
CONFIG_SCHEMA = _ordered_schema
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
config: dict
|
||||
@@ -265,12 +320,68 @@ class EnvVarReveal(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
|
||||
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
|
||||
|
||||
|
||||
def _probe_gateway_health() -> tuple[bool, dict | None]:
|
||||
"""Probe the gateway via its HTTP health endpoint (cross-container).
|
||||
|
||||
Uses ``/health/detailed`` first (returns full state), falling back to
|
||||
the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``.
|
||||
|
||||
Accepts any of these as ``GATEWAY_HEALTH_URL``:
|
||||
- ``http://gateway:8642`` (base URL — recommended)
|
||||
- ``http://gateway:8642/health`` (explicit health path)
|
||||
- ``http://gateway:8642/health/detailed`` (explicit detailed path)
|
||||
|
||||
This is a **blocking** call — run via ``run_in_executor`` from async code.
|
||||
"""
|
||||
if not _GATEWAY_HEALTH_URL:
|
||||
return False, None
|
||||
|
||||
# Normalise to base URL so we always probe the right paths regardless of
|
||||
# whether the user included /health or /health/detailed in the env var.
|
||||
base = _GATEWAY_HEALTH_URL.rstrip("/")
|
||||
if base.endswith("/health/detailed"):
|
||||
base = base[: -len("/health/detailed")]
|
||||
elif base.endswith("/health"):
|
||||
base = base[: -len("/health")]
|
||||
|
||||
for path in (f"{base}/health/detailed", f"{base}/health"):
|
||||
try:
|
||||
req = urllib.request.Request(path, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=_GATEWAY_HEALTH_TIMEOUT) as resp:
|
||||
if resp.status == 200:
|
||||
body = json.loads(resp.read())
|
||||
return True, body
|
||||
except Exception:
|
||||
continue
|
||||
return False, None
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
# --- Gateway liveness detection ---
|
||||
# Try local PID check first (same-host). If that fails and a remote
|
||||
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
|
||||
# dashboard works when the gateway runs in a separate container.
|
||||
gateway_pid = get_running_pid()
|
||||
gateway_running = gateway_pid is not None
|
||||
remote_health_body: dict | None = None
|
||||
|
||||
if not gateway_running and _GATEWAY_HEALTH_URL:
|
||||
loop = asyncio.get_event_loop()
|
||||
alive, remote_health_body = await loop.run_in_executor(
|
||||
None, _probe_gateway_health
|
||||
)
|
||||
if alive:
|
||||
gateway_running = True
|
||||
# PID from the remote container (display only — not locally valid)
|
||||
if remote_health_body:
|
||||
gateway_pid = remote_health_body.get("pid")
|
||||
|
||||
gateway_state = None
|
||||
gateway_platforms: dict = {}
|
||||
@@ -287,7 +398,12 @@ async def get_status():
|
||||
except Exception:
|
||||
configured_gateway_platforms = None
|
||||
|
||||
# Prefer the detailed health endpoint response (has full state) when the
|
||||
# local runtime status file is absent or stale (cross-container).
|
||||
runtime = read_runtime_status()
|
||||
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
|
||||
runtime = remote_health_body
|
||||
|
||||
if runtime:
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
gateway_platforms = runtime.get("platforms") or {}
|
||||
@@ -302,6 +418,17 @@ async def get_status():
|
||||
if not gateway_running:
|
||||
gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped"
|
||||
gateway_platforms = {}
|
||||
elif gateway_running and remote_health_body is not None:
|
||||
# The health probe confirmed the gateway is alive, but the local
|
||||
# runtime status file may be stale (cross-container). Override
|
||||
# stopped/None state so the dashboard shows the correct badge.
|
||||
if gateway_state in (None, "stopped"):
|
||||
gateway_state = "running"
|
||||
|
||||
# If there was no runtime info at all but the health probe confirmed alive,
|
||||
# ensure we still report the gateway as running (no shared volume scenario).
|
||||
if gateway_running and gateway_state is None and remote_health_body is not None:
|
||||
gateway_state = "running"
|
||||
|
||||
active_sessions = 0
|
||||
try:
|
||||
@@ -409,11 +536,19 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built
|
||||
from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the
|
||||
dict form. Normalize to the string form so the frontend schema matches.
|
||||
|
||||
Also surfaces ``model_context_length`` as a top-level field so the web UI can
|
||||
display and edit it. A value of 0 means "auto-detect".
|
||||
"""
|
||||
config = dict(config) # shallow copy
|
||||
model_val = config.get("model")
|
||||
if isinstance(model_val, dict):
|
||||
# Extract context_length before flattening the dict
|
||||
ctx_len = model_val.get("context_length", 0)
|
||||
config["model"] = model_val.get("default", model_val.get("name", ""))
|
||||
config["model_context_length"] = ctx_len if isinstance(ctx_len, int) else 0
|
||||
else:
|
||||
config["model_context_length"] = 0
|
||||
return config
|
||||
|
||||
|
||||
@@ -434,6 +569,93 @@ async def get_schema():
|
||||
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
|
||||
|
||||
|
||||
_EMPTY_MODEL_INFO: dict = {
|
||||
"model": "",
|
||||
"provider": "",
|
||||
"auto_context_length": 0,
|
||||
"config_context_length": 0,
|
||||
"effective_context_length": 0,
|
||||
"capabilities": {},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/model/info")
|
||||
def get_model_info():
|
||||
"""Return resolved model metadata for the currently configured model.
|
||||
|
||||
Calls the same context-length resolution chain the agent uses, so the
|
||||
frontend can display "Auto-detected: 200K" alongside the override field.
|
||||
Also returns model capabilities (vision, reasoning, tools) when available.
|
||||
"""
|
||||
try:
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", "")
|
||||
|
||||
# Extract model name and provider from the config
|
||||
if isinstance(model_cfg, dict):
|
||||
model_name = model_cfg.get("default", model_cfg.get("name", ""))
|
||||
provider = model_cfg.get("provider", "")
|
||||
base_url = model_cfg.get("base_url", "")
|
||||
config_ctx = model_cfg.get("context_length")
|
||||
else:
|
||||
model_name = str(model_cfg) if model_cfg else ""
|
||||
provider = ""
|
||||
base_url = ""
|
||||
config_ctx = None
|
||||
|
||||
if not model_name:
|
||||
return dict(_EMPTY_MODEL_INFO, provider=provider)
|
||||
|
||||
# Resolve auto-detected context length (pass config_ctx=None to get
|
||||
# purely auto-detected value, then separately report the override)
|
||||
try:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
auto_ctx = get_model_context_length(
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
provider=provider,
|
||||
config_context_length=None, # ignore override — we want auto value
|
||||
)
|
||||
except Exception:
|
||||
auto_ctx = 0
|
||||
|
||||
config_ctx_int = 0
|
||||
if isinstance(config_ctx, int) and config_ctx > 0:
|
||||
config_ctx_int = config_ctx
|
||||
|
||||
# Effective is what the agent actually uses
|
||||
effective_ctx = config_ctx_int if config_ctx_int > 0 else auto_ctx
|
||||
|
||||
# Try to get model capabilities from models.dev
|
||||
caps = {}
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
mc = get_model_capabilities(provider=provider, model=model_name)
|
||||
if mc is not None:
|
||||
caps = {
|
||||
"supports_tools": mc.supports_tools,
|
||||
"supports_vision": mc.supports_vision,
|
||||
"supports_reasoning": mc.supports_reasoning,
|
||||
"context_window": mc.context_window,
|
||||
"max_output_tokens": mc.max_output_tokens,
|
||||
"model_family": mc.model_family,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"model": model_name,
|
||||
"provider": provider,
|
||||
"auto_context_length": auto_ctx,
|
||||
"config_context_length": config_ctx_int,
|
||||
"effective_context_length": effective_ctx,
|
||||
"capabilities": caps,
|
||||
}
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/info failed")
|
||||
return dict(_EMPTY_MODEL_INFO)
|
||||
|
||||
|
||||
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reverse _normalize_config_for_web before saving.
|
||||
|
||||
@@ -441,12 +663,24 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
to recover model subkeys (provider, base_url, api_mode, etc.) that were
|
||||
stripped from the GET response. The frontend only sees model as a flat
|
||||
string; the rest is preserved transparently.
|
||||
|
||||
Also handles ``model_context_length`` — writes it back into the model dict
|
||||
as ``context_length``. A value of 0 or absent means "auto-detect" (omitted
|
||||
from the dict so get_model_context_length() uses its normal resolution).
|
||||
"""
|
||||
config = dict(config)
|
||||
# Remove any _model_meta that might have leaked in (shouldn't happen
|
||||
# with the stripped GET response, but be defensive)
|
||||
config.pop("_model_meta", None)
|
||||
|
||||
# Extract and remove model_context_length before processing model
|
||||
ctx_override = config.pop("model_context_length", 0)
|
||||
if not isinstance(ctx_override, int):
|
||||
try:
|
||||
ctx_override = int(ctx_override)
|
||||
except (TypeError, ValueError):
|
||||
ctx_override = 0
|
||||
|
||||
model_val = config.get("model")
|
||||
if isinstance(model_val, str) and model_val:
|
||||
# Read the current disk config to recover model subkeys
|
||||
@@ -456,7 +690,20 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if isinstance(disk_model, dict):
|
||||
# Preserve all subkeys, update default with the new value
|
||||
disk_model["default"] = model_val
|
||||
# Write context_length into the model dict (0 = remove/auto)
|
||||
if ctx_override > 0:
|
||||
disk_model["context_length"] = ctx_override
|
||||
else:
|
||||
disk_model.pop("context_length", None)
|
||||
config["model"] = disk_model
|
||||
else:
|
||||
# Model was previously a bare string — upgrade to dict if
|
||||
# user is setting a context_length override
|
||||
if ctx_override > 0:
|
||||
config["model"] = {
|
||||
"default": model_val,
|
||||
"context_length": ctx_override,
|
||||
}
|
||||
except Exception:
|
||||
pass # can't read disk config — just use the string form
|
||||
return config
|
||||
@@ -472,17 +719,6 @@ async def update_config(body: ConfigUpdate):
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@app.get("/api/auth/session-token")
|
||||
async def get_session_token():
|
||||
"""Return the ephemeral session token for this server instance.
|
||||
|
||||
The token protects sensitive endpoints (reveal). It's served to the SPA
|
||||
which stores it in memory — it's never persisted and dies when the server
|
||||
process exits. CORS already restricts this to localhost origins.
|
||||
"""
|
||||
return {"token": _SESSION_TOKEN}
|
||||
|
||||
|
||||
@app.get("/api/env")
|
||||
async def get_env_vars():
|
||||
env_on_disk = load_env()
|
||||
@@ -536,9 +772,7 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
|
||||
- Audit logging
|
||||
"""
|
||||
# --- Token check ---
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_require_token(request)
|
||||
|
||||
# --- Rate limit ---
|
||||
now = time.time()
|
||||
@@ -809,9 +1043,7 @@ async def list_oauth_providers():
|
||||
@app.delete("/api/providers/oauth/{provider_id}")
|
||||
async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_require_token(request)
|
||||
|
||||
valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||
if provider_id not in valid_ids:
|
||||
@@ -1217,6 +1449,22 @@ def _nous_poller(session_id: str) -> None:
|
||||
"base_url": full_state.get("inference_base_url"),
|
||||
})
|
||||
pool.add_entry(entry)
|
||||
# Also persist to auth store so get_nous_auth_status() sees it
|
||||
# (matches what _login_nous in auth.py does for the CLI flow).
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
_load_auth_store, _save_provider_state, _save_auth_store,
|
||||
_auth_store_lock,
|
||||
)
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
_save_provider_state(auth_store, "nous", full_state)
|
||||
_save_auth_store(auth_store)
|
||||
except Exception as store_exc:
|
||||
_log.warning(
|
||||
"oauth/device: credential pool saved but auth store write failed "
|
||||
"(session=%s): %s", session_id, store_exc,
|
||||
)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: nous login completed (session=%s)", session_id)
|
||||
@@ -1367,9 +1615,7 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
@app.post("/api/providers/oauth/{provider_id}/start")
|
||||
async def start_oauth_login(provider_id: str, request: Request):
|
||||
"""Initiate an OAuth login flow. Token-protected."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_require_token(request)
|
||||
_gc_oauth_sessions()
|
||||
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||
if provider_id not in valid:
|
||||
@@ -1401,9 +1647,7 @@ class OAuthSubmitBody(BaseModel):
|
||||
@app.post("/api/providers/oauth/{provider_id}/submit")
|
||||
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
|
||||
"""Submit the auth code for PKCE flows. Token-protected."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_require_token(request)
|
||||
if provider_id == "anthropic":
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, _submit_anthropic_pkce, body.session_id, body.code,
|
||||
@@ -1431,9 +1675,7 @@ async def poll_oauth_session(provider_id: str, session_id: str):
|
||||
@app.delete("/api/providers/oauth/sessions/{session_id}")
|
||||
async def cancel_oauth_session(session_id: str, request: Request):
|
||||
"""Cancel a pending OAuth session. Token-protected."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_require_token(request)
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.pop(session_id, None)
|
||||
if sess is None:
|
||||
@@ -1781,7 +2023,12 @@ async def get_usage_analytics(days: int = 30):
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing."""
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||
|
||||
The session token is injected into index.html via a ``<script>`` tag so
|
||||
the SPA can authenticate against protected API endpoints without a
|
||||
separate (unauthenticated) token-dispensing endpoint.
|
||||
"""
|
||||
if not WEB_DIST.exists():
|
||||
@application.get("/{full_path:path}")
|
||||
async def no_frontend(full_path: str):
|
||||
@@ -1791,6 +2038,20 @@ def mount_spa(application: FastAPI):
|
||||
)
|
||||
return
|
||||
|
||||
_index_path = WEB_DIST / "index.html"
|
||||
|
||||
def _serve_index():
|
||||
"""Return index.html with the session token injected."""
|
||||
html = _index_path.read_text()
|
||||
token_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";</script>'
|
||||
)
|
||||
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||
return HTMLResponse(
|
||||
html,
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
@application.get("/{full_path:path}")
|
||||
@@ -1804,24 +2065,32 @@ def mount_spa(application: FastAPI):
|
||||
and file_path.is_file()
|
||||
):
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(
|
||||
WEB_DIST / "index.html",
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
return _serve_index()
|
||||
|
||||
|
||||
mount_spa(app)
|
||||
|
||||
|
||||
def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True):
|
||||
def start_server(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 9119,
|
||||
open_browser: bool = True,
|
||||
allow_public: bool = False,
|
||||
):
|
||||
"""Start the web UI server."""
|
||||
import uvicorn
|
||||
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
import logging
|
||||
logging.warning(
|
||||
"Binding to %s — the web UI exposes config and API keys. "
|
||||
"Only bind to non-localhost if you trust all users on the network.", host,
|
||||
_LOCALHOST = ("127.0.0.1", "localhost", "::1")
|
||||
if host not in _LOCALHOST and not allow_public:
|
||||
raise SystemExit(
|
||||
f"Refusing to bind to {host} — the dashboard exposes API keys "
|
||||
f"and config without robust authentication.\n"
|
||||
f"Use --insecure to override (NOT recommended on untrusted networks)."
|
||||
)
|
||||
if host not in _LOCALHOST:
|
||||
_log.warning(
|
||||
"Binding to %s with --insecure — the dashboard has no robust "
|
||||
"authentication. Only use on trusted networks.", host,
|
||||
)
|
||||
|
||||
if open_browser:
|
||||
|
||||
@@ -237,10 +237,6 @@ def get_skills_dir() -> Path:
|
||||
return get_hermes_home() / "skills"
|
||||
|
||||
|
||||
def get_logs_dir() -> Path:
|
||||
"""Return the path to the logs directory under HERMES_HOME."""
|
||||
return get_hermes_home() / "logs"
|
||||
|
||||
|
||||
def get_env_path() -> Path:
|
||||
"""Return the path to the ``.env`` file under HERMES_HOME."""
|
||||
@@ -296,5 +292,3 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
||||
|
||||
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|
||||
|
||||
NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
|
||||
+2
-6
@@ -79,12 +79,7 @@ def set_session_context(session_id: str) -> None:
|
||||
|
||||
|
||||
def clear_session_context() -> None:
|
||||
"""Clear the session ID for the current thread.
|
||||
|
||||
Optional — ``set_session_context()`` overwrites the previous value,
|
||||
so explicit clearing is only needed if the thread is reused for
|
||||
non-conversation work after ``run_conversation()`` returns.
|
||||
"""
|
||||
"""Clear the session ID for the current thread."""
|
||||
_session_context.session_id = None
|
||||
|
||||
|
||||
@@ -363,6 +358,7 @@ def _add_rotating_handler(
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = _ManagedRotatingFileHandler(
|
||||
str(path), maxBytes=max_bytes, backupCount=backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
+46
-61
@@ -26,7 +26,7 @@ import logging
|
||||
import threading
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from tools.registry import registry
|
||||
from tools.registry import discover_builtin_tools, registry
|
||||
from toolsets import resolve_toolset, validate_toolset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -129,45 +129,7 @@ def _run_async(coro):
|
||||
# Tool Discovery (importing each module triggers its registry.register calls)
|
||||
# =============================================================================
|
||||
|
||||
def _discover_tools():
|
||||
"""Import all tool modules to trigger their registry.register() calls.
|
||||
|
||||
Wrapped in a function so import errors in optional tools (e.g., fal_client
|
||||
not installed) don't prevent the rest from loading.
|
||||
"""
|
||||
_modules = [
|
||||
"tools.web_tools",
|
||||
"tools.terminal_tool",
|
||||
"tools.file_tools",
|
||||
"tools.vision_tools",
|
||||
"tools.mixture_of_agents_tool",
|
||||
"tools.image_generation_tool",
|
||||
"tools.skills_tool",
|
||||
"tools.skill_manager_tool",
|
||||
"tools.browser_tool",
|
||||
"tools.cronjob_tools",
|
||||
"tools.rl_training_tool",
|
||||
"tools.tts_tool",
|
||||
"tools.todo_tool",
|
||||
"tools.memory_tool",
|
||||
"tools.session_search_tool",
|
||||
"tools.clarify_tool",
|
||||
"tools.code_execution_tool",
|
||||
"tools.delegate_tool",
|
||||
"tools.process_registry",
|
||||
"tools.send_message_tool",
|
||||
# "tools.honcho_tools", # Removed — Honcho is now a memory provider plugin
|
||||
"tools.homeassistant_tool",
|
||||
]
|
||||
import importlib
|
||||
for mod_name in _modules:
|
||||
try:
|
||||
importlib.import_module(mod_name)
|
||||
except Exception as e:
|
||||
logger.warning("Could not import tool module %s: %s", mod_name, e)
|
||||
|
||||
|
||||
_discover_tools()
|
||||
discover_builtin_tools()
|
||||
|
||||
# MCP tool discovery (external MCP servers from config)
|
||||
try:
|
||||
@@ -464,6 +426,7 @@ def handle_function_call(
|
||||
session_id: Optional[str] = None,
|
||||
user_task: Optional[str] = None,
|
||||
enabled_tools: Optional[List[str]] = None,
|
||||
skip_pre_tool_call_hook: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Main function call dispatcher that routes calls to the tool registry.
|
||||
@@ -484,31 +447,53 @@ def handle_function_call(
|
||||
# Coerce string arguments to their schema-declared types (e.g. "42"→42)
|
||||
function_args = coerce_tool_args(function_name, function_args)
|
||||
|
||||
# Notify the read-loop tracker when a non-read/search tool runs,
|
||||
# so the *consecutive* counter resets (reads after other work are fine).
|
||||
if function_name not in _READ_SEARCH_TOOLS:
|
||||
try:
|
||||
from tools.file_tools import notify_other_tool_call
|
||||
notify_other_tool_call(task_id or "default")
|
||||
except Exception:
|
||||
pass # file_tools may not be loaded yet
|
||||
|
||||
try:
|
||||
if function_name in _AGENT_LOOP_TOOLS:
|
||||
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook
|
||||
invoke_hook(
|
||||
"pre_tool_call",
|
||||
tool_name=function_name,
|
||||
args=function_args,
|
||||
task_id=task_id or "",
|
||||
session_id=session_id or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Check plugin hooks for a block directive (unless caller already
|
||||
# checked — e.g. run_agent._invoke_tool passes skip=True to
|
||||
# avoid double-firing the hook).
|
||||
if not skip_pre_tool_call_hook:
|
||||
block_message: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=task_id or "",
|
||||
session_id=session_id or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if block_message is not None:
|
||||
return json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
else:
|
||||
# Still fire the hook for observers — just don't check for blocking
|
||||
# (the caller already did that).
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook
|
||||
invoke_hook(
|
||||
"pre_tool_call",
|
||||
tool_name=function_name,
|
||||
args=function_args,
|
||||
task_id=task_id or "",
|
||||
session_id=session_id or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Notify the read-loop tracker when a non-read/search tool runs,
|
||||
# so the *consecutive* counter resets (reads after other work are fine).
|
||||
if function_name not in _READ_SEARCH_TOOLS:
|
||||
try:
|
||||
from tools.file_tools import notify_other_tool_call
|
||||
notify_other_tool_call(task_id or "default")
|
||||
except Exception:
|
||||
pass # file_tools may not be loaded yet
|
||||
|
||||
if function_name == "execute_code":
|
||||
# Prefer the caller-provided list so subagents can't overwrite
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: fitness-nutrition
|
||||
description: >
|
||||
Gym workout planner and nutrition tracker. Search 690+ exercises by muscle,
|
||||
equipment, or category via wger. Look up macros and calories for 380,000+
|
||||
foods via USDA FoodData Central. Compute BMI, TDEE, one-rep max, macro
|
||||
splits, and body fat — pure Python, no pip installs. Built for anyone
|
||||
chasing gains, cutting weight, or just trying to eat better.
|
||||
version: 1.0.0
|
||||
authors:
|
||||
- haileymarshall
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [health, fitness, nutrition, gym, workout, diet, exercise]
|
||||
category: health
|
||||
prerequisites:
|
||||
commands: [curl, python3]
|
||||
required_environment_variables:
|
||||
- name: USDA_API_KEY
|
||||
prompt: "USDA FoodData Central API key (free)"
|
||||
help: "Get one free at https://fdc.nal.usda.gov/api-key-signup/ — or skip to use DEMO_KEY with lower rate limits"
|
||||
required_for: "higher rate limits on food/nutrition lookups (DEMO_KEY works without signup)"
|
||||
optional: true
|
||||
---
|
||||
|
||||
# Fitness & Nutrition
|
||||
|
||||
Expert fitness coach and sports nutritionist skill. Two data sources
|
||||
plus offline calculators — everything a gym-goer needs in one place.
|
||||
|
||||
**Data sources (all free, no pip dependencies):**
|
||||
|
||||
- **wger** (https://wger.de/api/v2/) — open exercise database, 690+ exercises with muscles, equipment, images. Public endpoints need zero authentication.
|
||||
- **USDA FoodData Central** (https://api.nal.usda.gov/fdc/v1/) — US government nutrition database, 380,000+ foods. `DEMO_KEY` works instantly; free signup for higher limits.
|
||||
|
||||
**Offline calculators (pure stdlib Python):**
|
||||
|
||||
- BMI, TDEE (Mifflin-St Jeor), one-rep max (Epley/Brzycki/Lombardi), macro splits, body fat % (US Navy method)
|
||||
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger this skill when the user asks about:
|
||||
- Exercises, workouts, gym routines, muscle groups, workout splits
|
||||
- Food macros, calories, protein content, meal planning, calorie counting
|
||||
- Body composition: BMI, body fat, TDEE, caloric surplus/deficit
|
||||
- One-rep max estimates, training percentages, progressive overload
|
||||
- Macro ratios for cutting, bulking, or maintenance
|
||||
|
||||
---
|
||||
|
||||
## Procedure
|
||||
|
||||
### Exercise Lookup (wger API)
|
||||
|
||||
All wger public endpoints return JSON and require no auth. Always add
|
||||
`format=json` and `language=2` (English) to exercise queries.
|
||||
|
||||
**Step 1 — Identify what the user wants:**
|
||||
|
||||
- By muscle → use `/api/v2/exercise/?muscles={id}&language=2&status=2&format=json`
|
||||
- By category → use `/api/v2/exercise/?category={id}&language=2&status=2&format=json`
|
||||
- By equipment → use `/api/v2/exercise/?equipment={id}&language=2&status=2&format=json`
|
||||
- By name → use `/api/v2/exercise/search/?term={query}&language=english&format=json`
|
||||
- Full details → use `/api/v2/exerciseinfo/{exercise_id}/?format=json`
|
||||
|
||||
**Step 2 — Reference IDs (so you don't need extra API calls):**
|
||||
|
||||
Exercise categories:
|
||||
|
||||
| ID | Category |
|
||||
|----|-------------|
|
||||
| 8 | Arms |
|
||||
| 9 | Legs |
|
||||
| 10 | Abs |
|
||||
| 11 | Chest |
|
||||
| 12 | Back |
|
||||
| 13 | Shoulders |
|
||||
| 14 | Calves |
|
||||
| 15 | Cardio |
|
||||
|
||||
Muscles:
|
||||
|
||||
| ID | Muscle | ID | Muscle |
|
||||
|----|---------------------------|----|-------------------------|
|
||||
| 1 | Biceps brachii | 2 | Anterior deltoid |
|
||||
| 3 | Serratus anterior | 4 | Pectoralis major |
|
||||
| 5 | Obliquus externus | 6 | Gastrocnemius |
|
||||
| 7 | Rectus abdominis | 8 | Gluteus maximus |
|
||||
| 9 | Trapezius | 10 | Quadriceps femoris |
|
||||
| 11 | Biceps femoris | 12 | Latissimus dorsi |
|
||||
| 13 | Brachialis | 14 | Triceps brachii |
|
||||
| 15 | Soleus | | |
|
||||
|
||||
Equipment:
|
||||
|
||||
| ID | Equipment |
|
||||
|----|----------------|
|
||||
| 1 | Barbell |
|
||||
| 3 | Dumbbell |
|
||||
| 4 | Gym mat |
|
||||
| 5 | Swiss Ball |
|
||||
| 6 | Pull-up bar |
|
||||
| 7 | none (bodyweight) |
|
||||
| 8 | Bench |
|
||||
| 9 | Incline bench |
|
||||
| 10 | Kettlebell |
|
||||
|
||||
**Step 3 — Fetch and present results:**
|
||||
|
||||
```bash
|
||||
# Search exercises by name
|
||||
QUERY="$1"
|
||||
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
|
||||
curl -s "https://wger.de/api/v2/exercise/search/?term=${ENCODED}&language=english&format=json" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
for s in data.get('suggestions',[])[:10]:
|
||||
d=s.get('data',{})
|
||||
print(f\" ID {d.get('id','?'):>4} | {d.get('name','N/A'):<35} | Category: {d.get('category','N/A')}\")
|
||||
"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Get full details for a specific exercise
|
||||
EXERCISE_ID="$1"
|
||||
curl -s "https://wger.de/api/v2/exerciseinfo/${EXERCISE_ID}/?format=json" \
|
||||
| python3 -c "
|
||||
import json,sys,html,re
|
||||
data=json.load(sys.stdin)
|
||||
trans=[t for t in data.get('translations',[]) if t.get('language')==2]
|
||||
t=trans[0] if trans else data.get('translations',[{}])[0]
|
||||
desc=re.sub('<[^>]+>','',html.unescape(t.get('description','N/A')))
|
||||
print(f\"Exercise : {t.get('name','N/A')}\")
|
||||
print(f\"Category : {data.get('category',{}).get('name','N/A')}\")
|
||||
print(f\"Primary : {', '.join(m.get('name_en','') for m in data.get('muscles',[])) or 'N/A'}\")
|
||||
print(f\"Secondary : {', '.join(m.get('name_en','') for m in data.get('muscles_secondary',[])) or 'none'}\")
|
||||
print(f\"Equipment : {', '.join(e.get('name','') for e in data.get('equipment',[])) or 'bodyweight'}\")
|
||||
print(f\"How to : {desc[:500]}\")
|
||||
imgs=data.get('images',[])
|
||||
if imgs: print(f\"Image : {imgs[0].get('image','')}\")
|
||||
"
|
||||
```
|
||||
|
||||
```bash
|
||||
# List exercises filtering by muscle, category, or equipment
|
||||
# Combine filters as needed: ?muscles=4&equipment=1&language=2&status=2
|
||||
FILTER="$1" # e.g. "muscles=4" or "category=11" or "equipment=3"
|
||||
curl -s "https://wger.de/api/v2/exercise/?${FILTER}&language=2&status=2&limit=20&format=json" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
print(f'Found {data.get(\"count\",0)} exercises.')
|
||||
for ex in data.get('results',[]):
|
||||
print(f\" ID {ex['id']:>4} | muscles: {ex.get('muscles',[])} | equipment: {ex.get('equipment',[])}\")
|
||||
"
|
||||
```
|
||||
|
||||
### Nutrition Lookup (USDA FoodData Central)
|
||||
|
||||
Uses `USDA_API_KEY` env var if set, otherwise falls back to `DEMO_KEY`.
|
||||
DEMO_KEY = 30 requests/hour. Free signup key = 1,000 requests/hour.
|
||||
|
||||
```bash
|
||||
# Search foods by name
|
||||
FOOD="$1"
|
||||
API_KEY="${USDA_API_KEY:-DEMO_KEY}"
|
||||
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$FOOD")
|
||||
curl -s "https://api.nal.usda.gov/fdc/v1/foods/search?api_key=${API_KEY}&query=${ENCODED}&pageSize=5&dataType=Foundation,SR%20Legacy" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
foods=data.get('foods',[])
|
||||
if not foods: print('No foods found.'); sys.exit()
|
||||
for f in foods:
|
||||
n={x['nutrientName']:x.get('value','?') for x in f.get('foodNutrients',[])}
|
||||
cal=n.get('Energy','?'); prot=n.get('Protein','?')
|
||||
fat=n.get('Total lipid (fat)','?'); carb=n.get('Carbohydrate, by difference','?')
|
||||
print(f\"{f.get('description','N/A')}\")
|
||||
print(f\" Per 100g: {cal} kcal | {prot}g protein | {fat}g fat | {carb}g carbs\")
|
||||
print(f\" FDC ID: {f.get('fdcId','N/A')}\")
|
||||
print()
|
||||
"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Detailed nutrient profile by FDC ID
|
||||
FDC_ID="$1"
|
||||
API_KEY="${USDA_API_KEY:-DEMO_KEY}"
|
||||
curl -s "https://api.nal.usda.gov/fdc/v1/food/${FDC_ID}?api_key=${API_KEY}" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(f\"Food: {d.get('description','N/A')}\")
|
||||
print(f\"{'Nutrient':<40} {'Amount':>8} {'Unit'}\")
|
||||
print('-'*56)
|
||||
for x in sorted(d.get('foodNutrients',[]),key=lambda x:x.get('nutrient',{}).get('rank',9999)):
|
||||
nut=x.get('nutrient',{}); amt=x.get('amount',0)
|
||||
if amt and float(amt)>0:
|
||||
print(f\" {nut.get('name',''):<38} {amt:>8} {nut.get('unitName','')}\")
|
||||
"
|
||||
```
|
||||
|
||||
### Offline Calculators
|
||||
|
||||
Use the helper scripts in `scripts/` for batch operations,
|
||||
or run inline for single calculations:
|
||||
|
||||
- `python3 scripts/body_calc.py bmi <weight_kg> <height_cm>`
|
||||
- `python3 scripts/body_calc.py tdee <weight_kg> <height_cm> <age> <M|F> <activity 1-5>`
|
||||
- `python3 scripts/body_calc.py 1rm <weight> <reps>`
|
||||
- `python3 scripts/body_calc.py macros <tdee_kcal> <cut|maintain|bulk>`
|
||||
- `python3 scripts/body_calc.py bodyfat <M|F> <neck_cm> <waist_cm> [hip_cm] <height_cm>`
|
||||
|
||||
See `references/FORMULAS.md` for the science behind each formula.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- wger exercise endpoint returns **all languages by default** — always add `language=2` for English
|
||||
- wger includes **unverified user submissions** — add `status=2` to only get approved exercises
|
||||
- USDA `DEMO_KEY` has **30 req/hour** — add `sleep 2` between batch requests or get a free key
|
||||
- USDA data is **per 100g** — remind users to scale to their actual portion size
|
||||
- BMI does not distinguish muscle from fat — high BMI in muscular people is not necessarily unhealthy
|
||||
- Body fat formulas are **estimates** (±3-5%) — recommend DEXA scans for precision
|
||||
- 1RM formulas lose accuracy above 10 reps — use sets of 3-5 for best estimates
|
||||
- wger's `exercise/search` endpoint uses `term` not `query` as the parameter name
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After running exercise search: confirm results include exercise names, muscle groups, and equipment.
|
||||
After nutrition lookup: confirm per-100g macros are returned with kcal, protein, fat, carbs.
|
||||
After calculators: sanity-check outputs (e.g. TDEE should be 1500-3500 for most adults).
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Source | Endpoint |
|
||||
|------|--------|----------|
|
||||
| Search exercises by name | wger | `GET /api/v2/exercise/search/?term=&language=english` |
|
||||
| Exercise details | wger | `GET /api/v2/exerciseinfo/{id}/` |
|
||||
| Filter by muscle | wger | `GET /api/v2/exercise/?muscles={id}&language=2&status=2` |
|
||||
| Filter by equipment | wger | `GET /api/v2/exercise/?equipment={id}&language=2&status=2` |
|
||||
| List categories | wger | `GET /api/v2/exercisecategory/` |
|
||||
| List muscles | wger | `GET /api/v2/muscle/` |
|
||||
| Search foods | USDA | `GET /fdc/v1/foods/search?query=&dataType=Foundation,SR Legacy` |
|
||||
| Food details | USDA | `GET /fdc/v1/food/{fdcId}` |
|
||||
| BMI / TDEE / 1RM / macros | offline | `python3 scripts/body_calc.py` |
|
||||
@@ -0,0 +1,100 @@
|
||||
# Formulas Reference
|
||||
|
||||
Scientific references for all calculators used in the fitness-nutrition skill.
|
||||
|
||||
## BMI (Body Mass Index)
|
||||
|
||||
**Formula:** BMI = weight (kg) / height (m)²
|
||||
|
||||
| Category | BMI Range |
|
||||
|-------------|------------|
|
||||
| Underweight | < 18.5 |
|
||||
| Normal | 18.5 – 24.9 |
|
||||
| Overweight | 25.0 – 29.9 |
|
||||
| Obese | 30.0+ |
|
||||
|
||||
**Limitation:** BMI does not distinguish muscle from fat. A muscular person
|
||||
can have a high BMI while being lean. Use body fat % for a better picture.
|
||||
|
||||
Reference: Quetelet, A. (1832). Keys et al., Int J Obes (1972).
|
||||
|
||||
## TDEE (Total Daily Energy Expenditure)
|
||||
|
||||
Uses the **Mifflin-St Jeor equation** — the most accurate BMR predictor for
|
||||
the general population according to the ADA (2005).
|
||||
|
||||
**BMR formulas:**
|
||||
|
||||
- Male: BMR = 10 × weight(kg) + 6.25 × height(cm) − 5 × age + 5
|
||||
- Female: BMR = 10 × weight(kg) + 6.25 × height(cm) − 5 × age − 161
|
||||
|
||||
**Activity multipliers:**
|
||||
|
||||
| Level | Description | Multiplier |
|
||||
|-------|--------------------------------|------------|
|
||||
| 1 | Sedentary (desk job) | 1.200 |
|
||||
| 2 | Lightly active (1-3 days/wk) | 1.375 |
|
||||
| 3 | Moderately active (3-5 days) | 1.550 |
|
||||
| 4 | Very active (6-7 days) | 1.725 |
|
||||
| 5 | Extremely active (2x/day) | 1.900 |
|
||||
|
||||
Reference: Mifflin et al., Am J Clin Nutr 51, 241-247 (1990).
|
||||
|
||||
## One-Rep Max (1RM)
|
||||
|
||||
Three validated formulas. Average of all three is most reliable.
|
||||
|
||||
- **Epley:** 1RM = w × (1 + r/30)
|
||||
- **Brzycki:** 1RM = w × 36 / (37 − r)
|
||||
- **Lombardi:** 1RM = w × r^0.1
|
||||
|
||||
All formulas are most accurate for r ≤ 10. Above 10 reps, error increases.
|
||||
|
||||
Reference: LeSuer et al., J Strength Cond Res 11(4), 211-213 (1997).
|
||||
|
||||
## Macro Splits
|
||||
|
||||
Recommended splits based on goal:
|
||||
|
||||
| Goal | Protein | Fat | Carbs | Calorie Offset |
|
||||
|-------------|---------|------|-------|----------------|
|
||||
| Fat loss | 40% | 30% | 30% | −500 kcal |
|
||||
| Maintenance | 30% | 30% | 40% | 0 |
|
||||
| Lean bulk | 30% | 25% | 45% | +400 kcal |
|
||||
|
||||
Protein targets for muscle growth: 1.6–2.2 g/kg body weight per day.
|
||||
Minimum fat intake: 0.5 g/kg to support hormone production.
|
||||
|
||||
Conversion: Protein = 4 kcal/g, Fat = 9 kcal/g, Carbs = 4 kcal/g.
|
||||
|
||||
Reference: Morton et al., Br J Sports Med 52, 376–384 (2018).
|
||||
|
||||
## Body Fat % (US Navy Method)
|
||||
|
||||
**Male:**
|
||||
|
||||
BF% = 86.010 × log₁₀(waist − neck) − 70.041 × log₁₀(height) + 36.76
|
||||
|
||||
**Female:**
|
||||
|
||||
BF% = 163.205 × log₁₀(waist + hip − neck) − 97.684 × log₁₀(height) − 78.387
|
||||
|
||||
All measurements in centimeters.
|
||||
|
||||
| Category | Male | Female |
|
||||
|--------------|--------|--------|
|
||||
| Essential | 2-5% | 10-13% |
|
||||
| Athletic | 6-13% | 14-20% |
|
||||
| Fitness | 14-17% | 21-24% |
|
||||
| Average | 18-24% | 25-31% |
|
||||
| Obese | 25%+ | 32%+ |
|
||||
|
||||
Accuracy: ±3-5% compared to DEXA. Measure at the navel (waist),
|
||||
at the Adam's apple (neck), and widest point (hip, females only).
|
||||
|
||||
Reference: Hodgdon & Beckett, Naval Health Research Center (1984).
|
||||
|
||||
## APIs
|
||||
|
||||
- wger: https://wger.de/api/v2/ — AGPL-3.0, exercise data is CC-BY-SA 3.0
|
||||
- USDA FoodData Central: https://api.nal.usda.gov/fdc/v1/ — public domain (CC0 1.0)
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
body_calc.py — All-in-one fitness calculator.
|
||||
|
||||
Subcommands:
|
||||
bmi <weight_kg> <height_cm>
|
||||
tdee <weight_kg> <height_cm> <age> <M|F> <activity 1-5>
|
||||
1rm <weight> <reps>
|
||||
macros <tdee_kcal> <cut|maintain|bulk>
|
||||
bodyfat <M|F> <neck_cm> <waist_cm> [hip_cm] <height_cm>
|
||||
|
||||
No external dependencies — stdlib only.
|
||||
"""
|
||||
import sys
|
||||
import math
|
||||
|
||||
|
||||
def bmi(weight_kg, height_cm):
|
||||
h = height_cm / 100
|
||||
val = weight_kg / (h * h)
|
||||
if val < 18.5:
|
||||
cat = "Underweight"
|
||||
elif val < 25:
|
||||
cat = "Normal weight"
|
||||
elif val < 30:
|
||||
cat = "Overweight"
|
||||
else:
|
||||
cat = "Obese"
|
||||
print(f"BMI: {val:.1f} — {cat}")
|
||||
print()
|
||||
print("Ranges:")
|
||||
print(f" Underweight : < 18.5")
|
||||
print(f" Normal : 18.5 – 24.9")
|
||||
print(f" Overweight : 25.0 – 29.9")
|
||||
print(f" Obese : 30.0+")
|
||||
|
||||
|
||||
def tdee(weight_kg, height_cm, age, sex, activity):
|
||||
if sex.upper() == "M":
|
||||
bmr = 10 * weight_kg + 6.25 * height_cm - 5 * age + 5
|
||||
else:
|
||||
bmr = 10 * weight_kg + 6.25 * height_cm - 5 * age - 161
|
||||
|
||||
multipliers = {
|
||||
1: ("Sedentary (desk job, no exercise)", 1.2),
|
||||
2: ("Lightly active (1-3 days/week)", 1.375),
|
||||
3: ("Moderately active (3-5 days/week)", 1.55),
|
||||
4: ("Very active (6-7 days/week)", 1.725),
|
||||
5: ("Extremely active (athlete + physical job)", 1.9),
|
||||
}
|
||||
|
||||
label, mult = multipliers.get(activity, ("Moderate", 1.55))
|
||||
total = bmr * mult
|
||||
|
||||
print(f"BMR (Mifflin-St Jeor): {bmr:.0f} kcal/day")
|
||||
print(f"Activity: {label} (x{mult})")
|
||||
print(f"TDEE: {total:.0f} kcal/day")
|
||||
print()
|
||||
print("Calorie targets:")
|
||||
print(f" Aggressive cut (-750): {total - 750:.0f} kcal/day")
|
||||
print(f" Fat loss (-500): {total - 500:.0f} kcal/day")
|
||||
print(f" Mild cut (-250): {total - 250:.0f} kcal/day")
|
||||
print(f" Maintenance : {total:.0f} kcal/day")
|
||||
print(f" Lean bulk (+250): {total + 250:.0f} kcal/day")
|
||||
print(f" Bulk (+500): {total + 500:.0f} kcal/day")
|
||||
|
||||
|
||||
def one_rep_max(weight, reps):
|
||||
if reps < 1:
|
||||
print("Error: reps must be at least 1.")
|
||||
sys.exit(1)
|
||||
if reps == 1:
|
||||
print(f"1RM = {weight:.1f} (actual single)")
|
||||
return
|
||||
|
||||
epley = weight * (1 + reps / 30)
|
||||
brzycki = weight * (36 / (37 - reps)) if reps < 37 else 0
|
||||
lombardi = weight * (reps ** 0.1)
|
||||
avg = (epley + brzycki + lombardi) / 3
|
||||
|
||||
print(f"Estimated 1RM ({weight} x {reps} reps):")
|
||||
print(f" Epley : {epley:.1f}")
|
||||
print(f" Brzycki : {brzycki:.1f}")
|
||||
print(f" Lombardi : {lombardi:.1f}")
|
||||
print(f" Average : {avg:.1f}")
|
||||
print()
|
||||
print("Training percentages off average 1RM:")
|
||||
for pct, rep_range in [
|
||||
(100, "1"), (95, "1-2"), (90, "3-4"), (85, "4-6"),
|
||||
(80, "6-8"), (75, "8-10"), (70, "10-12"),
|
||||
(65, "12-15"), (60, "15-20"),
|
||||
]:
|
||||
print(f" {pct:>3}% = {avg * pct / 100:>7.1f} (~{rep_range} reps)")
|
||||
|
||||
|
||||
def macros(tdee_kcal, goal):
|
||||
goal = goal.lower()
|
||||
if goal in ("cut", "lose", "deficit"):
|
||||
cals = tdee_kcal - 500
|
||||
p, f, c = 0.40, 0.30, 0.30
|
||||
label = "Fat Loss (-500 kcal)"
|
||||
elif goal in ("bulk", "gain", "surplus"):
|
||||
cals = tdee_kcal + 400
|
||||
p, f, c = 0.30, 0.25, 0.45
|
||||
label = "Lean Bulk (+400 kcal)"
|
||||
else:
|
||||
cals = tdee_kcal
|
||||
p, f, c = 0.30, 0.30, 0.40
|
||||
label = "Maintenance"
|
||||
|
||||
prot_g = cals * p / 4
|
||||
fat_g = cals * f / 9
|
||||
carb_g = cals * c / 4
|
||||
|
||||
print(f"Goal: {label}")
|
||||
print(f"Daily calories: {cals:.0f} kcal")
|
||||
print()
|
||||
print(f" Protein : {prot_g:>6.0f}g ({p * 100:.0f}%) = {prot_g * 4:.0f} kcal")
|
||||
print(f" Fat : {fat_g:>6.0f}g ({f * 100:.0f}%) = {fat_g * 9:.0f} kcal")
|
||||
print(f" Carbs : {carb_g:>6.0f}g ({c * 100:.0f}%) = {carb_g * 4:.0f} kcal")
|
||||
print()
|
||||
print(f"Per meal (3 meals): P {prot_g / 3:.0f}g | F {fat_g / 3:.0f}g | C {carb_g / 3:.0f}g")
|
||||
print(f"Per meal (4 meals): P {prot_g / 4:.0f}g | F {fat_g / 4:.0f}g | C {carb_g / 4:.0f}g")
|
||||
|
||||
|
||||
def bodyfat(sex, neck_cm, waist_cm, hip_cm, height_cm):
|
||||
sex = sex.upper()
|
||||
if sex == "M":
|
||||
if waist_cm <= neck_cm:
|
||||
print("Error: waist must be larger than neck."); sys.exit(1)
|
||||
bf = 86.010 * math.log10(waist_cm - neck_cm) - 70.041 * math.log10(height_cm) + 36.76
|
||||
else:
|
||||
if (waist_cm + hip_cm) <= neck_cm:
|
||||
print("Error: waist + hip must be larger than neck."); sys.exit(1)
|
||||
bf = 163.205 * math.log10(waist_cm + hip_cm - neck_cm) - 97.684 * math.log10(height_cm) - 78.387
|
||||
|
||||
print(f"Estimated body fat: {bf:.1f}%")
|
||||
|
||||
if sex == "M":
|
||||
ranges = [
|
||||
(6, "Essential fat (2-5%)"),
|
||||
(14, "Athletic (6-13%)"),
|
||||
(18, "Fitness (14-17%)"),
|
||||
(25, "Average (18-24%)"),
|
||||
]
|
||||
default = "Obese (25%+)"
|
||||
else:
|
||||
ranges = [
|
||||
(14, "Essential fat (10-13%)"),
|
||||
(21, "Athletic (14-20%)"),
|
||||
(25, "Fitness (21-24%)"),
|
||||
(32, "Average (25-31%)"),
|
||||
]
|
||||
default = "Obese (32%+)"
|
||||
|
||||
cat = default
|
||||
for threshold, label in ranges:
|
||||
if bf < threshold:
|
||||
cat = label
|
||||
break
|
||||
|
||||
print(f"Category: {cat}")
|
||||
print(f"Method: US Navy circumference formula")
|
||||
|
||||
|
||||
def usage():
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
usage()
|
||||
|
||||
cmd = sys.argv[1].lower()
|
||||
|
||||
try:
|
||||
if cmd == "bmi":
|
||||
bmi(float(sys.argv[2]), float(sys.argv[3]))
|
||||
|
||||
elif cmd == "tdee":
|
||||
tdee(
|
||||
float(sys.argv[2]), float(sys.argv[3]),
|
||||
int(sys.argv[4]), sys.argv[5], int(sys.argv[6]),
|
||||
)
|
||||
|
||||
elif cmd in ("1rm", "orm"):
|
||||
one_rep_max(float(sys.argv[2]), int(sys.argv[3]))
|
||||
|
||||
elif cmd == "macros":
|
||||
macros(float(sys.argv[2]), sys.argv[3])
|
||||
|
||||
elif cmd == "bodyfat":
|
||||
sex = sys.argv[2]
|
||||
if sex.upper() == "M":
|
||||
bodyfat(sex, float(sys.argv[3]), float(sys.argv[4]), 0, float(sys.argv[5]))
|
||||
else:
|
||||
bodyfat(sex, float(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5]), float(sys.argv[6]))
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
usage()
|
||||
|
||||
except (IndexError, ValueError) as e:
|
||||
print(f"Error: {e}")
|
||||
usage()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
nutrition_search.py — Search USDA FoodData Central for nutrition info.
|
||||
|
||||
Usage:
|
||||
python3 nutrition_search.py "chicken breast"
|
||||
python3 nutrition_search.py "rice" "eggs" "broccoli"
|
||||
echo -e "oats\\nbanana\\nwhey protein" | python3 nutrition_search.py -
|
||||
|
||||
Reads USDA_API_KEY from environment, falls back to DEMO_KEY.
|
||||
No external dependencies.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
|
||||
API_KEY = os.environ.get("USDA_API_KEY", "DEMO_KEY")
|
||||
BASE = "https://api.nal.usda.gov/fdc/v1"
|
||||
|
||||
|
||||
def search(query, max_results=3):
|
||||
encoded = urllib.parse.quote(query)
|
||||
url = (
|
||||
f"{BASE}/foods/search?api_key={API_KEY}"
|
||||
f"&query={encoded}&pageSize={max_results}"
|
||||
f"&dataType=Foundation,SR%20Legacy"
|
||||
)
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
print(f" API error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def display(food):
|
||||
nutrients = {n["nutrientName"]: n.get("value", "?") for n in food.get("foodNutrients", [])}
|
||||
cal = nutrients.get("Energy", "?")
|
||||
prot = nutrients.get("Protein", "?")
|
||||
fat = nutrients.get("Total lipid (fat)", "?")
|
||||
carb = nutrients.get("Carbohydrate, by difference", "?")
|
||||
fib = nutrients.get("Fiber, total dietary", "?")
|
||||
sug = nutrients.get("Sugars, total including NLEA", "?")
|
||||
|
||||
print(f" {food.get('description', 'N/A')}")
|
||||
print(f" Calories : {cal} kcal")
|
||||
print(f" Protein : {prot}g")
|
||||
print(f" Fat : {fat}g")
|
||||
print(f" Carbs : {carb}g (fiber: {fib}g, sugar: {sug}g)")
|
||||
print(f" FDC ID : {food.get('fdcId', 'N/A')}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
if sys.argv[1] == "-":
|
||||
queries = [line.strip() for line in sys.stdin if line.strip()]
|
||||
else:
|
||||
queries = sys.argv[1:]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n--- {query.upper()} (per 100g) ---")
|
||||
data = search(query, max_results=2)
|
||||
if not data or not data.get("foods"):
|
||||
print(" No results found.")
|
||||
else:
|
||||
for food in data["foods"]:
|
||||
display(food)
|
||||
print()
|
||||
if len(queries) > 1:
|
||||
time.sleep(1) # respect rate limits
|
||||
|
||||
if API_KEY == "DEMO_KEY":
|
||||
print("\nTip: using DEMO_KEY (30 req/hr). Set USDA_API_KEY for 1000 req/hr.")
|
||||
print("Free signup: https://fdc.nal.usda.gov/api-key-signup/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
name: drug-discovery
|
||||
description: >
|
||||
Pharmaceutical research assistant for drug discovery workflows. Search
|
||||
bioactive compounds on ChEMBL, calculate drug-likeness (Lipinski Ro5, QED,
|
||||
TPSA, synthetic accessibility), look up drug-drug interactions via
|
||||
OpenFDA, interpret ADMET profiles, and assist with lead optimization.
|
||||
Use for medicinal chemistry questions, molecule property analysis, clinical
|
||||
pharmacology, and open-science drug research.
|
||||
version: 1.0.0
|
||||
author: bennytimz
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [science, chemistry, pharmacology, research, health]
|
||||
prerequisites:
|
||||
commands: [curl, python3]
|
||||
---
|
||||
|
||||
# Drug Discovery & Pharmaceutical Research
|
||||
|
||||
You are an expert pharmaceutical scientist and medicinal chemist with deep
|
||||
knowledge of drug discovery, cheminformatics, and clinical pharmacology.
|
||||
Use this skill for all pharma/chemistry research tasks.
|
||||
|
||||
## Core Workflows
|
||||
|
||||
### 1 — Bioactive Compound Search (ChEMBL)
|
||||
|
||||
Search ChEMBL (the world's largest open bioactivity database) for compounds
|
||||
by target, activity, or molecule name. No API key required.
|
||||
|
||||
```bash
|
||||
# Search compounds by target name (e.g. "EGFR", "COX-2", "ACE")
|
||||
TARGET="$1"
|
||||
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$TARGET")
|
||||
curl -s "https://www.ebi.ac.uk/chembl/api/data/target/search?q=${ENCODED}&format=json" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
targets=data.get('targets',[])[:5]
|
||||
for t in targets:
|
||||
print(f\"ChEMBL ID : {t.get('target_chembl_id')}\")
|
||||
print(f\"Name : {t.get('pref_name')}\")
|
||||
print(f\"Type : {t.get('target_type')}\")
|
||||
print()
|
||||
"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Get bioactivity data for a ChEMBL target ID
|
||||
TARGET_ID="$1" # e.g. CHEMBL203
|
||||
curl -s "https://www.ebi.ac.uk/chembl/api/data/activity?target_chembl_id=${TARGET_ID}&pchembl_value__gte=6&limit=10&format=json" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
acts=data.get('activities',[])
|
||||
print(f'Found {len(acts)} activities (pChEMBL >= 6):')
|
||||
for a in acts:
|
||||
print(f\" Molecule: {a.get('molecule_chembl_id')} | {a.get('standard_type')}: {a.get('standard_value')} {a.get('standard_units')} | pChEMBL: {a.get('pchembl_value')}\")
|
||||
"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Look up a specific molecule by ChEMBL ID
|
||||
MOL_ID="$1" # e.g. CHEMBL25 (aspirin)
|
||||
curl -s "https://www.ebi.ac.uk/chembl/api/data/molecule/${MOL_ID}?format=json" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
m=json.load(sys.stdin)
|
||||
props=m.get('molecule_properties',{}) or {}
|
||||
print(f\"Name : {m.get('pref_name','N/A')}\")
|
||||
print(f\"SMILES : {m.get('molecule_structures',{}).get('canonical_smiles','N/A') if m.get('molecule_structures') else 'N/A'}\")
|
||||
print(f\"MW : {props.get('full_mwt','N/A')} Da\")
|
||||
print(f\"LogP : {props.get('alogp','N/A')}\")
|
||||
print(f\"HBD : {props.get('hbd','N/A')}\")
|
||||
print(f\"HBA : {props.get('hba','N/A')}\")
|
||||
print(f\"TPSA : {props.get('psa','N/A')} Ų\")
|
||||
print(f\"Ro5 violations: {props.get('num_ro5_violations','N/A')}\")
|
||||
print(f\"QED : {props.get('qed_weighted','N/A')}\")
|
||||
"
|
||||
```
|
||||
|
||||
### 2 — Drug-Likeness Calculation (Lipinski Ro5 + Veber)
|
||||
|
||||
Assess any molecule against established oral bioavailability rules using
|
||||
PubChem's free property API — no RDKit install needed.
|
||||
|
||||
```bash
|
||||
COMPOUND="$1"
|
||||
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$COMPOUND")
|
||||
curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${ENCODED}/property/MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount,RotatableBondCount,TPSA,InChIKey/JSON" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
props=data['PropertyTable']['Properties'][0]
|
||||
mw = float(props.get('MolecularWeight', 0))
|
||||
logp = float(props.get('XLogP', 0))
|
||||
hbd = int(props.get('HBondDonorCount', 0))
|
||||
hba = int(props.get('HBondAcceptorCount', 0))
|
||||
rot = int(props.get('RotatableBondCount', 0))
|
||||
tpsa = float(props.get('TPSA', 0))
|
||||
print('=== Lipinski Rule of Five (Ro5) ===')
|
||||
print(f' MW {mw:.1f} Da {\"✓\" if mw<=500 else \"✗ VIOLATION (>500)\"}')
|
||||
print(f' LogP {logp:.2f} {\"✓\" if logp<=5 else \"✗ VIOLATION (>5)\"}')
|
||||
print(f' HBD {hbd} {\"✓\" if hbd<=5 else \"✗ VIOLATION (>5)\"}')
|
||||
print(f' HBA {hba} {\"✓\" if hba<=10 else \"✗ VIOLATION (>10)\"}')
|
||||
viol = sum([mw>500, logp>5, hbd>5, hba>10])
|
||||
print(f' Violations: {viol}/4 {\"→ Likely orally bioavailable\" if viol<=1 else \"→ Poor oral bioavailability predicted\"}')
|
||||
print()
|
||||
print('=== Veber Oral Bioavailability Rules ===')
|
||||
print(f' TPSA {tpsa:.1f} Ų {\"✓\" if tpsa<=140 else \"✗ VIOLATION (>140)\"}')
|
||||
print(f' Rot. bonds {rot} {\"✓\" if rot<=10 else \"✗ VIOLATION (>10)\"}')
|
||||
print(f' Both rules met: {\"Yes → good oral absorption predicted\" if tpsa<=140 and rot<=10 else \"No → reduced oral absorption\"}')
|
||||
"
|
||||
```
|
||||
|
||||
### 3 — Drug Interaction & Safety Lookup (OpenFDA)
|
||||
|
||||
```bash
|
||||
DRUG="$1"
|
||||
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$DRUG")
|
||||
curl -s "https://api.fda.gov/drug/label.json?search=drug_interactions:\"${ENCODED}\"&limit=3" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
results=data.get('results',[])
|
||||
if not results:
|
||||
print('No interaction data found in FDA labels.')
|
||||
sys.exit()
|
||||
for r in results[:2]:
|
||||
brand=r.get('openfda',{}).get('brand_name',['Unknown'])[0]
|
||||
generic=r.get('openfda',{}).get('generic_name',['Unknown'])[0]
|
||||
interactions=r.get('drug_interactions',['N/A'])[0]
|
||||
print(f'--- {brand} ({generic}) ---')
|
||||
print(interactions[:800])
|
||||
print()
|
||||
"
|
||||
```
|
||||
|
||||
```bash
|
||||
DRUG="$1"
|
||||
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$DRUG")
|
||||
curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduct:\"${ENCODED}\"&count=patient.reaction.reactionmeddrapt.exact&limit=10" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
results=data.get('results',[])
|
||||
if not results:
|
||||
print('No adverse event data found.')
|
||||
sys.exit()
|
||||
print(f'Top adverse events reported:')
|
||||
for r in results[:10]:
|
||||
print(f\" {r['count']:>5}x {r['term']}\")
|
||||
"
|
||||
```
|
||||
|
||||
### 4 — PubChem Compound Search
|
||||
|
||||
```bash
|
||||
COMPOUND="$1"
|
||||
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$COMPOUND")
|
||||
CID=$(curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${ENCODED}/cids/TXT" | head -1 | tr -d '[:space:]')
|
||||
echo "PubChem CID: $CID"
|
||||
curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${CID}/property/IsomericSMILES,InChIKey,IUPACName/JSON" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
p=json.load(sys.stdin)['PropertyTable']['Properties'][0]
|
||||
print(f\"IUPAC Name : {p.get('IUPACName','N/A')}\")
|
||||
print(f\"SMILES : {p.get('IsomericSMILES','N/A')}\")
|
||||
print(f\"InChIKey : {p.get('InChIKey','N/A')}\")
|
||||
"
|
||||
```
|
||||
|
||||
### 5 — Target & Disease Literature (OpenTargets)
|
||||
|
||||
```bash
|
||||
GENE="$1"
|
||||
curl -s -X POST "https://api.platform.opentargets.org/api/v4/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"query\":\"{ search(queryString: \\\"${GENE}\\\", entityNames: [\\\"target\\\"], page: {index: 0, size: 1}) { hits { id score object { ... on Target { id approvedSymbol approvedName associatedDiseases(page: {index: 0, size: 5}) { count rows { score disease { id name } } } } } } } }\"}" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
hits=data.get('data',{}).get('search',{}).get('hits',[])
|
||||
if not hits:
|
||||
print('Target not found.')
|
||||
sys.exit()
|
||||
obj=hits[0]['object']
|
||||
print(f\"Target: {obj.get('approvedSymbol')} — {obj.get('approvedName')}\")
|
||||
assoc=obj.get('associatedDiseases',{})
|
||||
print(f\"Associated with {assoc.get('count',0)} diseases. Top associations:\")
|
||||
for row in assoc.get('rows',[]):
|
||||
print(f\" Score {row['score']:.3f} | {row['disease']['name']}\")
|
||||
"
|
||||
```
|
||||
|
||||
## Reasoning Guidelines
|
||||
|
||||
When analysing drug-likeness or molecular properties, always:
|
||||
|
||||
1. **State raw values first** — MW, LogP, HBD, HBA, TPSA, RotBonds
|
||||
2. **Apply rule sets** — Ro5 (Lipinski), Veber, Ghose filter where relevant
|
||||
3. **Flag liabilities** — metabolic hotspots, hERG risk, high TPSA for CNS penetration
|
||||
4. **Suggest optimizations** — bioisosteric replacements, prodrug strategies, ring truncation
|
||||
5. **Cite the source API** — ChEMBL, PubChem, OpenFDA, or OpenTargets
|
||||
|
||||
For ADMET questions, reason through Absorption, Distribution, Metabolism, Excretion, Toxicity systematically. See references/ADMET_REFERENCE.md for detailed guidance.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- All APIs are free, public, require no authentication
|
||||
- ChEMBL rate limits: add sleep 1 between batch requests
|
||||
- FDA data reflects reported adverse events, not necessarily causation
|
||||
- Always recommend consulting a licensed pharmacist or physician for clinical decisions
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | API | Endpoint |
|
||||
|------|-----|----------|
|
||||
| Find target | ChEMBL | `/api/data/target/search?q=` |
|
||||
| Get bioactivity | ChEMBL | `/api/data/activity?target_chembl_id=` |
|
||||
| Molecule properties | PubChem | `/rest/pug/compound/name/{name}/property/` |
|
||||
| Drug interactions | OpenFDA | `/drug/label.json?search=drug_interactions:` |
|
||||
| Adverse events | OpenFDA | `/drug/event.json?search=...&count=reaction` |
|
||||
| Gene-disease | OpenTargets | GraphQL POST `/api/v4/graphql` |
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADMET Reference Guide
|
||||
|
||||
Comprehensive reference for Absorption, Distribution, Metabolism, Excretion, and Toxicity (ADMET) analysis in drug discovery.
|
||||
|
||||
## Drug-Likeness Rule Sets
|
||||
|
||||
### Lipinski's Rule of Five (Ro5)
|
||||
|
||||
| Property | Threshold |
|
||||
|----------|-----------|
|
||||
| Molecular Weight (MW) | ≤ 500 Da |
|
||||
| Lipophilicity (LogP) | ≤ 5 |
|
||||
| H-Bond Donors (HBD) | ≤ 5 |
|
||||
| H-Bond Acceptors (HBA) | ≤ 10 |
|
||||
|
||||
Reference: Lipinski et al., Adv. Drug Deliv. Rev. 23, 3–25 (1997).
|
||||
|
||||
### Veber's Oral Bioavailability Rules
|
||||
|
||||
| Property | Threshold |
|
||||
|----------|-----------|
|
||||
| TPSA | ≤ 140 Ų |
|
||||
| Rotatable Bonds | ≤ 10 |
|
||||
|
||||
Reference: Veber et al., J. Med. Chem. 45, 2615–2623 (2002).
|
||||
|
||||
### CNS Penetration (BBB)
|
||||
|
||||
| Property | CNS-Optimal |
|
||||
|----------|-------------|
|
||||
| MW | ≤ 400 Da |
|
||||
| LogP | 1–3 |
|
||||
| TPSA | < 90 Ų |
|
||||
| HBD | ≤ 3 |
|
||||
|
||||
## CYP450 Metabolism
|
||||
|
||||
| Isoform | % Drugs | Notable inhibitors |
|
||||
|---------|---------|-------------------|
|
||||
| CYP3A4 | ~50% | Grapefruit, ketoconazole |
|
||||
| CYP2D6 | ~25% | Fluoxetine, paroxetine |
|
||||
| CYP2C9 | ~15% | Fluconazole, amiodarone |
|
||||
| CYP2C19 | ~10% | Omeprazole, fluoxetine |
|
||||
| CYP1A2 | ~5% | Fluvoxamine, ciprofloxacin |
|
||||
|
||||
## hERG Cardiac Toxicity Risk
|
||||
|
||||
Structural alerts: basic nitrogen (pKa 7–9) + aromatic ring + hydrophobic moiety, LogP > 3.5 + basic amine.
|
||||
|
||||
Mitigation: reduce basicity, introduce polar groups, break planarity.
|
||||
|
||||
## Common Bioisosteric Replacements
|
||||
|
||||
| Original | Bioisostere | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| -COOH | -tetrazole, -SO₂NH₂ | Improve permeability |
|
||||
| -OH (phenol) | -F, -CN | Reduce glucuronidation |
|
||||
| Phenyl | Pyridine, thiophene | Reduce LogP |
|
||||
| Ester | -CONHR | Reduce hydrolysis |
|
||||
|
||||
## Key APIs
|
||||
|
||||
- ChEMBL: https://www.ebi.ac.uk/chembl/api/data/
|
||||
- PubChem: https://pubchem.ncbi.nlm.nih.gov/rest/pug/
|
||||
- OpenFDA: https://api.fda.gov/drug/
|
||||
- OpenTargets GraphQL: https://api.platform.opentargets.org/api/v4/graphql
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
chembl_target.py — Search ChEMBL for a target and retrieve top active compounds.
|
||||
Usage: python3 chembl_target.py "EGFR" --min-pchembl 7 --limit 20
|
||||
No external dependencies.
|
||||
"""
|
||||
import sys, json, time, argparse
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
|
||||
BASE = "https://www.ebi.ac.uk/chembl/api/data"
|
||||
|
||||
def get(endpoint):
|
||||
try:
|
||||
req = urllib.request.Request(f"{BASE}{endpoint}", headers={"Accept":"application/json"})
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
print(f"API error: {e}", file=sys.stderr); return None
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ChEMBL target → active compounds")
|
||||
parser.add_argument("target")
|
||||
parser.add_argument("--min-pchembl", type=float, default=6.0)
|
||||
parser.add_argument("--limit", type=int, default=10)
|
||||
args = parser.parse_args()
|
||||
|
||||
enc = urllib.parse.quote(args.target)
|
||||
data = get(f"/target/search?q={enc}&limit=5&format=json")
|
||||
if not data or not data.get("targets"):
|
||||
print("No targets found."); sys.exit(1)
|
||||
|
||||
t = data["targets"][0]
|
||||
tid = t.get("target_chembl_id","")
|
||||
print(f"\nTarget: {t.get('pref_name')} ({tid})")
|
||||
print(f"Type: {t.get('target_type')} | Organism: {t.get('organism','N/A')}")
|
||||
print(f"\nFetching compounds with pChEMBL ≥ {args.min_pchembl}...\n")
|
||||
|
||||
acts = get(f"/activity?target_chembl_id={tid}&pchembl_value__gte={args.min_pchembl}&assay_type=B&limit={args.limit}&order_by=-pchembl_value&format=json")
|
||||
if not acts or not acts.get("activities"):
|
||||
print("No activities found."); sys.exit(0)
|
||||
|
||||
print(f"{'Molecule':<18} {'pChEMBL':>8} {'Type':<12} {'Value':<10} {'Units'}")
|
||||
print("-"*65)
|
||||
seen = set()
|
||||
for a in acts["activities"]:
|
||||
mid = a.get("molecule_chembl_id","N/A")
|
||||
if mid in seen: continue
|
||||
seen.add(mid)
|
||||
print(f"{mid:<18} {str(a.get('pchembl_value','N/A')):>8} {str(a.get('standard_type','N/A')):<12} {str(a.get('standard_value','N/A')):<10} {a.get('standard_units','N/A')}")
|
||||
time.sleep(0.1)
|
||||
print(f"\nTotal: {len(seen)} unique molecules")
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ro5_screen.py — Batch Lipinski Ro5 + Veber screening via PubChem API.
|
||||
Usage: python3 ro5_screen.py aspirin ibuprofen paracetamol
|
||||
No external dependencies beyond stdlib.
|
||||
"""
|
||||
import sys, json, time, argparse
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
|
||||
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name"
|
||||
PROPS = "MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount,RotatableBondCount,TPSA"
|
||||
|
||||
def fetch(name):
|
||||
url = f"{BASE}/{urllib.parse.quote(name)}/property/{PROPS}/JSON"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return json.loads(r.read())["PropertyTable"]["Properties"][0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def check(p):
|
||||
mw,logp,hbd,hba,rot,tpsa = float(p.get("MolecularWeight",0)),float(p.get("XLogP",0)),int(p.get("HBondDonorCount",0)),int(p.get("HBondAcceptorCount",0)),int(p.get("RotatableBondCount",0)),float(p.get("TPSA",0))
|
||||
v = sum([mw>500,logp>5,hbd>5,hba>10])
|
||||
return dict(mw=mw,logp=logp,hbd=hbd,hba=hba,rot=rot,tpsa=tpsa,violations=v,ro5=v<=1,veber=tpsa<=140 and rot<=10,ok=v<=1 and tpsa<=140 and rot<=10)
|
||||
|
||||
def report(name, r):
|
||||
if not r: print(f"✗ {name:30s} — not found"); return
|
||||
s = "✓ PASS" if r["ok"] else "✗ FAIL"
|
||||
flags = (f" [Ro5 violations:{r['violations']}]" if not r["ro5"] else "") + (" [Veber fail]" if not r["veber"] else "")
|
||||
print(f"{s} {name:28s} MW={r['mw']:.0f} LogP={r['logp']:.2f} HBD={r['hbd']} HBA={r['hba']} TPSA={r['tpsa']:.0f} RotB={r['rot']}{flags}")
|
||||
|
||||
def main():
|
||||
compounds = sys.stdin.read().splitlines() if len(sys.argv)<2 or sys.argv[1]=="-" else sys.argv[1:]
|
||||
print(f"\n{'Status':<8} {'Compound':<30} Properties\n" + "-"*85)
|
||||
passed = 0
|
||||
for name in compounds:
|
||||
props = fetch(name.strip())
|
||||
result = check(props) if props else None
|
||||
report(name.strip(), result)
|
||||
if result and result["ok"]: passed += 1
|
||||
time.sleep(0.3)
|
||||
print(f"\nSummary: {passed}/{len(compounds)} passed Ro5 + Veber.\n")
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user