Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bad9fe2452 | |||
| 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 |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1152,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
|
||||
|
||||
+10
-2
@@ -106,9 +106,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,
|
||||
@@ -150,6 +156,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,
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+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))
|
||||
|
||||
@@ -523,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:
|
||||
#
|
||||
@@ -552,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]
|
||||
@@ -561,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,19 @@ 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"
|
||||
|
||||
|
||||
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 +1010,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 +1039,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:
|
||||
@@ -6156,6 +6159,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:
|
||||
@@ -8627,6 +8631,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)."""
|
||||
@@ -8924,9 +8946,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:
|
||||
@@ -9169,7 +9191,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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+25
-1
@@ -3,11 +3,12 @@ Event Hook System
|
||||
|
||||
A lightweight event-driven system that fires handlers at key lifecycle points.
|
||||
Hooks are discovered from ~/.hermes/hooks/ directories, each containing:
|
||||
- HOOK.yaml (metadata: name, description, events list)
|
||||
- HOOK.yaml (metadata: name, description, events list, optional startup_readiness)
|
||||
- handler.py (Python handler with async def handle(event_type, context))
|
||||
|
||||
Events:
|
||||
- gateway:startup -- Gateway process starts
|
||||
- gateway:shutdown -- Gateway process is shutting down
|
||||
- session:start -- New session created (first message of a new session)
|
||||
- session:end -- Session ends (user ran /new or /reset)
|
||||
- session:reset -- Session reset completed (new session entry created)
|
||||
@@ -31,6 +32,26 @@ from hermes_cli.config import get_hermes_home
|
||||
HOOKS_DIR = get_hermes_home() / "hooks"
|
||||
|
||||
|
||||
def _normalize_startup_readiness(hook_name: str, manifest: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Validate and normalize optional startup readiness metadata."""
|
||||
readiness = manifest.get("startup_readiness")
|
||||
if readiness is None:
|
||||
return None
|
||||
if not isinstance(readiness, dict):
|
||||
print(f"[hooks] Ignoring startup_readiness for {hook_name}: expected mapping", flush=True)
|
||||
return None
|
||||
|
||||
check_id = str(readiness.get("id", "")).strip()
|
||||
if not check_id:
|
||||
print(f"[hooks] Ignoring startup_readiness for {hook_name}: missing id", flush=True)
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": check_id,
|
||||
"required": bool(readiness.get("required", True)),
|
||||
}
|
||||
|
||||
|
||||
class HookRegistry:
|
||||
"""
|
||||
Discovers, loads, and fires event hooks.
|
||||
@@ -62,6 +83,7 @@ class HookRegistry:
|
||||
"description": "Run ~/.hermes/BOOT.md on gateway startup",
|
||||
"events": ["gateway:startup"],
|
||||
"path": "(builtin)",
|
||||
"startup_readiness": None,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[hooks] Could not load built-in boot-md hook: {e}", flush=True)
|
||||
@@ -102,6 +124,7 @@ class HookRegistry:
|
||||
if not events:
|
||||
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
|
||||
continue
|
||||
startup_readiness = _normalize_startup_readiness(hook_name, manifest)
|
||||
|
||||
# Dynamically load the handler module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
@@ -128,6 +151,7 @@ class HookRegistry:
|
||||
"description": manifest.get("description", ""),
|
||||
"events": events,
|
||||
"path": str(hook_dir),
|
||||
"startup_readiness": startup_readiness,
|
||||
})
|
||||
|
||||
print(f"[hooks] Loaded hook '{hook_name}' for events: {events}", flush=True)
|
||||
|
||||
@@ -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
|
||||
@@ -565,6 +566,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)
|
||||
@@ -1783,6 +1805,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)
|
||||
|
||||
@@ -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:
|
||||
@@ -835,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"),
|
||||
|
||||
@@ -1736,46 +1736,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."""
|
||||
@@ -2474,6 +2518,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,
|
||||
@@ -2482,7 +2534,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
-73
@@ -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"
|
||||
@@ -1490,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"},
|
||||
@@ -1510,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,
|
||||
@@ -1845,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:
|
||||
@@ -1950,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:
|
||||
@@ -2897,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.
|
||||
|
||||
@@ -2909,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_"):
|
||||
|
||||
@@ -958,6 +958,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", {})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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():
|
||||
|
||||
@@ -203,6 +203,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
"wecom_callback",
|
||||
"weixin",
|
||||
"bluebubbles",
|
||||
"qqbot",
|
||||
):
|
||||
return await self._deliver_cross_platform(
|
||||
deliver_type, content, delivery
|
||||
|
||||
+444
-16
@@ -1391,6 +1391,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. "
|
||||
"Use /retry after restart to continue."
|
||||
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:
|
||||
@@ -1481,7 +1540,7 @@ class GatewayRunner:
|
||||
pass
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
write_runtime_status(gateway_state="starting", exit_reason=None)
|
||||
write_runtime_status(gateway_state="starting", exit_reason=None, startup_checks={})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1499,6 +1558,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 +1572,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(
|
||||
@@ -1521,8 +1582,23 @@ class GatewayRunner:
|
||||
"or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id)."
|
||||
)
|
||||
|
||||
# Discover plugins before hooks so plugin-owned hook bundles can
|
||||
# participate in this same startup cycle.
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
except Exception as e:
|
||||
logger.warning("Plugin discovery during gateway startup failed: %s", e)
|
||||
|
||||
# Discover and load event hooks
|
||||
self.hooks.discover_and_load()
|
||||
try:
|
||||
from gateway.status import reset_startup_checks
|
||||
|
||||
reset_startup_checks(self.hooks.loaded_hooks)
|
||||
except Exception as e:
|
||||
logger.warning("Startup readiness initialization failed: %s", e)
|
||||
|
||||
# Recover background processes from checkpoint (crash recovery)
|
||||
try:
|
||||
@@ -2016,6 +2092,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:
|
||||
@@ -2039,6 +2119,11 @@ class GatewayRunner:
|
||||
logger.error("Failed to launch detached gateway restart: %s", e)
|
||||
|
||||
self._finalize_shutdown_agents(active_agents)
|
||||
await self.hooks.emit("gateway:shutdown", {
|
||||
"restart": self._restart_requested,
|
||||
"service_restart": self._restart_via_service,
|
||||
"detached_restart": self._restart_detached,
|
||||
})
|
||||
|
||||
for platform, adapter in list(self.adapters.items()):
|
||||
try:
|
||||
@@ -2086,12 +2171,23 @@ 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."
|
||||
)
|
||||
|
||||
if self._restart_requested and self._restart_via_service:
|
||||
self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
@@ -2255,8 +2351,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 +2399,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 +2417,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)
|
||||
@@ -3960,6 +4065,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()
|
||||
@@ -6469,7 +6579,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:
|
||||
@@ -7392,6 +7502,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,
|
||||
@@ -7415,6 +7782,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
|
||||
|
||||
@@ -7809,13 +8188,14 @@ 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.
|
||||
@@ -8901,8 +9281,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():
|
||||
@@ -8972,6 +9385,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
|
||||
|
||||
|
||||
|
||||
+153
-3
@@ -27,6 +27,7 @@ _RUNTIME_STATUS_FILE = "gateway_state.json"
|
||||
_LOCKS_DIRNAME = "gateway-locks"
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
_UNSET = object()
|
||||
_VALID_STARTUP_CHECK_STATES = {"pending", "ready", "failed"}
|
||||
|
||||
|
||||
def _get_pid_path() -> Path:
|
||||
@@ -162,11 +163,39 @@ def _build_runtime_status_record() -> dict[str, Any]:
|
||||
"restart_requested": False,
|
||||
"active_agents": 0,
|
||||
"platforms": {},
|
||||
"startup_checks": {},
|
||||
"updated_at": _utc_now_iso(),
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_startup_check_entries(
|
||||
startup_checks: Optional[dict[str, Any]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Normalize persisted startup readiness entries."""
|
||||
if not isinstance(startup_checks, dict):
|
||||
return {}
|
||||
|
||||
now = _utc_now_iso()
|
||||
normalized: dict[str, dict[str, Any]] = {}
|
||||
for raw_id, raw_payload in startup_checks.items():
|
||||
check_id = str(raw_id).strip()
|
||||
if not check_id:
|
||||
continue
|
||||
payload = raw_payload if isinstance(raw_payload, dict) else {}
|
||||
state = str(payload.get("state", "pending")).strip().lower()
|
||||
if state not in _VALID_STARTUP_CHECK_STATES:
|
||||
state = "pending"
|
||||
normalized[check_id] = {
|
||||
"state": state,
|
||||
"required": bool(payload.get("required", True)),
|
||||
"source": payload.get("source"),
|
||||
"detail": payload.get("detail"),
|
||||
"updated_at": payload.get("updated_at") or now,
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -223,6 +252,7 @@ def write_runtime_status(
|
||||
exit_reason: Any = _UNSET,
|
||||
restart_requested: Any = _UNSET,
|
||||
active_agents: Any = _UNSET,
|
||||
startup_checks: Any = _UNSET,
|
||||
platform: Any = _UNSET,
|
||||
platform_state: Any = _UNSET,
|
||||
error_code: Any = _UNSET,
|
||||
@@ -245,6 +275,8 @@ def write_runtime_status(
|
||||
payload["restart_requested"] = bool(restart_requested)
|
||||
if active_agents is not _UNSET:
|
||||
payload["active_agents"] = max(0, int(active_agents))
|
||||
if startup_checks is not _UNSET:
|
||||
payload["startup_checks"] = _normalize_startup_check_entries(startup_checks)
|
||||
|
||||
if platform is not _UNSET:
|
||||
platform_payload = payload["platforms"].get(platform, {})
|
||||
@@ -262,13 +294,131 @@ def write_runtime_status(
|
||||
|
||||
def read_runtime_status() -> Optional[dict[str, Any]]:
|
||||
"""Read the persisted gateway runtime health/status information."""
|
||||
return _read_json_file(_get_runtime_status_path())
|
||||
payload = _read_json_file(_get_runtime_status_path())
|
||||
if payload is None:
|
||||
return None
|
||||
payload.setdefault("platforms", {})
|
||||
payload["startup_checks"] = _normalize_startup_check_entries(payload.get("startup_checks"))
|
||||
return payload
|
||||
|
||||
|
||||
def reset_startup_checks(checks: Optional[list[dict[str, Any]]] = None) -> dict[str, dict[str, Any]]:
|
||||
"""Replace persisted startup readiness checks for the current run."""
|
||||
normalized: dict[str, dict[str, Any]] = {}
|
||||
now = _utc_now_iso()
|
||||
|
||||
for hook in checks or []:
|
||||
if not isinstance(hook, dict):
|
||||
continue
|
||||
readiness = hook.get("startup_readiness")
|
||||
if not isinstance(readiness, dict):
|
||||
continue
|
||||
check_id = str(readiness.get("id", "")).strip()
|
||||
if not check_id:
|
||||
continue
|
||||
normalized[check_id] = {
|
||||
"state": "pending",
|
||||
"required": bool(readiness.get("required", True)),
|
||||
"source": hook.get("name"),
|
||||
"detail": None,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
write_runtime_status(startup_checks=normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def update_startup_check(
|
||||
check_id: str,
|
||||
state: str,
|
||||
*,
|
||||
detail: Any = _UNSET,
|
||||
required: Any = _UNSET,
|
||||
source: Any = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
"""Update a single startup readiness check in the runtime status file."""
|
||||
normalized_id = str(check_id).strip()
|
||||
if not normalized_id:
|
||||
raise ValueError("startup readiness check id is required")
|
||||
|
||||
normalized_state = str(state).strip().lower()
|
||||
if normalized_state not in _VALID_STARTUP_CHECK_STATES:
|
||||
raise ValueError(f"invalid startup readiness state: {state}")
|
||||
|
||||
path = _get_runtime_status_path()
|
||||
payload = _read_json_file(path) or _build_runtime_status_record()
|
||||
checks = _normalize_startup_check_entries(payload.get("startup_checks"))
|
||||
existing = checks.get(normalized_id, {})
|
||||
now = _utc_now_iso()
|
||||
|
||||
checks[normalized_id] = {
|
||||
"state": normalized_state,
|
||||
"required": bool(existing.get("required", True) if required is _UNSET else required),
|
||||
"source": existing.get("source") if source is _UNSET else source,
|
||||
"detail": existing.get("detail") if detail is _UNSET else detail,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
payload["startup_checks"] = checks
|
||||
payload.setdefault("platforms", {})
|
||||
payload.setdefault("kind", _GATEWAY_KIND)
|
||||
payload["pid"] = os.getpid()
|
||||
payload["start_time"] = _get_process_start_time(os.getpid())
|
||||
payload["updated_at"] = now
|
||||
_write_json_file(path, payload)
|
||||
return checks[normalized_id]
|
||||
|
||||
|
||||
def mark_startup_check_pending(
|
||||
check_id: str,
|
||||
*,
|
||||
detail: Any = _UNSET,
|
||||
required: Any = _UNSET,
|
||||
source: Any = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
return update_startup_check(check_id, "pending", detail=detail, required=required, source=source)
|
||||
|
||||
|
||||
def mark_startup_check_ready(
|
||||
check_id: str,
|
||||
*,
|
||||
detail: Any = _UNSET,
|
||||
required: Any = _UNSET,
|
||||
source: Any = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
return update_startup_check(check_id, "ready", detail=detail, required=required, source=source)
|
||||
|
||||
|
||||
def mark_startup_check_failed(
|
||||
check_id: str,
|
||||
*,
|
||||
detail: Any = _UNSET,
|
||||
required: Any = _UNSET,
|
||||
source: Any = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
return update_startup_check(check_id, "failed", detail=detail, required=required, source=source)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
+154
-2
@@ -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)
|
||||
|
||||
@@ -496,10 +632,26 @@ class GatewayStreamConsumer:
|
||||
visible_without_cursor = text
|
||||
if self.cfg.cursor:
|
||||
visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "")
|
||||
if not visible_without_cursor.strip():
|
||||
_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.*
|
||||
+39
-34
@@ -224,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",),
|
||||
@@ -383,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)"),
|
||||
]
|
||||
|
||||
|
||||
@@ -397,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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+243
-34
@@ -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
|
||||
@@ -579,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.
|
||||
|
||||
@@ -610,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:
|
||||
@@ -794,46 +911,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)
|
||||
@@ -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",
|
||||
@@ -1331,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)",
|
||||
@@ -1379,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)",
|
||||
|
||||
@@ -42,6 +42,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",
|
||||
@@ -729,7 +730,7 @@ def run_doctor(args):
|
||||
# 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),
|
||||
@@ -749,7 +750,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)]
|
||||
|
||||
|
||||
+148
-2
@@ -10,6 +10,7 @@ import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
@@ -37,6 +38,10 @@ from hermes_cli.setup import (
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
_SERVICE_READINESS_TIMEOUT = 30.0
|
||||
_SERVICE_READINESS_POLL_INTERVAL = 0.2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Process Management (for manual gateway runs)
|
||||
# =============================================================================
|
||||
@@ -1100,12 +1105,123 @@ def systemd_uninstall(system: bool = False):
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service uninstalled")
|
||||
|
||||
|
||||
def _describe_startup_check(check_id: str, check: dict) -> str:
|
||||
source = check.get("source")
|
||||
detail = check.get("detail")
|
||||
label = f"{check_id} ({source})" if source and source != check_id else check_id
|
||||
return f"{label}: {detail}" if detail else label
|
||||
|
||||
|
||||
def _classify_startup_checks(state: dict | None) -> tuple[list[str], list[str], list[str]]:
|
||||
checks = (state or {}).get("startup_checks") or {}
|
||||
pending_required: list[str] = []
|
||||
failed_required: list[str] = []
|
||||
optional_warnings: list[str] = []
|
||||
|
||||
if not isinstance(checks, dict):
|
||||
return pending_required, failed_required, optional_warnings
|
||||
|
||||
for check_id, raw_check in checks.items():
|
||||
check = raw_check if isinstance(raw_check, dict) else {}
|
||||
label = _describe_startup_check(str(check_id), check)
|
||||
check_state = str(check.get("state", "pending")).strip().lower()
|
||||
required = bool(check.get("required", True))
|
||||
|
||||
if check_state == "ready":
|
||||
continue
|
||||
if required:
|
||||
if check_state == "failed":
|
||||
failed_required.append(label)
|
||||
else:
|
||||
pending_required.append(label)
|
||||
else:
|
||||
prefix = "failed" if check_state == "failed" else "pending"
|
||||
optional_warnings.append(f"{prefix}: {label}")
|
||||
|
||||
return pending_required, failed_required, optional_warnings
|
||||
|
||||
|
||||
def _wait_for_service_readiness(
|
||||
*,
|
||||
action: str,
|
||||
previous_pid: int | None = None,
|
||||
timeout: float = _SERVICE_READINESS_TIMEOUT,
|
||||
poll_interval: float = _SERVICE_READINESS_POLL_INTERVAL,
|
||||
) -> list[str]:
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
last_pending: list[str] = []
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
live_pid = get_running_pid()
|
||||
if live_pid is None or (previous_pid is not None and live_pid == previous_pid):
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
runtime = read_runtime_status() or {}
|
||||
try:
|
||||
runtime_pid = int(runtime.get("pid"))
|
||||
except (TypeError, ValueError):
|
||||
runtime_pid = None
|
||||
if runtime_pid != live_pid:
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
pending_required, failed_required, optional_warnings = _classify_startup_checks(runtime)
|
||||
last_pending = pending_required
|
||||
|
||||
if gateway_state == "startup_failed":
|
||||
reason = runtime.get("exit_reason") or f"gateway {action} failed during startup"
|
||||
raise RuntimeError(reason)
|
||||
if failed_required:
|
||||
raise RuntimeError(
|
||||
"required startup checks failed: " + "; ".join(failed_required)
|
||||
)
|
||||
if gateway_state == "running" and not pending_required:
|
||||
return optional_warnings
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
if last_pending:
|
||||
raise RuntimeError(
|
||||
"timed out waiting for required startup checks: " + "; ".join(last_pending)
|
||||
)
|
||||
if previous_pid is not None:
|
||||
raise RuntimeError(
|
||||
f"timed out waiting for gateway {action}; previous process is still active or no new runtime became ready"
|
||||
)
|
||||
raise RuntimeError(f"timed out waiting for gateway {action} readiness")
|
||||
|
||||
|
||||
def _await_service_ready_or_exit(
|
||||
*,
|
||||
action: str,
|
||||
previous_pid: int | None = None,
|
||||
timeout: float = _SERVICE_READINESS_TIMEOUT,
|
||||
) -> None:
|
||||
try:
|
||||
optional_warnings = _wait_for_service_readiness(
|
||||
action=action,
|
||||
previous_pid=previous_pid,
|
||||
timeout=timeout,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print_error(f" Gateway {action} did not become ready: {exc}")
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
for warning in optional_warnings:
|
||||
print_warning(f" Optional startup check {warning}")
|
||||
|
||||
|
||||
def systemd_start(system: bool = False):
|
||||
system = _select_systemd_scope(system)
|
||||
if system:
|
||||
_require_root_for_system_service("start")
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
|
||||
_await_service_ready_or_exit(action="start")
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service started")
|
||||
|
||||
|
||||
@@ -1128,9 +1244,11 @@ 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")
|
||||
_await_service_ready_or_exit(action="restart", previous_pid=pid)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")
|
||||
return
|
||||
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
_await_service_ready_or_exit(action="restart", previous_pid=pid)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")
|
||||
|
||||
|
||||
@@ -1389,6 +1507,7 @@ def launchd_start():
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
|
||||
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
|
||||
_await_service_ready_or_exit(action="start")
|
||||
print("✓ Service started")
|
||||
return
|
||||
|
||||
@@ -1401,6 +1520,7 @@ def launchd_start():
|
||||
print("↻ launchd job was unloaded; reloading service definition")
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
|
||||
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
|
||||
_await_service_ready_or_exit(action="start")
|
||||
print("✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
@@ -1471,7 +1591,8 @@ def launchd_restart():
|
||||
try:
|
||||
pid = get_running_pid()
|
||||
if pid is not None and _request_gateway_self_restart(pid):
|
||||
print("✓ Service restart requested")
|
||||
_await_service_ready_or_exit(action="restart", previous_pid=pid)
|
||||
print("✓ Service restarted")
|
||||
return
|
||||
if pid is not None:
|
||||
try:
|
||||
@@ -1483,6 +1604,7 @@ def launchd_restart():
|
||||
if not exited:
|
||||
print(f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart")
|
||||
subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90)
|
||||
_await_service_ready_or_exit(action="restart", previous_pid=pid)
|
||||
print("✓ Service restarted")
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode not in (3, 113):
|
||||
@@ -1492,6 +1614,7 @@ def launchd_restart():
|
||||
plist_path = get_launchd_plist_path()
|
||||
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
|
||||
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
|
||||
_await_service_ready_or_exit(action="restart", previous_pid=pid)
|
||||
print("✓ Service restarted")
|
||||
|
||||
def launchd_status(deep: bool = False):
|
||||
@@ -1913,6 +2036,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."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
+82
-25
@@ -1618,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
|
||||
@@ -1673,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
|
||||
|
||||
@@ -1709,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:
|
||||
@@ -4021,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):
|
||||
@@ -4109,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"}
|
||||
|
||||
@@ -4404,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):
|
||||
@@ -5894,13 +5947,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
|
||||
@@ -5913,6 +5966,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)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -705,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)
|
||||
|
||||
+40
-2
@@ -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",
|
||||
@@ -512,6 +517,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
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`)"),
|
||||
@@ -525,12 +531,11 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
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("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
|
||||
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", "AI Gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
|
||||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, pay-per-use)"),
|
||||
]
|
||||
|
||||
# Derived dicts — used throughout the codebase
|
||||
@@ -1818,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:
|
||||
@@ -1869,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:
|
||||
@@ -1901,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")),
|
||||
])
|
||||
|
||||
+112
-2
@@ -262,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
|
||||
@@ -278,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
|
||||
@@ -554,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,6 +655,45 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||||
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Plugins that need to enforce policy (rate limiting, security
|
||||
restrictions, approval workflows) can return::
|
||||
|
||||
{"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.
|
||||
"""
|
||||
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():
|
||||
"""Return the plugin-registered context engine, or None."""
|
||||
return get_plugin_manager()._context_engine
|
||||
@@ -608,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
|
||||
@@ -617,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)
|
||||
|
||||
|
||||
@@ -287,6 +287,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
|
||||
|
||||
+62
-1
@@ -776,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")
|
||||
@@ -1969,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)")
|
||||
@@ -2034,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")
|
||||
@@ -2097,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),
|
||||
]
|
||||
|
||||
@@ -2148,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:
|
||||
@@ -2169,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()
|
||||
|
||||
+102
-5
@@ -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
|
||||
==========
|
||||
@@ -304,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",
|
||||
@@ -685,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,
|
||||
@@ -692,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",
|
||||
@@ -716,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():
|
||||
|
||||
@@ -362,7 +362,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 +376,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 +426,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
|
||||
|
||||
|
||||
|
||||
+292
-38
@@ -10,8 +10,10 @@ Usage:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
@@ -47,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:
|
||||
@@ -84,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
|
||||
@@ -96,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",
|
||||
@@ -246,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
|
||||
@@ -264,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 = {}
|
||||
@@ -286,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 {}
|
||||
@@ -301,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:
|
||||
@@ -408,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
|
||||
|
||||
|
||||
@@ -433,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.
|
||||
|
||||
@@ -440,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
|
||||
@@ -455,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
|
||||
@@ -471,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()
|
||||
@@ -535,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()
|
||||
@@ -808,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:
|
||||
@@ -1382,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:
|
||||
@@ -1416,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,
|
||||
@@ -1446,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:
|
||||
@@ -1796,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):
|
||||
@@ -1806,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}")
|
||||
@@ -1819,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:
|
||||
|
||||
@@ -78,6 +78,10 @@ def set_session_context(session_id: str) -> None:
|
||||
_session_context.session_id = session_id
|
||||
|
||||
|
||||
def clear_session_context() -> None:
|
||||
"""Clear the session ID for the current thread."""
|
||||
_session_context.session_id = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record factory — injects session_tag into every LogRecord at creation
|
||||
|
||||
+44
-21
@@ -464,6 +464,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 +485,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()
|
||||
Generated
+44
-20
@@ -10,11 +10,11 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@askjo/camoufox-browser": "^1.0.0",
|
||||
"@askjo/camofox-browser": "^1.5.2",
|
||||
"agent-browser": "^0.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@appium/logger": {
|
||||
@@ -33,20 +33,19 @@
|
||||
"npm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@askjo/camoufox-browser": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@askjo/camoufox-browser/-/camoufox-browser-1.0.12.tgz",
|
||||
"integrity": "sha512-MxRvjK6SkX6zJSNleoO32g9iwhJAcXpaAgj4pik7y2SrYXqcHllpG7FfLkKE7d5bnBt7pO82rdarVYu6xtW2RA==",
|
||||
"deprecated": "Renamed to @askjo/camofox-browser",
|
||||
"node_modules/@askjo/camofox-browser": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@askjo/camofox-browser/-/camofox-browser-1.5.2.tgz",
|
||||
"integrity": "sha512-SvRCzhWnJaplxHkRVF9l1OWako6pp2eUw2mZKHOERUfLWDO2Xe/IKI+5bB+UT1TNvO45P6XdhgfAtihcTEARCg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camoufox-js": "^0.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"playwright": "^1.50.0",
|
||||
"playwright-core": "^1.58.0",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"prom-client": "^15.1.3",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -122,6 +121,15 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -977,6 +985,12 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bintrees": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
|
||||
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -1794,18 +1808,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -4032,6 +4034,19 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prom-client": {
|
||||
"version": "15.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
|
||||
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"tdigest": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16 || ^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -5269,6 +5284,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tdigest": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
|
||||
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bintrees": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/teen_process": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/teen_process/-/teen_process-2.3.3.tgz",
|
||||
|
||||
+2
-2
@@ -17,12 +17,12 @@
|
||||
"homepage": "https://github.com/NousResearch/Hermes-Agent#readme",
|
||||
"dependencies": {
|
||||
"agent-browser": "^0.13.0",
|
||||
"@askjo/camoufox-browser": "^1.0.0"
|
||||
"@askjo/camofox-browser": "^1.5.2"
|
||||
},
|
||||
"overrides": {
|
||||
"lodash": "4.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,19 +509,24 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
result = resp.get("result", {})
|
||||
|
||||
# Format results for the model — keep it concise
|
||||
formatted = []
|
||||
scored_entries = []
|
||||
for ctx_type in ("memories", "resources", "skills"):
|
||||
items = result.get(ctx_type, [])
|
||||
for item in items:
|
||||
raw_score = item.get("score")
|
||||
sort_score = raw_score if raw_score is not None else 0.0
|
||||
entry = {
|
||||
"uri": item.get("uri", ""),
|
||||
"type": ctx_type.rstrip("s"),
|
||||
"score": round(item.get("score", 0), 3),
|
||||
"score": round(raw_score, 3) if raw_score is not None else 0.0,
|
||||
"abstract": item.get("abstract", ""),
|
||||
}
|
||||
if item.get("relations"):
|
||||
entry["related"] = [r.get("uri") for r in item["relations"][:3]]
|
||||
formatted.append(entry)
|
||||
scored_entries.append((sort_score, entry))
|
||||
|
||||
scored_entries.sort(key=lambda x: x[0], reverse=True)
|
||||
formatted = [entry for _, entry in scored_entries]
|
||||
|
||||
return json.dumps({
|
||||
"results": formatted,
|
||||
|
||||
+3
-3
@@ -78,13 +78,13 @@ dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
||||
feishu = ["lark-oapi>=1.5.3,<2"]
|
||||
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||
rl = [
|
||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b",
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
"wandb>=0.15.0,<1",
|
||||
]
|
||||
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git ; python_version >= '3.12'"]
|
||||
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"]
|
||||
all = [
|
||||
"hermes-agent[modal]",
|
||||
"hermes-agent[daytona]",
|
||||
|
||||
+60
-18
@@ -6143,6 +6143,12 @@ class AIAgent:
|
||||
elif self.reasoning_config.get("effort"):
|
||||
reasoning_effort = self.reasoning_config["effort"]
|
||||
|
||||
# Clamp effort levels not supported by the Responses API model.
|
||||
# GPT-5.4 supports none/low/medium/high/xhigh but not "minimal".
|
||||
# "minimal" is valid on OpenRouter and GPT-5 but fails on 5.2/5.4.
|
||||
_effort_clamp = {"minimal": "low"}
|
||||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"instructions": instructions,
|
||||
@@ -6890,6 +6896,18 @@ class AIAgent:
|
||||
tools. Used by the concurrent execution path; the sequential path retains
|
||||
its own inline invocation for backward-compatible display handling.
|
||||
"""
|
||||
# Check plugin hooks for a block directive before executing anything.
|
||||
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=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if block_message is not None:
|
||||
return json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
|
||||
if function_name == "todo":
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
return _todo_tool(
|
||||
@@ -6954,6 +6972,7 @@ class AIAgent:
|
||||
tool_call_id=tool_call_id,
|
||||
session_id=self.session_id or "",
|
||||
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
)
|
||||
|
||||
def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
@@ -7184,12 +7203,6 @@ class AIAgent:
|
||||
|
||||
function_name = tool_call.function.name
|
||||
|
||||
# Reset nudge counters when the relevant tool is actually used
|
||||
if function_name == "memory":
|
||||
self._turns_since_memory = 0
|
||||
elif function_name == "skill_manage":
|
||||
self._iters_since_skill = 0
|
||||
|
||||
try:
|
||||
function_args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError as e:
|
||||
@@ -7198,6 +7211,27 @@ class AIAgent:
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# Check plugin hooks for a block directive before executing.
|
||||
_block_msg: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _block_msg is not None:
|
||||
# Tool blocked by plugin policy — skip counter resets.
|
||||
# Execution is handled below in the tool dispatch chain.
|
||||
pass
|
||||
else:
|
||||
# Reset nudge counters when the relevant tool is actually used
|
||||
if function_name == "memory":
|
||||
self._turns_since_memory = 0
|
||||
elif function_name == "skill_manage":
|
||||
self._iters_since_skill = 0
|
||||
|
||||
if not self.quiet_mode:
|
||||
args_str = json.dumps(function_args, ensure_ascii=False)
|
||||
if self.verbose_logging:
|
||||
@@ -7207,33 +7241,35 @@ class AIAgent:
|
||||
args_preview = args_str[:self.log_prefix_chars] + "..." if len(args_str) > self.log_prefix_chars else args_str
|
||||
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}")
|
||||
|
||||
self._current_tool = function_name
|
||||
self._touch_activity(f"executing tool: {function_name}")
|
||||
if _block_msg is None:
|
||||
self._current_tool = function_name
|
||||
self._touch_activity(f"executing tool: {function_name}")
|
||||
|
||||
# Set activity callback for long-running tool execution (terminal
|
||||
# commands, etc.) so the gateway's inactivity monitor doesn't kill
|
||||
# the agent while a command is running.
|
||||
try:
|
||||
from tools.environments.base import set_activity_callback
|
||||
set_activity_callback(self._touch_activity)
|
||||
except Exception:
|
||||
pass
|
||||
if _block_msg is None:
|
||||
try:
|
||||
from tools.environments.base import set_activity_callback
|
||||
set_activity_callback(self._touch_activity)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.tool_progress_callback:
|
||||
if _block_msg is None and self.tool_progress_callback:
|
||||
try:
|
||||
preview = _build_tool_preview(function_name, function_args)
|
||||
self.tool_progress_callback("tool.started", function_name, preview, function_args)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
if self.tool_start_callback:
|
||||
if _block_msg is None and self.tool_start_callback:
|
||||
try:
|
||||
self.tool_start_callback(tool_call.id, function_name, function_args)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool start callback error: {cb_err}")
|
||||
|
||||
# Checkpoint: snapshot working dir before file-mutating tools
|
||||
if function_name in ("write_file", "patch") and self._checkpoint_mgr.enabled:
|
||||
if _block_msg is None and function_name in ("write_file", "patch") and self._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
@@ -7245,7 +7281,7 @@ class AIAgent:
|
||||
pass # never block tool execution
|
||||
|
||||
# Checkpoint before destructive terminal commands
|
||||
if function_name == "terminal" and self._checkpoint_mgr.enabled:
|
||||
if _block_msg is None and function_name == "terminal" and self._checkpoint_mgr.enabled:
|
||||
try:
|
||||
cmd = function_args.get("command", "")
|
||||
if _is_destructive_command(cmd):
|
||||
@@ -7258,7 +7294,11 @@ class AIAgent:
|
||||
|
||||
tool_start_time = time.time()
|
||||
|
||||
if function_name == "todo":
|
||||
if _block_msg is not None:
|
||||
# Tool blocked by plugin policy — return error without executing.
|
||||
function_result = json.dumps({"error": _block_msg}, ensure_ascii=False)
|
||||
tool_duration = 0.0
|
||||
elif function_name == "todo":
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
function_result = _todo_tool(
|
||||
todos=function_args.get("todos"),
|
||||
@@ -7401,6 +7441,7 @@ class AIAgent:
|
||||
tool_call_id=tool_call.id,
|
||||
session_id=self.session_id or "",
|
||||
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
)
|
||||
_spinner_result = function_result
|
||||
except Exception as tool_error:
|
||||
@@ -7420,6 +7461,7 @@ class AIAgent:
|
||||
tool_call_id=tool_call.id,
|
||||
session_id=self.session_id or "",
|
||||
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
)
|
||||
except Exception as tool_error:
|
||||
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
|
||||
@@ -333,6 +333,16 @@ def main():
|
||||
default=None,
|
||||
help="Path to a release notes file to check for missing contributors",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Exit with code 1 if new unmapped emails are found (for CI)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff-base",
|
||||
default=None,
|
||||
help="Git ref to diff against (only flag emails from commits after this ref)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"=== Contributor Audit: {args.since_tag}..{args.until} ===")
|
||||
@@ -398,6 +408,42 @@ def main():
|
||||
for email, name in sorted(all_unknowns.items()):
|
||||
print(f' "{email}": "{name}",')
|
||||
|
||||
# ---- Strict mode: fail CI if new unmapped emails are introduced ----
|
||||
if args.strict and all_unknowns:
|
||||
# In strict mode, check if ANY unknown emails come from commits in this
|
||||
# PR's diff range (new unmapped emails that weren't there before).
|
||||
# This is the CI gate: existing unknowns are grandfathered, but new
|
||||
# commits must have their author email in AUTHOR_MAP.
|
||||
new_unknowns = {}
|
||||
if args.diff_base:
|
||||
# Only flag emails from commits after diff_base
|
||||
new_commits_output = git(
|
||||
"log", f"{args.diff_base}..HEAD",
|
||||
"--format=%ae", "--no-merges",
|
||||
)
|
||||
new_emails = set(new_commits_output.splitlines()) if new_commits_output else set()
|
||||
for email, name in all_unknowns.items():
|
||||
if email in new_emails:
|
||||
new_unknowns[email] = name
|
||||
else:
|
||||
new_unknowns = all_unknowns
|
||||
|
||||
if new_unknowns:
|
||||
print()
|
||||
print(f"=== STRICT MODE FAILURE: {len(new_unknowns)} new unmapped email(s) ===")
|
||||
print("Add these to AUTHOR_MAP in scripts/release.py before merging:")
|
||||
print()
|
||||
for email, name in sorted(new_unknowns.items()):
|
||||
print(f' "{email}": "<github-username>",')
|
||||
print()
|
||||
print("To find the GitHub username:")
|
||||
print(" gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'")
|
||||
strict_failed = True
|
||||
else:
|
||||
strict_failed = False
|
||||
else:
|
||||
strict_failed = False
|
||||
|
||||
# ---- Release file comparison ----
|
||||
if args.release_file:
|
||||
print()
|
||||
@@ -419,6 +465,9 @@ def main():
|
||||
print()
|
||||
print("Done.")
|
||||
|
||||
if strict_failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+22
-2
@@ -945,6 +945,7 @@ setup_path() {
|
||||
# which is always bash when piped from curl).
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$command_link_dir$"; then
|
||||
SHELL_CONFIGS=()
|
||||
IS_FISH=false
|
||||
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
|
||||
case "$LOGIN_SHELL" in
|
||||
zsh)
|
||||
@@ -960,6 +961,13 @@ setup_path() {
|
||||
[ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
|
||||
[ -f "$HOME/.bash_profile" ] && SHELL_CONFIGS+=("$HOME/.bash_profile")
|
||||
;;
|
||||
fish)
|
||||
# fish uses ~/.config/fish/config.fish and fish_add_path — not export PATH=
|
||||
IS_FISH=true
|
||||
FISH_CONFIG="$HOME/.config/fish/config.fish"
|
||||
mkdir -p "$(dirname "$FISH_CONFIG")"
|
||||
touch "$FISH_CONFIG"
|
||||
;;
|
||||
*)
|
||||
[ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
|
||||
[ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc")
|
||||
@@ -967,7 +975,7 @@ setup_path() {
|
||||
esac
|
||||
# Also ensure ~/.profile has it (sourced by login shells on
|
||||
# Ubuntu/Debian/WSL even when ~/.bashrc is skipped)
|
||||
[ -f "$HOME/.profile" ] && SHELL_CONFIGS+=("$HOME/.profile")
|
||||
[ "$IS_FISH" = "false" ] && [ -f "$HOME/.profile" ] && SHELL_CONFIGS+=("$HOME/.profile")
|
||||
|
||||
PATH_LINE='export PATH="$HOME/.local/bin:$PATH"'
|
||||
|
||||
@@ -980,7 +988,17 @@ setup_path() {
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
|
||||
# fish uses fish_add_path instead of export PATH=...
|
||||
if [ "$IS_FISH" = "true" ]; then
|
||||
if ! grep -q 'fish_add_path.*\.local/bin' "$FISH_CONFIG" 2>/dev/null; then
|
||||
echo "" >> "$FISH_CONFIG"
|
||||
echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$FISH_CONFIG"
|
||||
echo 'fish_add_path "$HOME/.local/bin"' >> "$FISH_CONFIG"
|
||||
log_success "Added ~/.local/bin to PATH in $FISH_CONFIG"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$IS_FISH" = "false" ] && [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
|
||||
log_warn "Could not detect shell config file to add ~/.local/bin to PATH"
|
||||
log_info "Add manually: $PATH_LINE"
|
||||
fi
|
||||
@@ -1315,6 +1333,8 @@ print_success() {
|
||||
echo " source ~/.zshrc"
|
||||
elif [ "$LOGIN_SHELL" = "bash" ]; then
|
||||
echo " source ~/.bashrc"
|
||||
elif [ "$LOGIN_SHELL" = "fish" ]; then
|
||||
echo " source ~/.config/fish/config.fish"
|
||||
else
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
fi
|
||||
|
||||
@@ -62,6 +62,7 @@ AUTHOR_MAP = {
|
||||
"258577966+voidborne-d@users.noreply.github.com": "voidborne-d",
|
||||
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
|
||||
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
|
||||
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
|
||||
# contributors (manual mapping from git names)
|
||||
"dmayhem93@gmail.com": "dmahan93",
|
||||
"samherring99@gmail.com": "samherring99",
|
||||
@@ -98,6 +99,7 @@ AUTHOR_MAP = {
|
||||
"bryan@intertwinesys.com": "bryanyoung",
|
||||
"christo.mitov@gmail.com": "christomitov",
|
||||
"hermes@nousresearch.com": "NousResearch",
|
||||
"chinmingcock@gmail.com": "ChimingLiu",
|
||||
"openclaw@sparklab.ai": "openclaw",
|
||||
"semihcvlk53@gmail.com": "Himess",
|
||||
"erenkar950@gmail.com": "erenkarakus",
|
||||
@@ -112,6 +114,85 @@ AUTHOR_MAP = {
|
||||
"dalvidjr2022@gmail.com": "Jr-kenny",
|
||||
"m@statecraft.systems": "mbierling",
|
||||
"balyan.sid@gmail.com": "balyansid",
|
||||
"oluwadareab12@gmail.com": "bennytimz",
|
||||
# ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply
|
||||
# crossref, and GH contributor list matching (April 2026 audit) ──
|
||||
"1115117931@qq.com": "aaronagent",
|
||||
"1506751656@qq.com": "hqhq1025",
|
||||
"364939526@qq.com": "luyao618",
|
||||
"aaronwong1999@icloud.com": "AaronWong1999",
|
||||
"agents@kylefrench.dev": "DeployFaith",
|
||||
"angelos@oikos.lan.home.malaiwah.com": "angelos",
|
||||
"aptx4561@gmail.com": "cokemine",
|
||||
"arilotter@gmail.com": "ethernet8023",
|
||||
"ben@nousresearch.com": "benbarclay",
|
||||
"birdiegyal@gmail.com": "yyovil",
|
||||
"boschi1997@gmail.com": "nicoloboschi",
|
||||
"chef.ya@gmail.com": "cherifya",
|
||||
"chlqhdtn98@gmail.com": "BongSuCHOI",
|
||||
"coffeemjj@gmail.com": "Cafexss",
|
||||
"dalianmao0107@gmail.com": "dalianmao000",
|
||||
"der@konsi.org": "konsisumer",
|
||||
"dgrieco@redhat.com": "DomGrieco",
|
||||
"dhicham.pro@gmail.com": "spideystreet",
|
||||
"dipp.who@gmail.com": "dippwho",
|
||||
"don.rhm@gmail.com": "donrhmexe",
|
||||
"dorukardahan@hotmail.com": "dorukardahan",
|
||||
"dsocolobsky@gmail.com": "dsocolobsky",
|
||||
"duerzy@gmail.com": "duerzy",
|
||||
"emozilla@nousresearch.com": "emozilla",
|
||||
"fancydirty@gmail.com": "fancydirty",
|
||||
"floptopbot33@gmail.com": "flobo3",
|
||||
"fontana.pedro93@gmail.com": "pefontana",
|
||||
"francis.x.fitzpatrick@gmail.com": "fxfitz",
|
||||
"frank@helmschrott.de": "Helmi",
|
||||
"gaixg94@gmail.com": "gaixianggeng",
|
||||
"geoff.wellman@gmail.com": "geoffwellman",
|
||||
"han.shan@live.cn": "jamesarch",
|
||||
"haolong@microsoft.com": "LongOddCode",
|
||||
"hata1234@gmail.com": "hata1234",
|
||||
"hmbown@gmail.com": "Hmbown",
|
||||
"iacobs@m0n5t3r.info": "m0n5t3r",
|
||||
"jiayuw794@gmail.com": "JiayuuWang",
|
||||
"jonny@nousresearch.com": "jquesnelle",
|
||||
"juan.ovalle@mistral.ai": "jjovalle99",
|
||||
"julien.talbot@ergonomia.re": "Julientalbot",
|
||||
"kagura.chen28@gmail.com": "kagura-agent",
|
||||
"kamil@gwozdz.me": "kamil-gwozdz",
|
||||
"karamusti912@gmail.com": "MustafaKara7",
|
||||
"kira@ariaki.me": "kira-ariaki",
|
||||
"knopki@duck.com": "knopki",
|
||||
"limars874@gmail.com": "limars874",
|
||||
"lisicheng168@gmail.com": "lesterli",
|
||||
"mingjwan@microsoft.com": "MagicRay1217",
|
||||
"niyant@spicefi.xyz": "spniyant",
|
||||
"olafthiele@gmail.com": "olafthiele",
|
||||
"oncuevtv@gmail.com": "sprmn24",
|
||||
"programming@olafthiele.com": "olafthiele",
|
||||
"r2668940489@gmail.com": "r266-tech",
|
||||
"s5460703@gmail.com": "BlackishGreen33",
|
||||
"saul.jj.wu@gmail.com": "SaulJWu",
|
||||
"shenhaocheng19990111@gmail.com": "hcshen0111",
|
||||
"sjtuwbh@gmail.com": "Cygra",
|
||||
"srhtsrht17@gmail.com": "Sertug17",
|
||||
"stephenschoettler@gmail.com": "stephenschoettler",
|
||||
"tanishq231003@gmail.com": "yyovil",
|
||||
"tesseracttars@gmail.com": "tesseracttars-creator",
|
||||
"tianliangjay@gmail.com": "xingkongliang",
|
||||
"tranquil_flow@protonmail.com": "Tranquil-Flow",
|
||||
"unayung@gmail.com": "Unayung",
|
||||
"vorvul.danylo@gmail.com": "WorldInnovationsDepartment",
|
||||
"win4r@outlook.com": "win4r",
|
||||
"xush@xush.org": "KUSH42",
|
||||
"yangzhi.see@gmail.com": "SeeYangZhi",
|
||||
"yongtenglei@gmail.com": "yongtenglei",
|
||||
"young@YoungdeMacBook-Pro.local": "YoungYang963",
|
||||
"ysfalweshcan@gmail.com": "Awsh1",
|
||||
"ysfwaxlycan@gmail.com": "WAXLYY",
|
||||
"yusufalweshdemir@gmail.com": "Dusk1e",
|
||||
"zhouboli@gmail.com": "zhouboli",
|
||||
"zqiao@microsoft.com": "tomqiaozc",
|
||||
"zzn+pa@zzn.im": "xinbenlv",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"start": "node bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#fix/abprops-abt-fetch",
|
||||
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770",
|
||||
"express": "^4.21.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: architecture-diagram
|
||||
description: Generate professional dark-themed system architecture diagrams as standalone HTML/SVG files. Self-contained output with no external dependencies. Based on Cocoon AI's architecture-diagram-generator (MIT).
|
||||
version: 1.0.0
|
||||
author: Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent
|
||||
license: MIT
|
||||
dependencies: []
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [architecture, diagrams, SVG, HTML, visualization, infrastructure, cloud]
|
||||
related_skills: [excalidraw]
|
||||
---
|
||||
|
||||
# Architecture Diagram Skill
|
||||
|
||||
Generate professional, dark-themed technical architecture diagrams as standalone HTML files with inline SVG graphics. No external tools, no API keys, no rendering libraries — just write the HTML file and open it in a browser.
|
||||
|
||||
Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) (MIT).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. User describes their system architecture (components, connections, technologies)
|
||||
2. Generate the HTML file following the design system below
|
||||
3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`)
|
||||
4. User opens in any browser — works offline, no dependencies
|
||||
|
||||
### Output Location
|
||||
|
||||
Save diagrams to a user-specified path, or default to the current working directory:
|
||||
```
|
||||
./[project-name]-architecture.html
|
||||
```
|
||||
|
||||
### Preview
|
||||
|
||||
After saving, suggest the user open it:
|
||||
```bash
|
||||
# macOS
|
||||
open ./my-architecture.html
|
||||
# Linux
|
||||
xdg-open ./my-architecture.html
|
||||
```
|
||||
|
||||
## Design System & Visual Language
|
||||
|
||||
### Color Palette (Semantic Mapping)
|
||||
|
||||
Use specific `rgba` fills and hex strokes to categorize components:
|
||||
|
||||
| Component Type | Fill (rgba) | Stroke (Hex) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Frontend** | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan-400) |
|
||||
| **Backend** | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald-400) |
|
||||
| **Database** | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet-400) |
|
||||
| **AWS/Cloud** | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber-400) |
|
||||
| **Security** | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose-400) |
|
||||
| **Message Bus** | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange-400) |
|
||||
| **External** | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate-400) |
|
||||
|
||||
### Typography & Background
|
||||
- **Font:** JetBrains Mono (Monospace), loaded from Google Fonts
|
||||
- **Sizes:** 12px (Names), 9px (Sublabels), 8px (Annotations), 7px (Tiny labels)
|
||||
- **Background:** Slate-950 (`#020617`) with a subtle 40px grid pattern
|
||||
|
||||
```svg
|
||||
<!-- Background Grid Pattern -->
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
```
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Component Rendering
|
||||
Components are rounded rectangles (`rx="6"`) with 1.5px strokes. To prevent arrows from showing through semi-transparent fills, use a **double-rect masking technique**:
|
||||
1. Draw an opaque background rect (`#0f172a`)
|
||||
2. Draw the semi-transparent styled rect on top
|
||||
|
||||
### Connection Rules
|
||||
- **Z-Order:** Draw arrows *early* in the SVG (after the grid) so they render behind component boxes
|
||||
- **Arrowheads:** Defined via SVG markers
|
||||
- **Security Flows:** Use dashed lines in rose color (`#fb7185`)
|
||||
- **Boundaries:**
|
||||
- *Security Groups:* Dashed (`4,4`), rose color
|
||||
- *Regions:* Large dashed (`8,4`), amber color, `rx="12"`
|
||||
|
||||
### Spacing & Layout Logic
|
||||
- **Standard Height:** 60px (Services); 80-120px (Large components)
|
||||
- **Vertical Gap:** Minimum 40px between components
|
||||
- **Message Buses:** Must be placed *in the gap* between services, not overlapping them
|
||||
- **Legend Placement:** **CRITICAL.** Must be placed outside all boundary boxes. Calculate the lowest Y-coordinate of all boundaries and place the legend at least 20px below it.
|
||||
|
||||
## Document Structure
|
||||
|
||||
The generated HTML file follows a four-part layout:
|
||||
1. **Header:** Title with a pulsing dot indicator and subtitle
|
||||
2. **Main SVG:** The diagram contained within a rounded border card
|
||||
3. **Summary Cards:** A grid of three cards below the diagram for high-level details
|
||||
4. **Footer:** Minimal metadata
|
||||
|
||||
### Info Card Pattern
|
||||
```html
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot cyan"></div>
|
||||
<h3>Title</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>• Item one</li>
|
||||
<li>• Item two</li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Output Requirements
|
||||
- **Single File:** One self-contained `.html` file
|
||||
- **No External Dependencies:** All CSS and SVG must be inline (except Google Fonts)
|
||||
- **No JavaScript:** Use pure CSS for any animations (like pulsing dots)
|
||||
- **Compatibility:** Must render correctly in any modern web browser
|
||||
|
||||
## Template Reference
|
||||
|
||||
Load the full HTML template for the exact structure, CSS, and SVG component examples:
|
||||
|
||||
```
|
||||
skill_view(name="architecture-diagram", file_path="templates/template.html")
|
||||
```
|
||||
|
||||
The template contains working examples of every component type (frontend, backend, database, cloud, security), arrow styles (standard, dashed, curved), security groups, region boundaries, and the legend — use it as your structural reference when generating diagrams.
|
||||
@@ -0,0 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>[PROJECT NAME] Architecture Diagram</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: #020617;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #22d3ee;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
margin-left: 1.75rem;
|
||||
}
|
||||
|
||||
.diagram-container {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 1.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.card-dot.cyan { background: #22d3ee; }
|
||||
.card-dot.emerald { background: #34d399; }
|
||||
.card-dot.violet { background: #a78bfa; }
|
||||
.card-dot.amber { background: #fbbf24; }
|
||||
.card-dot.rose { background: #fb7185; }
|
||||
|
||||
.card h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card ul {
|
||||
list-style: none;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.card li {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-row">
|
||||
<div class="pulse-dot"></div>
|
||||
<h1>[PROJECT NAME] Architecture</h1>
|
||||
</div>
|
||||
<p class="subtitle">[Subtitle description]</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Diagram -->
|
||||
<div class="diagram-container">
|
||||
<svg viewBox="0 0 1000 680">
|
||||
<!-- Definitions -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b" />
|
||||
</marker>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- =================================================================
|
||||
COMPONENT EXAMPLES - Copy and customize these patterns
|
||||
================================================================= -->
|
||||
|
||||
<!-- External/Generic Component -->
|
||||
<rect x="30" y="280" width="100" height="50" rx="6" fill="rgba(30, 41, 59, 0.5)" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<text x="80" y="300" fill="white" font-size="11" font-weight="600" text-anchor="middle">Users</text>
|
||||
<text x="80" y="316" fill="#94a3b8" font-size="9" text-anchor="middle">Browser/Mobile</text>
|
||||
|
||||
<!-- Security Component -->
|
||||
<rect x="30" y="80" width="100" height="60" rx="6" fill="rgba(136, 19, 55, 0.4)" stroke="#fb7185" stroke-width="1.5"/>
|
||||
<text x="80" y="105" fill="white" font-size="11" font-weight="600" text-anchor="middle">Auth Provider</text>
|
||||
<text x="80" y="121" fill="#94a3b8" font-size="9" text-anchor="middle">OAuth 2.0</text>
|
||||
|
||||
<!-- Region/Cloud Boundary -->
|
||||
<rect x="160" y="40" width="820" height="620" rx="12" fill="rgba(251, 191, 36, 0.05)" stroke="#fbbf24" stroke-width="1" stroke-dasharray="8,4"/>
|
||||
<text x="172" y="58" fill="#fbbf24" font-size="10" font-weight="600">AWS Region: us-west-2</text>
|
||||
|
||||
<!-- AWS/Cloud Service -->
|
||||
<rect x="200" y="280" width="110" height="50" rx="6" fill="rgba(120, 53, 15, 0.3)" stroke="#fbbf24" stroke-width="1.5"/>
|
||||
<text x="255" y="300" fill="white" font-size="11" font-weight="600" text-anchor="middle">CloudFront</text>
|
||||
<text x="255" y="316" fill="#94a3b8" font-size="9" text-anchor="middle">CDN</text>
|
||||
|
||||
<!-- Multi-line AWS Component (S3 Buckets example) -->
|
||||
<rect x="200" y="380" width="110" height="100" rx="6" fill="rgba(120, 53, 15, 0.3)" stroke="#fbbf24" stroke-width="1.5"/>
|
||||
<text x="255" y="400" fill="white" font-size="11" font-weight="600" text-anchor="middle">S3 Buckets</text>
|
||||
<text x="255" y="420" fill="#94a3b8" font-size="8" text-anchor="middle">• bucket-one</text>
|
||||
<text x="255" y="434" fill="#94a3b8" font-size="8" text-anchor="middle">• bucket-two</text>
|
||||
<text x="255" y="448" fill="#94a3b8" font-size="8" text-anchor="middle">• bucket-three</text>
|
||||
<text x="255" y="466" fill="#fbbf24" font-size="7" text-anchor="middle">OAI Protected</text>
|
||||
|
||||
<!-- Security Group (dashed boundary) -->
|
||||
<rect x="350" y="265" width="120" height="80" rx="8" fill="transparent" stroke="#fb7185" stroke-width="1" stroke-dasharray="4,4"/>
|
||||
<text x="358" y="279" fill="#fb7185" font-size="8">sg-name :port</text>
|
||||
|
||||
<!-- Component inside security group -->
|
||||
<rect x="360" y="280" width="100" height="50" rx="6" fill="rgba(120, 53, 15, 0.3)" stroke="#fbbf24" stroke-width="1.5"/>
|
||||
<text x="410" y="300" fill="white" font-size="11" font-weight="600" text-anchor="middle">Load Balancer</text>
|
||||
<text x="410" y="316" fill="#94a3b8" font-size="9" text-anchor="middle">HTTPS :443</text>
|
||||
|
||||
<!-- Backend Component -->
|
||||
<rect x="510" y="280" width="110" height="50" rx="6" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1.5"/>
|
||||
<text x="565" y="300" fill="white" font-size="11" font-weight="600" text-anchor="middle">API Server</text>
|
||||
<text x="565" y="316" fill="#94a3b8" font-size="9" text-anchor="middle">FastAPI :8000</text>
|
||||
|
||||
<!-- Database Component -->
|
||||
<rect x="700" y="280" width="120" height="50" rx="6" fill="rgba(76, 29, 149, 0.4)" stroke="#a78bfa" stroke-width="1.5"/>
|
||||
<text x="760" y="300" fill="white" font-size="11" font-weight="600" text-anchor="middle">Database</text>
|
||||
<text x="760" y="316" fill="#94a3b8" font-size="9" text-anchor="middle">PostgreSQL</text>
|
||||
|
||||
<!-- Frontend Component -->
|
||||
<rect x="200" y="520" width="200" height="110" rx="8" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="300" y="545" fill="white" font-size="12" font-weight="600" text-anchor="middle">Frontend</text>
|
||||
<text x="300" y="565" fill="#94a3b8" font-size="9" text-anchor="middle">React + TypeScript</text>
|
||||
<text x="300" y="580" fill="#94a3b8" font-size="9" text-anchor="middle">Additional detail</text>
|
||||
<text x="300" y="595" fill="#94a3b8" font-size="9" text-anchor="middle">More info</text>
|
||||
<text x="300" y="615" fill="#22d3ee" font-size="8" text-anchor="middle">domain.example.com</text>
|
||||
|
||||
<!-- =================================================================
|
||||
ARROW EXAMPLES
|
||||
================================================================= -->
|
||||
|
||||
<!-- Standard arrow with label -->
|
||||
<line x1="130" y1="305" x2="198" y2="305" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<text x="164" y="299" fill="#94a3b8" font-size="9" text-anchor="middle">HTTPS</text>
|
||||
|
||||
<!-- Simple arrow (no label) -->
|
||||
<line x1="310" y1="305" x2="358" y2="305" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Vertical arrow -->
|
||||
<line x1="255" y1="330" x2="255" y2="378" stroke="#fbbf24" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<text x="270" y="358" fill="#94a3b8" font-size="9">OAI</text>
|
||||
|
||||
<!-- Dashed arrow (for auth/security flows) -->
|
||||
<line x1="460" y1="305" x2="508" y2="305" stroke="#34d399" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<line x1="620" y1="305" x2="698" y2="305" stroke="#a78bfa" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<text x="655" y="299" fill="#94a3b8" font-size="9">TLS</text>
|
||||
|
||||
<!-- Curved path for auth flow -->
|
||||
<path d="M 80 140 L 80 200 Q 80 220 100 220 L 200 220 Q 220 220 220 240 L 220 278" fill="none" stroke="#fb7185" stroke-width="1.5" stroke-dasharray="5,5"/>
|
||||
<text x="150" y="210" fill="#fb7185" font-size="8">JWT + PKCE</text>
|
||||
|
||||
<!-- =================================================================
|
||||
LEGEND
|
||||
================================================================= -->
|
||||
<text x="720" y="70" fill="white" font-size="10" font-weight="600">Legend</text>
|
||||
|
||||
<rect x="720" y="82" width="16" height="10" rx="2" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1"/>
|
||||
<text x="742" y="90" fill="#94a3b8" font-size="8">Frontend</text>
|
||||
|
||||
<rect x="720" y="98" width="16" height="10" rx="2" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1"/>
|
||||
<text x="742" y="106" fill="#94a3b8" font-size="8">Backend</text>
|
||||
|
||||
<rect x="720" y="114" width="16" height="10" rx="2" fill="rgba(120, 53, 15, 0.3)" stroke="#fbbf24" stroke-width="1"/>
|
||||
<text x="742" y="122" fill="#94a3b8" font-size="8">Cloud Service</text>
|
||||
|
||||
<rect x="720" y="130" width="16" height="10" rx="2" fill="rgba(76, 29, 149, 0.4)" stroke="#a78bfa" stroke-width="1"/>
|
||||
<text x="742" y="138" fill="#94a3b8" font-size="8">Database</text>
|
||||
|
||||
<rect x="720" y="146" width="16" height="10" rx="2" fill="rgba(136, 19, 55, 0.4)" stroke="#fb7185" stroke-width="1"/>
|
||||
<text x="742" y="154" fill="#94a3b8" font-size="8">Security</text>
|
||||
|
||||
<line x1="720" y1="168" x2="736" y2="168" stroke="#fb7185" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<text x="742" y="171" fill="#94a3b8" font-size="8">Auth Flow</text>
|
||||
|
||||
<rect x="720" y="178" width="16" height="10" rx="2" fill="transparent" stroke="#fb7185" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<text x="742" y="186" fill="#94a3b8" font-size="8">Security Group</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Info Cards -->
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot rose"></div>
|
||||
<h3>Card Title 1</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>• Item one</li>
|
||||
<li>• Item two</li>
|
||||
<li>• Item three</li>
|
||||
<li>• Item four</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot amber"></div>
|
||||
<h3>Card Title 2</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>• Item one</li>
|
||||
<li>• Item two</li>
|
||||
<li>• Item three</li>
|
||||
<li>• Item four</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot violet"></div>
|
||||
<h3>Card Title 3</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>• Item one</li>
|
||||
<li>• Item two</li>
|
||||
<li>• Item three</li>
|
||||
<li>• Item four</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="footer">
|
||||
[Project Name] • [Additional metadata]
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -365,7 +365,7 @@ class TestExpiredCodexFallback:
|
||||
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
|
||||
"""OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*)."""
|
||||
# Mock resolve_anthropic_token to return an OAuth-style token
|
||||
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="hermes-oauth-jwt-token"), \
|
||||
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat-hermes-token"), \
|
||||
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
|
||||
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
||||
mock_build.return_value = MagicMock()
|
||||
@@ -420,7 +420,7 @@ class TestExpiredCodexFallback:
|
||||
|
||||
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
|
||||
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "cc-oauth-token-test")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat-cc-test-token")
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
|
||||
mock_build.return_value = MagicMock()
|
||||
@@ -786,7 +786,7 @@ class TestAuxiliaryPoolAwareness:
|
||||
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
||||
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
|
||||
):
|
||||
client, model = get_vision_auxiliary_client()
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert client is not None
|
||||
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
||||
|
||||
@@ -25,6 +25,11 @@ def _make_compressor():
|
||||
compressor._previous_summary = None
|
||||
compressor._summary_failure_cooldown_until = 0.0
|
||||
compressor.summary_model = None
|
||||
compressor.model = "test-model"
|
||||
compressor.provider = "test"
|
||||
compressor.base_url = "http://localhost"
|
||||
compressor.api_key = "test-key"
|
||||
compressor.api_mode = "chat_completions"
|
||||
return compressor
|
||||
|
||||
|
||||
|
||||
@@ -1071,3 +1071,88 @@ def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_p
|
||||
|
||||
# Should NOT have seeded the claude_code entry
|
||||
assert pool.entries() == []
|
||||
|
||||
|
||||
def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):
|
||||
"""Copilot credentials from `gh auth token` should be seeded into the pool."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.copilot_auth.resolve_copilot_token",
|
||||
lambda: ("gho_fake_token_abc123", "gh auth token"),
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("copilot")
|
||||
|
||||
assert pool.has_credentials()
|
||||
entries = pool.entries()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].source == "gh_cli"
|
||||
assert entries[0].access_token == "gho_fake_token_abc123"
|
||||
|
||||
|
||||
def test_load_pool_does_not_seed_copilot_when_no_token(tmp_path, monkeypatch):
|
||||
"""Copilot pool should be empty when resolve_copilot_token() returns nothing."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.copilot_auth.resolve_copilot_token",
|
||||
lambda: ("", ""),
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("copilot")
|
||||
|
||||
assert not pool.has_credentials()
|
||||
assert pool.entries() == []
|
||||
|
||||
|
||||
def test_load_pool_seeds_qwen_oauth_via_cli_tokens(tmp_path, monkeypatch):
|
||||
"""Qwen OAuth credentials from ~/.qwen/oauth_creds.json should be seeded into the pool."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_qwen_runtime_credentials",
|
||||
lambda **kw: {
|
||||
"provider": "qwen-oauth",
|
||||
"base_url": "https://portal.qwen.ai/v1",
|
||||
"api_key": "qwen_fake_token_xyz",
|
||||
"source": "qwen-cli",
|
||||
"expires_at_ms": 1900000000000,
|
||||
"auth_file": str(tmp_path / ".qwen" / "oauth_creds.json"),
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("qwen-oauth")
|
||||
|
||||
assert pool.has_credentials()
|
||||
entries = pool.entries()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].source == "qwen-cli"
|
||||
assert entries[0].access_token == "qwen_fake_token_xyz"
|
||||
|
||||
|
||||
def test_load_pool_does_not_seed_qwen_oauth_when_no_token(tmp_path, monkeypatch):
|
||||
"""Qwen OAuth pool should be empty when no CLI credentials exist."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
|
||||
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_qwen_runtime_credentials",
|
||||
lambda **kw: (_ for _ in ()).throw(
|
||||
AuthError("Qwen CLI credentials not found.", provider="qwen-oauth", code="qwen_auth_missing")
|
||||
),
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("qwen-oauth")
|
||||
|
||||
assert not pool.has_credentials()
|
||||
assert pool.entries() == []
|
||||
|
||||
@@ -109,14 +109,12 @@ class TestMemoryManagerUserIdThreading:
|
||||
assert "user_id" not in p._init_kwargs
|
||||
|
||||
def test_multiple_providers_all_receive_user_id(self):
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
mgr = MemoryManager()
|
||||
# Use builtin + one external (MemoryManager only allows one external)
|
||||
builtin = BuiltinMemoryProvider()
|
||||
ext = RecordingProvider("external")
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(ext)
|
||||
# Use one provider named "builtin" (always accepted) and one external
|
||||
p1 = RecordingProvider("builtin")
|
||||
p2 = RecordingProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
mgr.initialize_all(
|
||||
session_id="sess-multi",
|
||||
@@ -124,8 +122,10 @@ class TestMemoryManagerUserIdThreading:
|
||||
user_id="slack_U12345",
|
||||
)
|
||||
|
||||
assert ext._init_kwargs.get("user_id") == "slack_U12345"
|
||||
assert ext._init_kwargs.get("platform") == "slack"
|
||||
assert p1._init_kwargs.get("user_id") == "slack_U12345"
|
||||
assert p1._init_kwargs.get("platform") == "slack"
|
||||
assert p2._init_kwargs.get("user_id") == "slack_U12345"
|
||||
assert p2._init_kwargs.get("platform") == "slack"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -211,17 +211,17 @@ class TestHonchoUserIdScoping:
|
||||
"""Verify Honcho plugin uses gateway user_id for peer_name when provided."""
|
||||
|
||||
def test_gateway_user_id_overrides_peer_name(self):
|
||||
"""When user_id is in kwargs, cfg.peer_name should be overridden."""
|
||||
"""When user_id is in kwargs and no explicit peer_name, user_id should be used."""
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
|
||||
# Create a mock config with a static peer_name
|
||||
# Create a mock config with NO explicit peer_name
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.enabled = True
|
||||
mock_cfg.api_key = "test-key"
|
||||
mock_cfg.base_url = None
|
||||
mock_cfg.peer_name = "static-user"
|
||||
mock_cfg.peer_name = "" # No explicit peer_name — user_id should fill it
|
||||
mock_cfg.recall_mode = "tools" # Use tools mode to defer session init
|
||||
|
||||
with patch(
|
||||
|
||||
@@ -63,6 +63,7 @@ class TestCLISubagentInterrupt(unittest.TestCase):
|
||||
parent._delegate_depth = 0
|
||||
parent._delegate_spinner = None
|
||||
parent.tool_progress_callback = None
|
||||
parent._execution_thread_id = None
|
||||
|
||||
# We'll track what happens with _active_children
|
||||
original_children = parent._active_children
|
||||
|
||||
@@ -576,8 +576,9 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
|
||||
|
||||
# After the probe detects a single model ("llm"), the flow asks
|
||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length.
|
||||
answers = iter(["http://localhost:8000", "local-key", "", ""])
|
||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
|
||||
# then display name.
|
||||
answers = iter(["http://localhost:8000", "local-key", "", "", ""])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
||||
monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
|
||||
|
||||
@@ -641,3 +642,46 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
|
||||
"ca_bundle": "/tmp/local-ca.pem",
|
||||
"insecure": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _auto_provider_name — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auto_provider_name_localhost():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)"
|
||||
assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)"
|
||||
|
||||
|
||||
def test_auto_provider_name_runpod():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
assert "RunPod" in _auto_provider_name("https://xyz.runpod.io/v1")
|
||||
|
||||
|
||||
def test_auto_provider_name_remote():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
result = _auto_provider_name("https://api.together.xyz/v1")
|
||||
assert result == "Api.together.xyz"
|
||||
|
||||
|
||||
def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path):
|
||||
"""When a display name is passed, it should appear in the saved entry."""
|
||||
import yaml
|
||||
from hermes_cli.main import _save_custom_provider
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.dump({}))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
|
||||
)
|
||||
saved = {}
|
||||
def _save(cfg):
|
||||
saved.update(cfg)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", _save)
|
||||
|
||||
_save_custom_provider("http://localhost:11434/v1", name="Ollama")
|
||||
entries = saved.get("custom_providers", [])
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["name"] == "Ollama"
|
||||
|
||||
@@ -369,7 +369,8 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
assert kwargs.get("speed") == "fast"
|
||||
assert kwargs.get("extra_body", {}).get("speed") == "fast"
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" in kwargs
|
||||
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
|
||||
|
||||
@@ -384,6 +385,7 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
reasoning_config=None,
|
||||
fast_mode=False,
|
||||
)
|
||||
assert kwargs.get("extra_body", {}).get("speed") is None
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
@@ -400,9 +402,24 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
base_url="https://api.minimax.io/anthropic/v1",
|
||||
)
|
||||
# Third-party endpoints should NOT get speed or fast-mode beta
|
||||
assert kwargs.get("extra_body", {}).get("speed") is None
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
assert "speed" not in kwargs
|
||||
assert kwargs.get("extra_body", {}).get("speed") == "fast"
|
||||
|
||||
|
||||
class TestConfigDefault(unittest.TestCase):
|
||||
def test_default_config_has_service_tier(self):
|
||||
|
||||
@@ -93,6 +93,12 @@ def make_restart_runner(
|
||||
runner._running_agent_count = GatewayRunner._running_agent_count.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._snapshot_running_agents = GatewayRunner._snapshot_running_agents.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._notify_active_sessions_of_shutdown = (
|
||||
GatewayRunner._notify_active_sessions_of_shutdown.__get__(runner, GatewayRunner)
|
||||
)
|
||||
runner._launch_detached_restart_command = GatewayRunner._launch_detached_restart_command.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
|
||||
@@ -220,6 +220,7 @@ def _create_app(adapter: APIServerAdapter) -> web.Application:
|
||||
app = web.Application(middlewares=mws)
|
||||
app["api_server_adapter"] = adapter
|
||||
app.router.add_get("/health", adapter._handle_health)
|
||||
app.router.add_get("/health/detailed", adapter._handle_health_detailed)
|
||||
app.router.add_get("/v1/health", adapter._handle_health)
|
||||
app.router.add_get("/v1/models", adapter._handle_models)
|
||||
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
|
||||
@@ -277,6 +278,58 @@ class TestHealthEndpoint:
|
||||
assert data["platform"] == "hermes-agent"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /health/detailed endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHealthDetailedEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_detailed_returns_ok(self, adapter):
|
||||
"""GET /health/detailed returns status, platform, and runtime fields."""
|
||||
app = _create_app(adapter)
|
||||
with patch("gateway.status.read_runtime_status", return_value={
|
||||
"gateway_state": "running",
|
||||
"platforms": {"telegram": {"state": "connected"}},
|
||||
"active_agents": 2,
|
||||
"exit_reason": None,
|
||||
"updated_at": "2026-04-14T00:00:00Z",
|
||||
}):
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/health/detailed")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["platform"] == "hermes-agent"
|
||||
assert data["gateway_state"] == "running"
|
||||
assert data["platforms"] == {"telegram": {"state": "connected"}}
|
||||
assert data["active_agents"] == 2
|
||||
assert isinstance(data["pid"], int)
|
||||
assert "updated_at" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_detailed_no_runtime_status(self, adapter):
|
||||
"""When gateway_state.json is missing, fields are None."""
|
||||
app = _create_app(adapter)
|
||||
with patch("gateway.status.read_runtime_status", return_value=None):
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/health/detailed")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["gateway_state"] is None
|
||||
assert data["platforms"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_detailed_does_not_require_auth(self, auth_adapter):
|
||||
"""Health detailed endpoint should be accessible without auth, like /health."""
|
||||
app = _create_app(auth_adapter)
|
||||
with patch("gateway.status.read_runtime_status", return_value=None):
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/health/detailed")
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /v1/models endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -167,6 +167,63 @@ class TestBlueBubblesWebhookParsing:
|
||||
chat_identifier = sender
|
||||
assert chat_identifier == "user@example.com"
|
||||
|
||||
def test_webhook_extracts_chat_guid_from_chats_array_dm(self, monkeypatch):
|
||||
"""BB v1.9+ webhook payloads omit top-level chatGuid; GUID is in chats[0].guid."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "MESSAGE-GUID",
|
||||
"text": "hello",
|
||||
"handle": {"address": "+15551234567"},
|
||||
"isFromMe": False,
|
||||
"chats": [
|
||||
{"guid": "any;-;+15551234567", "chatIdentifier": "+15551234567"}
|
||||
],
|
||||
},
|
||||
}
|
||||
record = adapter._extract_payload_record(payload) or {}
|
||||
chat_guid = adapter._value(
|
||||
record.get("chatGuid"),
|
||||
payload.get("chatGuid"),
|
||||
record.get("chat_guid"),
|
||||
payload.get("chat_guid"),
|
||||
payload.get("guid"),
|
||||
)
|
||||
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")
|
||||
assert chat_guid == "any;-;+15551234567"
|
||||
|
||||
def test_webhook_extracts_chat_guid_from_chats_array_group(self, monkeypatch):
|
||||
"""Group chat GUIDs contain ;+; and must be extracted from chats array."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "MESSAGE-GUID",
|
||||
"text": "hello everyone",
|
||||
"handle": {"address": "+15551234567"},
|
||||
"isFromMe": False,
|
||||
"isGroup": True,
|
||||
"chats": [{"guid": "any;+;chat-uuid-abc123"}],
|
||||
},
|
||||
}
|
||||
record = adapter._extract_payload_record(payload) or {}
|
||||
chat_guid = adapter._value(
|
||||
record.get("chatGuid"),
|
||||
payload.get("chatGuid"),
|
||||
record.get("chat_guid"),
|
||||
payload.get("chat_guid"),
|
||||
payload.get("guid"),
|
||||
)
|
||||
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")
|
||||
assert chat_guid == "any;+;chat-uuid-abc123"
|
||||
|
||||
def test_extract_payload_record_accepts_list_data(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
payload = {
|
||||
@@ -385,6 +442,28 @@ class TestBlueBubblesWebhookUrl:
|
||||
adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50")
|
||||
assert "192.168.1.50" in adapter._webhook_url
|
||||
|
||||
def test_register_url_embeds_password(self, monkeypatch):
|
||||
"""_webhook_register_url should append ?password=... for inbound auth."""
|
||||
adapter = _make_adapter(monkeypatch, password="secret123")
|
||||
assert adapter._webhook_register_url.endswith("?password=secret123")
|
||||
assert adapter._webhook_register_url.startswith(adapter._webhook_url)
|
||||
|
||||
def test_register_url_url_encodes_password(self, monkeypatch):
|
||||
"""Passwords with special characters must be URL-encoded."""
|
||||
adapter = _make_adapter(monkeypatch, password="W9fTC&L5JL*@")
|
||||
assert "password=W9fTC%26L5JL%2A%40" in adapter._webhook_register_url
|
||||
|
||||
def test_register_url_omits_query_when_no_password(self, monkeypatch):
|
||||
"""If no password is configured, the register URL should be the bare URL."""
|
||||
monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False)
|
||||
from gateway.platforms.bluebubbles import BlueBubblesAdapter
|
||||
cfg = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"server_url": "http://localhost:1234", "password": ""},
|
||||
)
|
||||
adapter = BlueBubblesAdapter(cfg)
|
||||
assert adapter._webhook_register_url == adapter._webhook_url
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookRegistration:
|
||||
"""Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks."""
|
||||
@@ -500,7 +579,7 @@ class TestBlueBubblesWebhookRegistration:
|
||||
"""Crash resilience — existing registration is reused, no POST needed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
url = adapter._webhook_register_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 7, "url": url, "events": ["new-message"]},
|
||||
@@ -548,7 +627,7 @@ class TestBlueBubblesWebhookRegistration:
|
||||
def test_unregister_removes_matching(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
url = adapter._webhook_register_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 10, "url": url},
|
||||
@@ -563,7 +642,7 @@ class TestBlueBubblesWebhookRegistration:
|
||||
"""Multiple orphaned registrations for same URL — all get removed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
url = adapter._webhook_register_url
|
||||
deleted_ids = []
|
||||
|
||||
async def mock_delete(*args, **kwargs):
|
||||
|
||||
@@ -4,9 +4,12 @@ Covers the threading behavior control for multi-chunk replies:
|
||||
- "off": Never reply-reference to original message
|
||||
- "first": Only first chunk uses reply reference (default)
|
||||
- "all": All chunks reply-reference the original message
|
||||
|
||||
Also covers reply_to_text extraction from incoming messages.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
@@ -275,3 +278,107 @@ class TestEnvVarOverride:
|
||||
_apply_env_overrides(config)
|
||||
assert Platform.DISCORD in config.platforms
|
||||
assert config.platforms[Platform.DISCORD].reply_to_mode == "off"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tests for reply_to_text extraction in _handle_message
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class FakeDMChannel:
|
||||
"""Minimal DM channel stub (skips mention / channel-allow checks)."""
|
||||
def __init__(self, channel_id: int = 100, name: str = "dm"):
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
|
||||
|
||||
def _make_message(*, content: str = "hi", reference=None):
|
||||
"""Build a mock Discord message for _handle_message tests."""
|
||||
author = SimpleNamespace(id=42, display_name="TestUser", name="TestUser")
|
||||
return SimpleNamespace(
|
||||
id=999,
|
||||
content=content,
|
||||
mentions=[],
|
||||
attachments=[],
|
||||
reference=reference,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
channel=FakeDMChannel(),
|
||||
author=author,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reply_text_adapter(monkeypatch):
|
||||
"""DiscordAdapter wired for _handle_message → handle_message capture."""
|
||||
import gateway.platforms.discord as discord_platform
|
||||
|
||||
monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False)
|
||||
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
adapter._text_batch_delay_seconds = 0
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestReplyToText:
|
||||
"""Tests for reply_to_text populated by _handle_message."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reference_both_none(self, reply_text_adapter):
|
||||
message = _make_message(reference=None)
|
||||
|
||||
await reply_text_adapter._handle_message(message)
|
||||
|
||||
event = reply_text_adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_message_id is None
|
||||
assert event.reply_to_text is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_without_resolved(self, reply_text_adapter):
|
||||
ref = SimpleNamespace(message_id=555, resolved=None)
|
||||
message = _make_message(reference=ref)
|
||||
|
||||
await reply_text_adapter._handle_message(message)
|
||||
|
||||
event = reply_text_adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_message_id == "555"
|
||||
assert event.reply_to_text is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_with_resolved_content(self, reply_text_adapter):
|
||||
resolved_msg = SimpleNamespace(content="original message text")
|
||||
ref = SimpleNamespace(message_id=555, resolved=resolved_msg)
|
||||
message = _make_message(reference=ref)
|
||||
|
||||
await reply_text_adapter._handle_message(message)
|
||||
|
||||
event = reply_text_adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_message_id == "555"
|
||||
assert event.reply_to_text == "original message text"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_with_empty_resolved_content(self, reply_text_adapter):
|
||||
"""Empty string content should become None, not leak as empty string."""
|
||||
resolved_msg = SimpleNamespace(content="")
|
||||
ref = SimpleNamespace(message_id=555, resolved=resolved_msg)
|
||||
message = _make_message(reference=ref)
|
||||
|
||||
await reply_text_adapter._handle_message(message)
|
||||
|
||||
event = reply_text_adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_message_id == "555"
|
||||
assert event.reply_to_text is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_with_deleted_message(self, reply_text_adapter):
|
||||
"""Deleted messages lack .content — getattr guard should return None."""
|
||||
resolved_deleted = SimpleNamespace(id=555)
|
||||
ref = SimpleNamespace(message_id=555, resolved=resolved_deleted)
|
||||
message = _make_message(reference=ref)
|
||||
|
||||
await reply_text_adapter._handle_message(message)
|
||||
|
||||
event = reply_text_adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_message_id == "555"
|
||||
assert event.reply_to_text is None
|
||||
|
||||
@@ -19,10 +19,34 @@ def _ensure_discord_mock():
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
|
||||
# Lightweight mock for app_commands.Group and Command used by
|
||||
# _register_skill_group.
|
||||
class _FakeGroup:
|
||||
def __init__(self, *, name, description, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parent = parent
|
||||
self._children: dict[str, object] = {}
|
||||
if parent is not None:
|
||||
parent.add_command(self)
|
||||
|
||||
def add_command(self, cmd):
|
||||
self._children[cmd.name] = cmd
|
||||
|
||||
class _FakeCommand:
|
||||
def __init__(self, *, name, description, callback, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.callback = callback
|
||||
self.parent = parent
|
||||
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
Group=_FakeGroup,
|
||||
Command=_FakeCommand,
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
@@ -51,6 +75,12 @@ class FakeTree:
|
||||
|
||||
return decorator
|
||||
|
||||
def add_command(self, cmd):
|
||||
self.commands[cmd.name] = cmd
|
||||
|
||||
def get_commands(self):
|
||||
return [SimpleNamespace(name=n) for n in self.commands]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
@@ -498,3 +528,79 @@ def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
|
||||
|
||||
import os
|
||||
assert os.getenv("DISCORD_AUTO_THREAD") == "true"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /skill group registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_register_skill_group_creates_group(adapter):
|
||||
"""_register_skill_group should register a '/skill' Group on the tree."""
|
||||
mock_categories = {
|
||||
"creative": [
|
||||
("ascii-art", "Generate ASCII art", "/ascii-art"),
|
||||
("excalidraw", "Hand-drawn diagrams", "/excalidraw"),
|
||||
],
|
||||
"media": [
|
||||
("gif-search", "Search for GIFs", "/gif-search"),
|
||||
],
|
||||
}
|
||||
mock_uncategorized = [
|
||||
("dogfood", "Exploratory QA testing", "/dogfood"),
|
||||
]
|
||||
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=(mock_categories, mock_uncategorized, 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
tree = adapter._client.tree
|
||||
assert "skill" in tree.commands, "Expected /skill group to be registered"
|
||||
skill_group = tree.commands["skill"]
|
||||
assert skill_group.name == "skill"
|
||||
# Should have 2 category subgroups + 1 uncategorized subcommand
|
||||
children = skill_group._children
|
||||
assert "creative" in children
|
||||
assert "media" in children
|
||||
assert "dogfood" in children
|
||||
# Category groups should have their skills
|
||||
assert "ascii-art" in children["creative"]._children
|
||||
assert "excalidraw" in children["creative"]._children
|
||||
assert "gif-search" in children["media"]._children
|
||||
|
||||
|
||||
def test_register_skill_group_empty_skills_no_group(adapter):
|
||||
"""No /skill group should be added when there are zero skills."""
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=({}, [], 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
tree = adapter._client.tree
|
||||
assert "skill" not in tree.commands
|
||||
|
||||
|
||||
def test_register_skill_group_handler_dispatches_command(adapter):
|
||||
"""Skill subcommand handlers should dispatch the correct /cmd-key text."""
|
||||
mock_categories = {
|
||||
"media": [
|
||||
("gif-search", "Search for GIFs", "/gif-search"),
|
||||
],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=(mock_categories, [], 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
skill_group = adapter._client.tree.commands["skill"]
|
||||
media_group = skill_group._children["media"]
|
||||
gif_cmd = media_group._children["gif-search"]
|
||||
assert gif_cmd.callback is not None
|
||||
# The callback name should reflect the skill
|
||||
assert "gif_search" in gif_cmd.callback.__name__
|
||||
|
||||
|
||||
@@ -220,41 +220,6 @@ class TestPlatformDefaults:
|
||||
assert resolve_display_setting({}, "telegram", "streaming") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_effective_display / get_platform_defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHelpers:
|
||||
"""Helper functions return correct composite results."""
|
||||
|
||||
def test_get_effective_display_merges_correctly(self):
|
||||
from gateway.display_config import get_effective_display
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "new",
|
||||
"show_reasoning": True,
|
||||
"platforms": {
|
||||
"telegram": {"tool_progress": "verbose"},
|
||||
},
|
||||
}
|
||||
}
|
||||
eff = get_effective_display(config, "telegram")
|
||||
assert eff["tool_progress"] == "verbose" # platform override
|
||||
assert eff["show_reasoning"] is True # global
|
||||
assert "tool_preview_length" in eff # default filled in
|
||||
|
||||
def test_get_platform_defaults_returns_dict(self):
|
||||
from gateway.display_config import get_platform_defaults
|
||||
|
||||
defaults = get_platform_defaults("telegram")
|
||||
assert "tool_progress" in defaults
|
||||
assert "show_reasoning" in defaults
|
||||
# Returns a new dict (not the shared tier dict)
|
||||
defaults["tool_progress"] = "changed"
|
||||
assert get_platform_defaults("telegram")["tool_progress"] != "changed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config migration: tool_progress_overrides → display.platforms
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -332,6 +297,15 @@ class TestStreamingPerPlatform:
|
||||
result = resolve_display_setting(config, "telegram", "streaming")
|
||||
assert result is None # caller should check global StreamingConfig
|
||||
|
||||
def test_global_display_streaming_is_cli_only(self):
|
||||
"""display.streaming must not act as a gateway streaming override."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for value in (True, False):
|
||||
config = {"display": {"streaming": value}}
|
||||
assert resolve_display_setting(config, "telegram", "streaming") is None
|
||||
assert resolve_display_setting(config, "discord", "streaming") is None
|
||||
|
||||
def test_explicit_false_disables(self):
|
||||
"""Explicit False disables streaming for that platform."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
@@ -334,10 +334,12 @@ class TestChannelDirectory(unittest.TestCase):
|
||||
"""Verify email in channel directory session-based discovery."""
|
||||
|
||||
def test_email_in_session_discovery(self):
|
||||
import gateway.channel_directory
|
||||
import inspect
|
||||
source = inspect.getsource(gateway.channel_directory.build_channel_directory)
|
||||
self.assertIn('"email"', source)
|
||||
from gateway.config import Platform
|
||||
# Verify email is a Platform enum member — the dynamic loop in
|
||||
# build_channel_directory iterates all Platform members, so email
|
||||
# is included automatically as long as it's in the enum.
|
||||
email_values = [p.value for p in Platform]
|
||||
self.assertIn("email", email_values)
|
||||
|
||||
|
||||
class TestGatewaySetup(unittest.TestCase):
|
||||
|
||||
@@ -631,6 +631,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
calls.append("card_action")
|
||||
return self
|
||||
|
||||
def register_p2_im_chat_member_bot_added_v1(self, _handler):
|
||||
calls.append("bot_added")
|
||||
return self
|
||||
|
||||
def register_p2_im_chat_member_bot_deleted_v1(self, _handler):
|
||||
calls.append("bot_deleted")
|
||||
return self
|
||||
|
||||
def build(self):
|
||||
calls.append("build")
|
||||
return "handler"
|
||||
@@ -654,6 +662,8 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
"reaction_created",
|
||||
"reaction_deleted",
|
||||
"card_action",
|
||||
"bot_added",
|
||||
"bot_deleted",
|
||||
"build",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Tests for Feishu interactive card approval buttons."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,14 +22,14 @@ if _repo not in sys.path:
|
||||
# ---------------------------------------------------------------------------
|
||||
def _ensure_feishu_mocks():
|
||||
"""Provide stubs for lark-oapi / aiohttp.web so the import succeeds."""
|
||||
if "lark_oapi" not in sys.modules:
|
||||
if importlib.util.find_spec("lark_oapi") is None and "lark_oapi" not in sys.modules:
|
||||
mod = MagicMock()
|
||||
for name in (
|
||||
"lark_oapi", "lark_oapi.api.im.v1",
|
||||
"lark_oapi.event", "lark_oapi.event.callback_type",
|
||||
):
|
||||
sys.modules.setdefault(name, mod)
|
||||
if "aiohttp" not in sys.modules:
|
||||
if importlib.util.find_spec("aiohttp") is None and "aiohttp" not in sys.modules:
|
||||
aio = MagicMock()
|
||||
sys.modules.setdefault("aiohttp", aio)
|
||||
sys.modules.setdefault("aiohttp.web", aio.web)
|
||||
@@ -39,6 +38,7 @@ def _ensure_feishu_mocks():
|
||||
_ensure_feishu_mocks()
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
import gateway.platforms.feishu as feishu_module
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@ def _make_card_action_data(
|
||||
)
|
||||
|
||||
|
||||
def _close_submitted_coro(coro, _loop):
|
||||
"""Close scheduled coroutines in sync-handler tests to avoid unawaited warnings."""
|
||||
coro.close()
|
||||
return SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# send_exec_approval — interactive card with buttons
|
||||
# ===========================================================================
|
||||
@@ -203,14 +209,14 @@ class TestFeishuExecApproval:
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _handle_card_action_event — approval button clicks
|
||||
# _resolve_approval — approval state pop + gateway resolution
|
||||
# ===========================================================================
|
||||
|
||||
class TestFeishuApprovalCallback:
|
||||
"""Test the approval intercept in _handle_card_action_event."""
|
||||
class TestResolveApproval:
|
||||
"""Test _resolve_approval pops state and calls resolve_gateway_approval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolves_approval_on_click(self):
|
||||
async def test_resolves_once(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[1] = {
|
||||
"session_key": "agent:main:feishu:group:oc_12345",
|
||||
@@ -218,28 +224,14 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_once", "approval_id": 1},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_user1", "user_name": "Norbert", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(1, "once", "Norbert")
|
||||
|
||||
mock_resolve.assert_called_once_with("agent:main:feishu:group:oc_12345", "once")
|
||||
mock_update.assert_called_once_with("msg_001", "Approved once", "Norbert", "once")
|
||||
|
||||
# State should be cleaned up
|
||||
assert 1 not in adapter._approval_state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deny_button(self):
|
||||
async def test_resolves_deny(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[2] = {
|
||||
"session_key": "some-session",
|
||||
@@ -247,26 +239,13 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "deny", "approval_id": 2},
|
||||
token="tok_deny",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_alice", "user_name": "Alice", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(2, "deny", "Alice")
|
||||
|
||||
mock_resolve.assert_called_once_with("some-session", "deny")
|
||||
mock_update.assert_called_once_with("msg_002", "Denied", "Alice", "deny")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_approval(self):
|
||||
async def test_resolves_session(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[3] = {
|
||||
"session_key": "sess-3",
|
||||
@@ -274,26 +253,13 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_99",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_session", "approval_id": 3},
|
||||
token="tok_ses",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_u", "user_name": "Bob", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(3, "session", "Bob")
|
||||
|
||||
mock_resolve.assert_called_once_with("sess-3", "session")
|
||||
mock_update.assert_called_once_with("msg_003", "Approved for session", "Bob", "session")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_always_approval(self):
|
||||
async def test_resolves_always(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[4] = {
|
||||
"session_key": "sess-4",
|
||||
@@ -301,42 +267,29 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_55",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_always", "approval_id": 4},
|
||||
token="tok_alw",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_u", "user_name": "Carol", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock),
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(4, "always", "Carol")
|
||||
|
||||
mock_resolve.assert_called_once_with("sess-4", "always")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_resolved_drops_silently(self):
|
||||
adapter = _make_adapter()
|
||||
# No state for approval_id 99 — already resolved
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_once", "approval_id": 99},
|
||||
token="tok_gone",
|
||||
)
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||
await adapter._handle_card_action_event(data)
|
||||
await adapter._resolve_approval(99, "once", "Nobody")
|
||||
|
||||
# Should NOT resolve — already handled
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
# ===========================================================================
|
||||
# _handle_card_action_event — non-approval card actions
|
||||
# ===========================================================================
|
||||
|
||||
class TestNonApprovalCardAction:
|
||||
"""Non-approval card actions should still route as synthetic commands."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_approval_actions_route_normally(self):
|
||||
"""Non-approval card actions should still become synthetic commands."""
|
||||
async def test_routes_as_synthetic_command(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
data = _make_card_action_data(
|
||||
@@ -351,82 +304,141 @@ class TestFeishuApprovalCallback:
|
||||
),
|
||||
patch.object(adapter, "get_chat_info", new_callable=AsyncMock, return_value={"name": "Test Chat"}),
|
||||
patch.object(adapter, "_handle_message_with_guards", new_callable=AsyncMock) as mock_handle,
|
||||
patch("tools.approval.resolve_gateway_approval") as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
|
||||
# Should NOT resolve any approval
|
||||
mock_resolve.assert_not_called()
|
||||
# Should have routed as synthetic command
|
||||
mock_handle.assert_called_once()
|
||||
event = mock_handle.call_args[0][0]
|
||||
assert "/card button" in event.text
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _update_approval_card — card replacement after resolution
|
||||
# _on_card_action_trigger — inline card response for approval actions
|
||||
# ===========================================================================
|
||||
|
||||
class TestFeishuUpdateApprovalCard:
|
||||
"""Test the card update after approval resolution."""
|
||||
class _FakeCallBackCard:
|
||||
def __init__(self):
|
||||
self.type = None
|
||||
self.data = None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_card_on_approve(self):
|
||||
|
||||
class _FakeP2Response:
|
||||
def __init__(self):
|
||||
self.card = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=False)
|
||||
def _patch_callback_card_types(monkeypatch):
|
||||
"""Provide real-ish P2CardActionTriggerResponse / CallBackCard for tests."""
|
||||
monkeypatch.setattr(feishu_module, "P2CardActionTriggerResponse", _FakeP2Response)
|
||||
monkeypatch.setattr(feishu_module, "CallBackCard", _FakeCallBackCard)
|
||||
|
||||
|
||||
class TestCardActionCallbackResponse:
|
||||
"""Test that _on_card_action_trigger returns updated card inline."""
|
||||
|
||||
def test_drops_action_when_loop_not_ready(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = None
|
||||
data = _make_card_action_data({"hermes_action": "approve_once", "approval_id": 1})
|
||||
|
||||
mock_update = AsyncMock()
|
||||
adapter._client.im.v1.message.update = MagicMock()
|
||||
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"msg_001", "Approved once", "Norbert", "once"
|
||||
)
|
||||
assert response is not None
|
||||
assert response.card is None
|
||||
mock_submit.assert_not_called()
|
||||
|
||||
mock_thread.assert_called_once()
|
||||
# Verify the update request was built
|
||||
call_args = mock_thread.call_args
|
||||
assert call_args[0][0] == adapter._client.im.v1.message.update
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_card_on_deny(self):
|
||||
def test_returns_card_for_approve_action(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 1},
|
||||
open_id="ou_bob",
|
||||
)
|
||||
adapter._sender_name_cache["ou_bob"] = ("Bob", 9999999999)
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"msg_002", "Denied", "Alice", "deny"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
mock_thread.assert_called_once()
|
||||
assert response is not None
|
||||
assert response.card is not None
|
||||
assert response.card.type == "raw"
|
||||
card = response.card.data
|
||||
assert card["header"]["template"] == "green"
|
||||
assert "Approved once" in card["header"]["title"]["content"]
|
||||
assert "Bob" in card["elements"][0]["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_update_when_not_connected(self):
|
||||
def test_returns_card_for_deny_action(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._client = None
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "deny", "approval_id": 2},
|
||||
)
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"msg_001", "Approved", "Bob", "once"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
mock_thread.assert_not_called()
|
||||
assert response.card is not None
|
||||
card = response.card.data
|
||||
assert card["header"]["template"] == "red"
|
||||
assert "Denied" in card["header"]["title"]["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_update_when_no_message_id(self):
|
||||
def test_ignores_missing_approval_id(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data({"hermes_action": "approve_once"})
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"", "Approved", "Bob", "once"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
mock_thread.assert_not_called()
|
||||
assert response is not None
|
||||
assert response.card is None
|
||||
mock_submit.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swallows_update_errors(self):
|
||||
def test_no_card_for_non_approval_action(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data({"some_other": "value"})
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock, side_effect=Exception("API error")):
|
||||
# Should not raise
|
||||
await adapter._update_approval_card(
|
||||
"msg_001", "Approved", "Bob", "once"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
assert response is not None
|
||||
assert response.card is None
|
||||
|
||||
def test_falls_back_to_open_id_when_name_not_cached(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_session", "approval_id": 3},
|
||||
open_id="ou_unknown",
|
||||
)
|
||||
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
card = response.card.data
|
||||
assert "ou_unknown" in card["elements"][0]["content"]
|
||||
|
||||
def test_ignores_expired_cached_name(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 4},
|
||||
open_id="ou_expired",
|
||||
)
|
||||
adapter._sender_name_cache["ou_expired"] = ("Old Name", 1)
|
||||
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
card = response.card.data
|
||||
assert "Old Name" not in card["elements"][0]["content"]
|
||||
assert "ou_expired" in card["elements"][0]["content"]
|
||||
|
||||
@@ -125,6 +125,25 @@ async def test_gateway_stop_service_restart_sets_named_exit_code():
|
||||
assert runner._exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_stop_emits_shutdown_hook_after_drain(monkeypatch):
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.disconnect = AsyncMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop(restart=True, service_restart=True)
|
||||
|
||||
runner.hooks.emit.assert_awaited_once_with(
|
||||
"gateway:shutdown",
|
||||
{
|
||||
"restart": True,
|
||||
"service_restart": True,
|
||||
"detached_restart": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_active_agents_throttles_status_updates():
|
||||
runner, _adapter = make_restart_runner()
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from gateway.hooks import HookRegistry
|
||||
|
||||
|
||||
def _create_hook(hooks_dir, hook_name, events, handler_code):
|
||||
def _create_hook(hooks_dir, hook_name, events, handler_code, *, manifest_extra=""):
|
||||
"""Helper to create a hook directory with HOOK.yaml and handler.py."""
|
||||
hook_dir = hooks_dir / hook_name
|
||||
hook_dir.mkdir(parents=True)
|
||||
@@ -17,6 +17,7 @@ def _create_hook(hooks_dir, hook_name, events, handler_code):
|
||||
f"name: {hook_name}\n"
|
||||
f"description: Test hook\n"
|
||||
f"events: {events}\n"
|
||||
f"{manifest_extra}"
|
||||
)
|
||||
(hook_dir / "handler.py").write_text(handler_code)
|
||||
return hook_dir
|
||||
@@ -112,6 +113,24 @@ class TestDiscoverAndLoad:
|
||||
|
||||
assert len(reg.loaded_hooks) == 2
|
||||
|
||||
def test_preserves_optional_startup_readiness_metadata(self, tmp_path):
|
||||
_create_hook(
|
||||
tmp_path,
|
||||
"ready-hook",
|
||||
'["gateway:startup"]',
|
||||
"def handle(e, c): pass\n",
|
||||
manifest_extra="startup_readiness:\n id: beam-runtime\n required: false\n",
|
||||
)
|
||||
|
||||
reg = HookRegistry()
|
||||
with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
|
||||
reg.discover_and_load()
|
||||
|
||||
assert reg.loaded_hooks[0]["startup_readiness"] == {
|
||||
"id": "beam-runtime",
|
||||
"required": False,
|
||||
}
|
||||
|
||||
|
||||
class TestEmit:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Tests for gateway proxy mode — forwarding messages to a remote API server."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, StreamingConfig
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_runner(proxy_url=None):
|
||||
"""Create a minimal GatewayRunner for proxy tests."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner.config = MagicMock()
|
||||
runner.config.streaming = StreamingConfig()
|
||||
runner._running_agents = {}
|
||||
runner._session_model_overrides = {}
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = None
|
||||
return runner
|
||||
|
||||
|
||||
def _make_source(platform=Platform.MATRIX):
|
||||
return SessionSource(
|
||||
platform=platform,
|
||||
chat_id="!room:server.org",
|
||||
chat_name="Test Room",
|
||||
chat_type="group",
|
||||
user_id="@user:server.org",
|
||||
user_name="testuser",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
|
||||
class _FakeSSEResponse:
|
||||
"""Simulates an aiohttp response with SSE streaming."""
|
||||
|
||||
def __init__(self, status=200, sse_chunks=None, error_text=""):
|
||||
self.status = status
|
||||
self._sse_chunks = sse_chunks or []
|
||||
self._error_text = error_text
|
||||
self.content = self
|
||||
|
||||
async def text(self):
|
||||
return self._error_text
|
||||
|
||||
async def iter_any(self):
|
||||
for chunk in self._sse_chunks:
|
||||
if isinstance(chunk, str):
|
||||
chunk = chunk.encode("utf-8")
|
||||
yield chunk
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Simulates an aiohttp.ClientSession with captured request args."""
|
||||
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
self.captured_url = None
|
||||
self.captured_json = None
|
||||
self.captured_headers = None
|
||||
|
||||
def post(self, url, json=None, headers=None, **kwargs):
|
||||
self.captured_url = url
|
||||
self.captured_json = json
|
||||
self.captured_headers = headers
|
||||
return self._response
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
def _patch_aiohttp(session):
|
||||
"""Patch aiohttp.ClientSession to return our fake session."""
|
||||
return patch(
|
||||
"aiohttp.ClientSession",
|
||||
return_value=session,
|
||||
)
|
||||
|
||||
|
||||
class TestGetProxyUrl:
|
||||
"""Test _get_proxy_url() config resolution."""
|
||||
|
||||
def test_returns_none_when_not_configured(self, monkeypatch):
|
||||
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
|
||||
runner = _make_runner()
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
assert runner._get_proxy_url() is None
|
||||
|
||||
def test_reads_from_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://192.168.1.100:8642")
|
||||
runner = _make_runner()
|
||||
assert runner._get_proxy_url() == "http://192.168.1.100:8642"
|
||||
|
||||
def test_strips_trailing_slash(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642/")
|
||||
runner = _make_runner()
|
||||
assert runner._get_proxy_url() == "http://host:8642"
|
||||
|
||||
def test_reads_from_config_yaml(self, monkeypatch):
|
||||
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
|
||||
runner = _make_runner()
|
||||
cfg = {"gateway": {"proxy_url": "http://10.0.0.1:8642"}}
|
||||
with patch("gateway.run._load_gateway_config", return_value=cfg):
|
||||
assert runner._get_proxy_url() == "http://10.0.0.1:8642"
|
||||
|
||||
def test_env_var_overrides_config(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://env-host:8642")
|
||||
runner = _make_runner()
|
||||
cfg = {"gateway": {"proxy_url": "http://config-host:8642"}}
|
||||
with patch("gateway.run._load_gateway_config", return_value=cfg):
|
||||
assert runner._get_proxy_url() == "http://env-host:8642"
|
||||
|
||||
def test_empty_string_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", " ")
|
||||
runner = _make_runner()
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
assert runner._get_proxy_url() is None
|
||||
|
||||
|
||||
class TestRunAgentProxyDispatch:
|
||||
"""Test that _run_agent() delegates to proxy when configured."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_delegates_to_proxy(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
expected_result = {
|
||||
"final_response": "Hello from remote!",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "Hello from remote!"},
|
||||
],
|
||||
"api_calls": 1,
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
runner._run_agent_via_proxy = AsyncMock(return_value=expected_result)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="test-session-123",
|
||||
session_key="test-key",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "Hello from remote!"
|
||||
runner._run_agent_via_proxy.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_skips_proxy_when_not_configured(self, monkeypatch):
|
||||
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
|
||||
runner = _make_runner()
|
||||
|
||||
runner._run_agent_via_proxy = AsyncMock()
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
try:
|
||||
await runner._run_agent(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=_make_source(),
|
||||
session_id="test-session",
|
||||
)
|
||||
except Exception:
|
||||
pass # Expected — bare runner can't create a real agent
|
||||
|
||||
runner._run_agent_via_proxy.assert_not_called()
|
||||
|
||||
|
||||
class TestRunAgentViaProxy:
|
||||
"""Test the actual proxy HTTP forwarding logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builds_correct_request(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
||||
monkeypatch.setenv("GATEWAY_PROXY_KEY", "test-key-123")
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
resp = _FakeSSEResponse(
|
||||
status=200,
|
||||
sse_chunks=[
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
],
|
||||
)
|
||||
session = _FakeSession(resp)
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
with _patch_aiohttp(session):
|
||||
with patch("aiohttp.ClientTimeout"):
|
||||
result = await runner._run_agent_via_proxy(
|
||||
message="How are you?",
|
||||
context_prompt="You are helpful.",
|
||||
history=[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
],
|
||||
source=source,
|
||||
session_id="session-abc",
|
||||
)
|
||||
|
||||
# Verify request URL
|
||||
assert session.captured_url == "http://host:8642/v1/chat/completions"
|
||||
|
||||
# Verify auth header
|
||||
assert session.captured_headers["Authorization"] == "Bearer test-key-123"
|
||||
|
||||
# Verify session ID header
|
||||
assert session.captured_headers["X-Hermes-Session-Id"] == "session-abc"
|
||||
|
||||
# Verify messages include system, history, and current message
|
||||
messages = session.captured_json["messages"]
|
||||
assert messages[0] == {"role": "system", "content": "You are helpful."}
|
||||
assert messages[1] == {"role": "user", "content": "Hello"}
|
||||
assert messages[2] == {"role": "assistant", "content": "Hi there!"}
|
||||
assert messages[3] == {"role": "user", "content": "How are you?"}
|
||||
|
||||
# Verify streaming is requested
|
||||
assert session.captured_json["stream"] is True
|
||||
|
||||
# Verify response was assembled
|
||||
assert result["final_response"] == "Hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_http_error(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
||||
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
resp = _FakeSSEResponse(status=401, error_text="Unauthorized: invalid API key")
|
||||
session = _FakeSession(resp)
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
with _patch_aiohttp(session):
|
||||
with patch("aiohttp.ClientTimeout"):
|
||||
result = await runner._run_agent_via_proxy(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="test",
|
||||
)
|
||||
|
||||
assert "Proxy error (401)" in result["final_response"]
|
||||
assert result["api_calls"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_connection_error(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://unreachable:8642")
|
||||
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
class _ErrorSession:
|
||||
def post(self, *args, **kwargs):
|
||||
raise ConnectionError("Connection refused")
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
with patch("aiohttp.ClientSession", return_value=_ErrorSession()):
|
||||
with patch("aiohttp.ClientTimeout"):
|
||||
result = await runner._run_agent_via_proxy(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="test",
|
||||
)
|
||||
|
||||
assert "Proxy connection error" in result["final_response"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_tool_messages_in_history(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
||||
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
resp = _FakeSSEResponse(
|
||||
status=200,
|
||||
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
|
||||
)
|
||||
session = _FakeSession(resp)
|
||||
|
||||
history = [
|
||||
{"role": "user", "content": "search for X"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "tc1"}]},
|
||||
{"role": "tool", "content": "search results...", "tool_call_id": "tc1"},
|
||||
{"role": "assistant", "content": "Found results."},
|
||||
]
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
with _patch_aiohttp(session):
|
||||
with patch("aiohttp.ClientTimeout"):
|
||||
await runner._run_agent_via_proxy(
|
||||
message="tell me more",
|
||||
context_prompt="",
|
||||
history=history,
|
||||
source=source,
|
||||
session_id="test",
|
||||
)
|
||||
|
||||
# Only user and assistant with content should be forwarded
|
||||
messages = session.captured_json["messages"]
|
||||
roles = [m["role"] for m in messages]
|
||||
assert "tool" not in roles
|
||||
# assistant with None content should be skipped
|
||||
assert all(m.get("content") for m in messages)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_result_shape_matches_run_agent(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
||||
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
resp = _FakeSSEResponse(
|
||||
status=200,
|
||||
sse_chunks=[b'data: {"choices":[{"delta":{"content":"answer"}}]}\n\ndata: [DONE]\n\n'],
|
||||
)
|
||||
session = _FakeSession(resp)
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
with _patch_aiohttp(session):
|
||||
with patch("aiohttp.ClientTimeout"):
|
||||
result = await runner._run_agent_via_proxy(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[{"role": "user", "content": "prev"}, {"role": "assistant", "content": "ok"}],
|
||||
source=source,
|
||||
session_id="sess-123",
|
||||
)
|
||||
|
||||
# Required keys that callers depend on
|
||||
assert "final_response" in result
|
||||
assert result["final_response"] == "answer"
|
||||
assert "messages" in result
|
||||
assert "api_calls" in result
|
||||
assert "tools" in result
|
||||
assert "history_offset" in result
|
||||
assert result["history_offset"] == 2 # len(history)
|
||||
assert "session_id" in result
|
||||
assert result["session_id"] == "sess-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_auth_header_without_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
||||
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
resp = _FakeSSEResponse(
|
||||
status=200,
|
||||
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
|
||||
)
|
||||
session = _FakeSession(resp)
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
with _patch_aiohttp(session):
|
||||
with patch("aiohttp.ClientTimeout"):
|
||||
await runner._run_agent_via_proxy(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="test",
|
||||
)
|
||||
|
||||
assert "Authorization" not in session.captured_headers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_system_message_when_context_empty(self, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
|
||||
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
|
||||
runner = _make_runner()
|
||||
source = _make_source()
|
||||
|
||||
resp = _FakeSSEResponse(
|
||||
status=200,
|
||||
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
|
||||
)
|
||||
session = _FakeSession(resp)
|
||||
|
||||
with patch("gateway.run._load_gateway_config", return_value={}):
|
||||
with _patch_aiohttp(session):
|
||||
with patch("aiohttp.ClientTimeout"):
|
||||
await runner._run_agent_via_proxy(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="test",
|
||||
)
|
||||
|
||||
# No system message should appear when context_prompt is empty
|
||||
messages = session.captured_json["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "hello"
|
||||
|
||||
|
||||
class TestEnvVarRegistration:
|
||||
"""Verify GATEWAY_PROXY_URL and GATEWAY_PROXY_KEY are registered."""
|
||||
|
||||
def test_proxy_url_in_optional_env_vars(self):
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS
|
||||
assert "GATEWAY_PROXY_URL" in OPTIONAL_ENV_VARS
|
||||
info = OPTIONAL_ENV_VARS["GATEWAY_PROXY_URL"]
|
||||
assert info["category"] == "messaging"
|
||||
assert info["password"] is False
|
||||
|
||||
def test_proxy_key_in_optional_env_vars(self):
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS
|
||||
assert "GATEWAY_PROXY_KEY" in OPTIONAL_ENV_VARS
|
||||
info = OPTIONAL_ENV_VARS["GATEWAY_PROXY_KEY"]
|
||||
assert info["category"] == "messaging"
|
||||
assert info["password"] is True
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Tests for the QQ Bot platform adapter."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_config(**extra):
|
||||
"""Build a PlatformConfig(enabled=True, extra=extra) for testing."""
|
||||
return PlatformConfig(enabled=True, extra=extra)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_qq_requirements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQRequirements:
|
||||
def test_returns_bool(self):
|
||||
from gateway.platforms.qqbot import check_qq_requirements
|
||||
result = check_qq_requirements()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QQAdapter.__init__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQAdapterInit:
|
||||
def _make(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_basic_attributes(self):
|
||||
adapter = self._make(app_id="123", client_secret="sec")
|
||||
assert adapter._app_id == "123"
|
||||
assert adapter._client_secret == "sec"
|
||||
|
||||
def test_env_fallback(self):
|
||||
with mock.patch.dict(os.environ, {"QQ_APP_ID": "env_id", "QQ_CLIENT_SECRET": "env_sec"}, clear=False):
|
||||
adapter = self._make()
|
||||
assert adapter._app_id == "env_id"
|
||||
assert adapter._client_secret == "env_sec"
|
||||
|
||||
def test_env_fallback_extra_wins(self):
|
||||
with mock.patch.dict(os.environ, {"QQ_APP_ID": "env_id"}, clear=False):
|
||||
adapter = self._make(app_id="extra_id", client_secret="sec")
|
||||
assert adapter._app_id == "extra_id"
|
||||
|
||||
def test_dm_policy_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._dm_policy == "open"
|
||||
|
||||
def test_dm_policy_explicit(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", dm_policy="allowlist")
|
||||
assert adapter._dm_policy == "allowlist"
|
||||
|
||||
def test_group_policy_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._group_policy == "open"
|
||||
|
||||
def test_allow_from_parsing_string(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", allow_from="x, y , z")
|
||||
assert adapter._allow_from == ["x", "y", "z"]
|
||||
|
||||
def test_allow_from_parsing_list(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", allow_from=["a", "b"])
|
||||
assert adapter._allow_from == ["a", "b"]
|
||||
|
||||
def test_allow_from_default_empty(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._allow_from == []
|
||||
|
||||
def test_group_allow_from(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", group_allow_from="g1,g2")
|
||||
assert adapter._group_allow_from == ["g1", "g2"]
|
||||
|
||||
def test_markdown_support_default(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter._markdown_support is True
|
||||
|
||||
def test_markdown_support_false(self):
|
||||
adapter = self._make(app_id="a", client_secret="b", markdown_support=False)
|
||||
assert adapter._markdown_support is False
|
||||
|
||||
def test_name_property(self):
|
||||
adapter = self._make(app_id="a", client_secret="b")
|
||||
assert adapter.name == "QQBot"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _coerce_list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCoerceList:
|
||||
def _fn(self, value):
|
||||
from gateway.platforms.qqbot import _coerce_list
|
||||
return _coerce_list(value)
|
||||
|
||||
def test_none(self):
|
||||
assert self._fn(None) == []
|
||||
|
||||
def test_string(self):
|
||||
assert self._fn("a, b ,c") == ["a", "b", "c"]
|
||||
|
||||
def test_list(self):
|
||||
assert self._fn(["x", "y"]) == ["x", "y"]
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self._fn("") == []
|
||||
|
||||
def test_tuple(self):
|
||||
assert self._fn(("a", "b")) == ["a", "b"]
|
||||
|
||||
def test_single_item_string(self):
|
||||
assert self._fn("hello") == ["hello"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_voice_content_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsVoiceContentType:
|
||||
def _fn(self, content_type, filename):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._is_voice_content_type(content_type, filename)
|
||||
|
||||
def test_voice_content_type(self):
|
||||
assert self._fn("voice", "msg.silk") is True
|
||||
|
||||
def test_audio_content_type(self):
|
||||
assert self._fn("audio/mp3", "file.mp3") is True
|
||||
|
||||
def test_voice_extension(self):
|
||||
assert self._fn("", "file.silk") is True
|
||||
|
||||
def test_non_voice(self):
|
||||
assert self._fn("image/jpeg", "photo.jpg") is False
|
||||
|
||||
def test_audio_extension_amr(self):
|
||||
assert self._fn("", "recording.amr") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_at_mention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStripAtMention:
|
||||
def _fn(self, content):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._strip_at_mention(content)
|
||||
|
||||
def test_removes_mention(self):
|
||||
result = self._fn("@BotUser hello there")
|
||||
assert result == "hello there"
|
||||
|
||||
def test_no_mention(self):
|
||||
result = self._fn("just text")
|
||||
assert result == "just text"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self._fn("") == ""
|
||||
|
||||
def test_only_mention(self):
|
||||
assert self._fn("@Someone ") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_dm_allowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDmAllowed:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_open_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="open")
|
||||
assert adapter._is_dm_allowed("any_user") is True
|
||||
|
||||
def test_disabled_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="disabled")
|
||||
assert adapter._is_dm_allowed("any_user") is False
|
||||
|
||||
def test_allowlist_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="user1,user2")
|
||||
assert adapter._is_dm_allowed("user1") is True
|
||||
|
||||
def test_allowlist_no_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="user1,user2")
|
||||
assert adapter._is_dm_allowed("user3") is False
|
||||
|
||||
def test_allowlist_wildcard(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="*")
|
||||
assert adapter._is_dm_allowed("anyone") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_group_allowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGroupAllowed:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_open_policy(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="open")
|
||||
assert adapter._is_group_allowed("grp1", "user1") is True
|
||||
|
||||
def test_allowlist_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="allowlist", group_allow_from="grp1")
|
||||
assert adapter._is_group_allowed("grp1", "user1") is True
|
||||
|
||||
def test_allowlist_no_match(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="allowlist", group_allow_from="grp1")
|
||||
assert adapter._is_group_allowed("grp2", "user1") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_stt_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveSTTConfig:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_no_config(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
assert adapter._resolve_stt_config() is None
|
||||
|
||||
def test_env_config(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
with mock.patch.dict(os.environ, {
|
||||
"QQ_STT_API_KEY": "key123",
|
||||
"QQ_STT_BASE_URL": "https://example.com/v1",
|
||||
"QQ_STT_MODEL": "my-model",
|
||||
}, clear=True):
|
||||
cfg = adapter._resolve_stt_config()
|
||||
assert cfg is not None
|
||||
assert cfg["api_key"] == "key123"
|
||||
assert cfg["base_url"] == "https://example.com/v1"
|
||||
assert cfg["model"] == "my-model"
|
||||
|
||||
def test_extra_config(self):
|
||||
stt_cfg = {
|
||||
"baseUrl": "https://custom.api/v4",
|
||||
"apiKey": "sk_extra",
|
||||
"model": "glm-asr",
|
||||
}
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", stt=stt_cfg)
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
cfg = adapter._resolve_stt_config()
|
||||
assert cfg is not None
|
||||
assert cfg["base_url"] == "https://custom.api/v4"
|
||||
assert cfg["api_key"] == "sk_extra"
|
||||
assert cfg["model"] == "glm-asr"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_message_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetectMessageType:
|
||||
def _fn(self, media_urls, media_types):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._detect_message_type(media_urls, media_types)
|
||||
|
||||
def test_no_media(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn([], []) == MessageType.TEXT
|
||||
|
||||
def test_image(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["file.jpg"], ["image/jpeg"]) == MessageType.PHOTO
|
||||
|
||||
def test_voice(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["voice.silk"], ["audio/silk"]) == MessageType.VOICE
|
||||
|
||||
def test_video(self):
|
||||
from gateway.platforms.base import MessageType
|
||||
assert self._fn(["vid.mp4"], ["video/mp4"]) == MessageType.VIDEO
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QQCloseError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQQCloseError:
|
||||
def test_attributes(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError(4004, "bad token")
|
||||
assert err.code == 4004
|
||||
assert err.reason == "bad token"
|
||||
|
||||
def test_code_none(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError(None, "")
|
||||
assert err.code is None
|
||||
|
||||
def test_string_to_int(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError("4914", "banned")
|
||||
assert err.code == 4914
|
||||
assert err.reason == "banned"
|
||||
|
||||
def test_message_format(self):
|
||||
from gateway.platforms.qqbot import QQCloseError
|
||||
err = QQCloseError(4008, "rate limit")
|
||||
assert "4008" in str(err)
|
||||
assert "rate limit" in str(err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _dispatch_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDispatchPayload:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
adapter = QQAdapter(_make_config(**extra))
|
||||
return adapter
|
||||
|
||||
def test_unknown_op(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
# Should not raise
|
||||
adapter._dispatch_payload({"op": 99, "d": {}})
|
||||
# last_seq should remain None
|
||||
assert adapter._last_seq is None
|
||||
|
||||
def test_op10_updates_heartbeat_interval(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 10, "d": {"heartbeat_interval": 50000}})
|
||||
# Should be 50000 / 1000 * 0.8 = 40.0
|
||||
assert adapter._heartbeat_interval == 40.0
|
||||
|
||||
def test_op11_heartbeat_ack(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
# Should not raise
|
||||
adapter._dispatch_payload({"op": 11, "t": "HEARTBEAT_ACK", "s": 42})
|
||||
|
||||
def test_seq_tracking(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 0, "t": "READY", "s": 100, "d": {}})
|
||||
assert adapter._last_seq == 100
|
||||
|
||||
def test_seq_increments(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({"op": 0, "t": "READY", "s": 5, "d": {}})
|
||||
adapter._dispatch_payload({"op": 0, "t": "SOME_EVENT", "s": 10, "d": {}})
|
||||
assert adapter._last_seq == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# READY / RESUMED handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadyHandling:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_ready_stores_session(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._dispatch_payload({
|
||||
"op": 0, "t": "READY",
|
||||
"s": 1,
|
||||
"d": {"session_id": "sess_abc123"},
|
||||
})
|
||||
assert adapter._session_id == "sess_abc123"
|
||||
|
||||
def test_resumed_preserves_session(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b")
|
||||
adapter._session_id = "old_sess"
|
||||
adapter._last_seq = 50
|
||||
adapter._dispatch_payload({
|
||||
"op": 0, "t": "RESUMED", "s": 60, "d": {},
|
||||
})
|
||||
# Session should remain unchanged on RESUMED
|
||||
assert adapter._session_id == "old_sess"
|
||||
assert adapter._last_seq == 60
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseJson:
|
||||
def _fn(self, raw):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter._parse_json(raw)
|
||||
|
||||
def test_valid_json(self):
|
||||
result = self._fn('{"op": 10, "d": {}}')
|
||||
assert result == {"op": 10, "d": {}}
|
||||
|
||||
def test_invalid_json(self):
|
||||
result = self._fn("not json")
|
||||
assert result is None
|
||||
|
||||
def test_none_input(self):
|
||||
result = self._fn(None)
|
||||
assert result is None
|
||||
|
||||
def test_non_dict_json(self):
|
||||
result = self._fn('"just a string"')
|
||||
assert result is None
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = self._fn('{}')
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_text_body
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildTextBody:
|
||||
def _make_adapter(self, **extra):
|
||||
from gateway.platforms.qqbot import QQAdapter
|
||||
return QQAdapter(_make_config(**extra))
|
||||
|
||||
def test_plain_text(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("hello world")
|
||||
assert body["msg_type"] == 0 # MSG_TYPE_TEXT
|
||||
assert body["content"] == "hello world"
|
||||
|
||||
def test_markdown_text(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=True)
|
||||
body = adapter._build_text_body("**bold** text")
|
||||
assert body["msg_type"] == 2 # MSG_TYPE_MARKDOWN
|
||||
assert body["markdown"]["content"] == "**bold** text"
|
||||
|
||||
def test_truncation(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
long_text = "x" * 10000
|
||||
body = adapter._build_text_body(long_text)
|
||||
assert len(body["content"]) == adapter.MAX_MESSAGE_LENGTH
|
||||
|
||||
def test_empty_string(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("")
|
||||
assert body["content"] == ""
|
||||
|
||||
def test_reply_to(self):
|
||||
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
|
||||
body = adapter._build_text_body("reply text", reply_to="msg_123")
|
||||
assert body.get("message_reference", {}).get("message_id") == "msg_123"
|
||||
@@ -13,7 +13,10 @@ from tests.gateway.restart_test_helpers import make_restart_runner, make_restart
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_while_busy_requests_drain_without_interrupt():
|
||||
async def test_restart_command_while_busy_requests_drain_without_interrupt(monkeypatch):
|
||||
# Ensure INVOCATION_ID is NOT set — systemd sets this in service mode,
|
||||
# which changes the restart call signature.
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
event = MessageEvent(
|
||||
@@ -158,3 +161,84 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
||||
assert kwargs["start_new_session"] is True
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
|
||||
|
||||
# ── Shutdown notification tests ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_notification_sent_to_active_sessions():
|
||||
"""Active sessions receive a notification when the gateway starts shutting down."""
|
||||
runner, adapter = make_restart_runner()
|
||||
source = make_restart_source(chat_id="999", chat_type="dm")
|
||||
session_key = f"agent:main:telegram:dm:999"
|
||||
runner._running_agents[session_key] = MagicMock()
|
||||
|
||||
await runner._notify_active_sessions_of_shutdown()
|
||||
|
||||
assert len(adapter.sent) == 1
|
||||
assert "shutting down" in adapter.sent[0]
|
||||
assert "interrupted" in adapter.sent[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_notification_says_restarting_when_restart_requested():
|
||||
"""When _restart_requested is True, the message says 'restarting' and mentions /retry."""
|
||||
runner, adapter = make_restart_runner()
|
||||
runner._restart_requested = True
|
||||
session_key = "agent:main:telegram:dm:999"
|
||||
runner._running_agents[session_key] = MagicMock()
|
||||
|
||||
await runner._notify_active_sessions_of_shutdown()
|
||||
|
||||
assert len(adapter.sent) == 1
|
||||
assert "restarting" in adapter.sent[0]
|
||||
assert "/retry" in adapter.sent[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_notification_deduplicates_per_chat():
|
||||
"""Multiple sessions in the same chat only get one notification."""
|
||||
runner, adapter = make_restart_runner()
|
||||
# Two sessions (different users) in the same chat
|
||||
runner._running_agents["agent:main:telegram:group:chat1:u1"] = MagicMock()
|
||||
runner._running_agents["agent:main:telegram:group:chat1:u2"] = MagicMock()
|
||||
|
||||
await runner._notify_active_sessions_of_shutdown()
|
||||
|
||||
assert len(adapter.sent) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_notification_skipped_when_no_active_agents():
|
||||
"""No notification is sent when there are no active agents."""
|
||||
runner, adapter = make_restart_runner()
|
||||
|
||||
await runner._notify_active_sessions_of_shutdown()
|
||||
|
||||
assert len(adapter.sent) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_notification_ignores_pending_sentinels():
|
||||
"""Pending sentinels (not-yet-started agents) don't trigger notifications."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
|
||||
runner, adapter = make_restart_runner()
|
||||
runner._running_agents["agent:main:telegram:dm:999"] = _AGENT_PENDING_SENTINEL
|
||||
|
||||
await runner._notify_active_sessions_of_shutdown()
|
||||
|
||||
assert len(adapter.sent) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_notification_send_failure_does_not_block():
|
||||
"""If sending a notification fails, the method still completes."""
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.send = AsyncMock(side_effect=Exception("network error"))
|
||||
session_key = "agent:main:telegram:dm:999"
|
||||
runner._running_agents[session_key] = MagicMock()
|
||||
|
||||
# Should not raise
|
||||
await runner._notify_active_sessions_of_shutdown()
|
||||
|
||||
@@ -572,6 +572,27 @@ async def test_run_agent_streaming_does_not_enable_completed_interim_commentary(
|
||||
assert not any(call["content"] == "I'll inspect the repo first." for call in adapter.sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_display_streaming_does_not_enable_gateway_streaming(monkeypatch, tmp_path):
|
||||
adapter, result = await _run_with_agent(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
CommentaryAgent,
|
||||
session_id="sess-display-streaming-cli-only",
|
||||
config_data={
|
||||
"display": {
|
||||
"streaming": True,
|
||||
"interim_assistant_messages": True,
|
||||
},
|
||||
"streaming": {"enabled": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert result.get("already_sent") is not True
|
||||
assert adapter.edits == []
|
||||
assert [call["content"] for call in adapter.sent] == ["I'll inspect the repo first."]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_interim_commentary_works_with_tool_progress_off(monkeypatch, tmp_path):
|
||||
adapter, result = await _run_with_agent(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user