Compare commits
135
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48a7e137b1 | ||
|
|
11d93096b3 | ||
|
|
d464d08a5f | ||
|
|
5a95fb2e14 | ||
|
|
a08725e52a | ||
|
|
ea5a6c216b | ||
|
|
4df62d239e | ||
|
|
490b3e76b1 | ||
|
|
321ce94e25 | ||
|
|
c5e496e1c0 | ||
|
|
7a3c38d0b7 | ||
|
|
5cbc3fbdcc | ||
|
|
f30db14ced | ||
|
|
3a9bc9d88a | ||
|
|
5f66c36470 | ||
|
|
7a8589e782 | ||
|
|
7050c052e3 | ||
|
|
b1d3ead7fb | ||
|
|
fe5c8ec4ad | ||
|
|
0c859a1c04 | ||
|
|
1a74795735 | ||
|
|
e8b9369a9d | ||
|
|
0554ef1aa3 | ||
|
|
a82c88bac0 | ||
|
|
c0d04694ea | ||
|
|
67011cc0d7 | ||
|
|
e0572a6def | ||
|
|
5e1f793430 | ||
|
|
b243afb68b | ||
|
|
68ddd6b338 | ||
|
|
eafe11d456 | ||
|
|
a1eaad2fc0 | ||
|
|
6f9182cb34 | ||
|
|
432a691758 | ||
|
|
78be458608 | ||
|
|
e9f3f2b34a | ||
|
|
10ee4a729b | ||
|
|
f8896dedc8 | ||
|
|
b5495db701 | ||
|
|
9179396cb7 | ||
|
|
a91b1c8b31 | ||
|
|
247b24b49f | ||
|
|
031983bbf8 | ||
|
|
8b6beaab5f | ||
|
|
c7f7783e5c | ||
|
|
4ed482549f | ||
|
|
aa3466063b | ||
|
|
bb0ac5ced2 | ||
|
|
70abae8e3b | ||
|
|
538f0fa339 | ||
|
|
9b5dae17a5 | ||
|
|
fb9f3a4ef9 | ||
|
|
09a5cd8084 | ||
|
|
43abc51f66 | ||
|
|
986abb3cf7 | ||
|
|
87e5b2fae0 | ||
|
|
8595281f3c | ||
|
|
1a9ef83147 | ||
|
|
442a9203c0 | ||
|
|
459d7694d3 | ||
|
|
dc52b82d53 | ||
|
|
1cf5e639b3 | ||
|
|
406901b27d | ||
|
|
0bf9b867cf | ||
|
|
7b778db472 | ||
|
|
3ba8962738 | ||
|
|
90b6b3d18f | ||
|
|
3ad46933d3 | ||
|
|
4e702fe2d9 | ||
|
|
875d930ac7 | ||
|
|
b924b22a9d | ||
|
|
4a6f1863ac | ||
|
|
66489f38c7 | ||
|
|
ebe04c66cd | ||
|
|
6d947e4d78 | ||
|
|
10f13c3881 | ||
|
|
c9410b3462 | ||
|
|
c341a2d107 | ||
|
|
71b4a6b18e | ||
|
|
aeb992d343 | ||
|
|
b345323195 | ||
|
|
912e6e2274 | ||
|
|
0927fb5584 | ||
|
|
36c99af37a | ||
|
|
2d5dcfabc3 | ||
|
|
dc98314fbd | ||
|
|
ffdc937c18 | ||
|
|
99c19eb2fe | ||
|
|
c002668ff0 | ||
|
|
e83252dc46 | ||
|
|
5c49cd0ed0 | ||
|
|
6416dd5187 | ||
|
|
963d22cde6 | ||
|
|
4efb40c325 | ||
|
|
0537e2600d | ||
|
|
ee80dfdea0 | ||
|
|
f040710d04 | ||
|
|
a38e283395 | ||
|
|
53bdef5775 | ||
|
|
6f2a2f157f | ||
|
|
e8955f222c | ||
|
|
5deb384b53 | ||
|
|
c94ad89818 | ||
|
|
fc47b7285c | ||
|
|
8386f84454 | ||
|
|
dc9d677d59 | ||
|
|
3476509f97 | ||
|
|
283bb810e7 | ||
|
|
486d632cc2 | ||
|
|
9919caff46 | ||
|
|
eccbbe4b1b | ||
|
|
c89393b711 | ||
|
|
bcae3fcc4e | ||
|
|
1800a1c796 | ||
|
|
1a8e67076a | ||
|
|
939499beed | ||
|
|
6feb2afd50 | ||
|
|
58987cb8b1 | ||
|
|
3cf5e8225d | ||
|
|
0bac880991 | ||
|
|
c03960decd | ||
|
|
00e6830204 | ||
|
|
30b391ab36 | ||
|
|
382b1fc1b6 | ||
|
|
2e3c6627ce | ||
|
|
2e181602a1 | ||
|
|
414a5bc924 | ||
|
|
dd0d5d5a82 | ||
|
|
458a94e425 | ||
|
|
f0de3cd0a0 | ||
|
|
825948edab | ||
|
|
b4eea187d5 | ||
|
|
a699de83ec | ||
|
|
0325e18f34 | ||
|
|
10421ec9ee |
@@ -22,7 +22,12 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-vercel:
|
||||
if: github.event_name == 'release'
|
||||
# Triggered automatically on release publish (production cuts) and
|
||||
# manually via `gh workflow run deploy-site.yml` when an out-of-band
|
||||
# main commit needs to ship live before the next release tag — e.g.
|
||||
# a skills-index PR that doesn't touch website/** paths and so
|
||||
# doesn't auto-deploy via the deploy-docs path.
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Vercel Deploy
|
||||
|
||||
@@ -28,8 +28,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own :main or release-tagged image. :latest is guarded separately
|
||||
# by the move-latest job. PR runs reuse a PR-scoped group with
|
||||
# its own image. PR runs reuse a PR-scoped group with
|
||||
# cancel-in-progress: true so rapid pushes to the same PR collapse to the
|
||||
# latest commit.
|
||||
concurrency:
|
||||
@@ -72,6 +71,8 @@ jobs:
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
@@ -140,12 +141,6 @@ jobs:
|
||||
# Push amd64 by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
#
|
||||
# We apply the OCI revision label here (and again on arm64) because
|
||||
# the move-latest job reads it off the linux/amd64 sub-manifest
|
||||
# config of the floating tag to decide whether it's safe to advance.
|
||||
# The label must be on each per-arch image — manifest lists themselves
|
||||
# don't carry image config labels.
|
||||
- name: Push amd64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
@@ -156,6 +151,8 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
@@ -210,6 +207,8 @@ jobs:
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
@@ -235,6 +234,8 @@ jobs:
|
||||
platforms: linux/arm64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
@@ -258,30 +259,17 @@ jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
# so it runs in ~30 seconds. On main pushes it produces :main; on
|
||||
# releases it produces :<release_tag_name>.
|
||||
# so it runs in ~30 seconds.
|
||||
#
|
||||
# For main pushes the ancestor check runs BEFORE the manifest push so
|
||||
# we never overwrite :main with an older commit. The top-level
|
||||
# concurrency group (`docker-${{ github.ref }}` with
|
||||
# `cancel-in-progress: false`) already serialises runs per ref; the
|
||||
# ancestor check is defense-in-depth.
|
||||
# On main pushes: tags both :main and :latest.
|
||||
# On releases: tags :<release_tag_name>.
|
||||
# ---------------------------------------------------------------------------
|
||||
merge:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-amd64, build-arm64]
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
pushed_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }}
|
||||
release_tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
@@ -298,86 +286,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Read the git revision label off the current :main manifest, then
|
||||
# use `git merge-base --is-ancestor` to check whether our commit is
|
||||
# a descendant of it. If :main doesn't exist yet, or its label is
|
||||
# missing, we treat that as "safe to publish". If another run
|
||||
# already advanced :main past us (or diverged), we skip and leave
|
||||
# it alone.
|
||||
- name: Decide whether to move :main
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
id: main_check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
|
||||
image_json=$(
|
||||
docker buildx imagetools inspect "${image}:main" \
|
||||
--format '{{ json (index .Image "linux/amd64") }}' \
|
||||
2>/dev/null || true
|
||||
)
|
||||
|
||||
if [ -z "${image_json}" ]; then
|
||||
echo "No existing :main (or inspect failed) — safe to publish."
|
||||
echo "push_main=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_sha=$(
|
||||
printf '%s' "${image_json}" \
|
||||
| jq -r '.config.Labels."org.opencontainers.image.revision" // ""'
|
||||
)
|
||||
|
||||
if [ -z "${current_sha}" ]; then
|
||||
echo "Registry :main has no revision label — safe to publish."
|
||||
echo "push_main=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Registry :main is at ${current_sha}"
|
||||
echo "This run is at ${GITHUB_SHA}"
|
||||
|
||||
if [ "${current_sha}" = "${GITHUB_SHA}" ]; then
|
||||
echo ":main already points at our SHA — nothing to do."
|
||||
echo "push_main=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Registry :main points at an unknown commit (${current_sha}); refusing to overwrite."
|
||||
echo "push_main=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
|
||||
echo "Our commit is a descendant of :main — safe to advance."
|
||||
echo "push_main=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Another run advanced :main past us (or diverged) — leaving it alone."
|
||||
echo "push_main=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Compute the tag for this run. Main pushes tag directly as :main
|
||||
# (no per-commit SHA tags); releases use the release tag name.
|
||||
- name: Compute tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=main" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Gate the manifest push on the ancestor check for main pushes.
|
||||
# For releases there is no gate — the check doesn't even run.
|
||||
- name: Create manifest list and push
|
||||
if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true'
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -385,137 +294,26 @@ jobs:
|
||||
for digest_file in *; do
|
||||
args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:main" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
"${args[@]}"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
|
||||
- name: Inspect image
|
||||
if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}"
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
|
||||
else
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:main"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
|
||||
# Signal to move-latest that the release tag is live.
|
||||
- name: Mark release tag pushed
|
||||
id: mark_release_pushed
|
||||
if: github.event_name == 'release'
|
||||
run: echo "pushed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Move :latest to point at the release tag the merge job pushed.
|
||||
#
|
||||
# :latest is the floating tag that tracks the most recent stable release.
|
||||
# Only `release: published` events advance it — never main pushes.
|
||||
#
|
||||
# We still run an ancestor check against the existing :latest so that a
|
||||
# backport release on an older branch (e.g. patching v1.1.5 after v1.2.3
|
||||
# is out) doesn't drag :latest backwards. The check is the same shape
|
||||
# as the ancestor check in the merge job for :main: read the OCI
|
||||
# revision label off the current :latest, look up that commit in git,
|
||||
# and only advance if our release commit is a strict descendant.
|
||||
# ---------------------------------------------------------------------------
|
||||
move-latest:
|
||||
if: |
|
||||
github.repository == 'NousResearch/hermes-agent'
|
||||
&& github.event_name == 'release'
|
||||
&& needs.merge.outputs.pushed_release_tag == 'true'
|
||||
needs: merge
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: docker-move-latest
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Decide whether to move :latest
|
||||
id: latest_check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
|
||||
image_json=$(
|
||||
docker buildx imagetools inspect "${image}:latest" \
|
||||
--format '{{ json (index .Image "linux/amd64") }}' \
|
||||
2>/dev/null || true
|
||||
)
|
||||
|
||||
if [ -z "${image_json}" ]; then
|
||||
echo "No existing :latest (or inspect failed) — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_sha=$(
|
||||
printf '%s' "${image_json}" \
|
||||
| jq -r '.config.Labels."org.opencontainers.image.revision" // ""'
|
||||
)
|
||||
|
||||
if [ -z "${current_sha}" ]; then
|
||||
echo "Registry :latest has no revision label — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Registry :latest is at ${current_sha}"
|
||||
echo "This release is at ${GITHUB_SHA}"
|
||||
|
||||
if [ "${current_sha}" = "${GITHUB_SHA}" ]; then
|
||||
echo ":latest already points at our SHA — nothing to do."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Make sure we have the :latest commit locally for merge-base.
|
||||
# Releases can be cut from any branch, so fetch broadly.
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Our release SHA must be a descendant of the current :latest.
|
||||
# Backport releases on older branches won't satisfy this and will
|
||||
# be left alone — :latest stays on the newer release.
|
||||
if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
|
||||
echo "Our release commit is a descendant of :latest — safe to advance."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Existing :latest is newer than this release (likely a backport) — leaving it alone."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Retag the already-pushed release manifest as :latest.
|
||||
- name: Move :latest to this release tag
|
||||
if: steps.latest_check.outputs.push_latest == 'true'
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.merge.outputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
docker buildx imagetools create \
|
||||
--tag "${image}:latest" \
|
||||
"${image}:${RELEASE_TAG}"
|
||||
|
||||
@@ -200,3 +200,22 @@ jobs:
|
||||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
plugin-isolation:
|
||||
# Enforce that core code and core tests never import from plugin packages.
|
||||
# Core must interact with plugins exclusively through the registry layer.
|
||||
# See scripts/check_no_plugin_imports_in_core.py for the rule list.
|
||||
name: Plugin isolation (blocking)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Run plugin isolation checker
|
||||
run: python scripts/check_no_plugin_imports_in_core.py
|
||||
|
||||
+8
-3
@@ -48,7 +48,7 @@ agent-browser/
|
||||
privvy*
|
||||
images/
|
||||
__pycache__/
|
||||
hermes_agent.egg-info/
|
||||
*.egg-info
|
||||
wandb/
|
||||
testlogs
|
||||
|
||||
@@ -78,11 +78,16 @@ mini-swe-agent/
|
||||
.nix-stamps/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
# skills.json + skills-meta.json are build artifacts emitted by
|
||||
# website/scripts/extract-skills.py during prebuild — keep them out of
|
||||
# git for the same reason as skills-index.json (large, generated, change
|
||||
# every build).
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
models-dev-upstream/
|
||||
hermes_cli/tui_dist/*
|
||||
hermes_cli/scripts/
|
||||
docs/superpowers/*
|
||||
# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime;
|
||||
docs/superpowers/*# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime;
|
||||
# also created in-repo when an agent operates in this checkout). Plans, audit
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
|
||||
@@ -29,7 +29,9 @@ hermes-agent/
|
||||
├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
|
||||
├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
|
||||
├── batch_runner.py # Parallel batch processing
|
||||
├── _build_backend.py # Custom PEP 517 build backend — inlines plugin deps at wheel build time
|
||||
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
|
||||
│ └── plugin_registries.py # Typed capability registries (auth, transport, platform, tool, model_metadata)
|
||||
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
|
||||
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
|
||||
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
|
||||
@@ -39,16 +41,20 @@ hermes-agent/
|
||||
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
|
||||
│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
|
||||
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
|
||||
├── plugins/ # Plugin system (see "Plugins" section below)
|
||||
├── plugins/ # Plugin packages — uv workspace members (see "Plugins" section)
|
||||
│ ├── model-providers/ # anthropic, bedrock, azure-foundry (own pyproject.toml each)
|
||||
│ ├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
|
||||
│ ├── tts/ # Text-to-speech plugin
|
||||
│ ├── stt/ # Speech-to-text plugin
|
||||
│ ├── image_gen/ # FAL image generation
|
||||
│ ├── terminals/ # daytona, modal, vercel
|
||||
│ ├── web/ # exa, firecrawl, parallel
|
||||
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
|
||||
│ ├── context_engine/ # Context-engine plugins
|
||||
│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
|
||||
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
|
||||
│ ├── hermes-achievements/ # Gamified achievement tracking
|
||||
│ ├── observability/ # Metrics / traces / logs plugin
|
||||
│ ├── image_gen/ # Image-generation providers
|
||||
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
|
||||
│ # spotify, strike-freedom-cockpit, ...
|
||||
│ └── <others>/ # dashboard, google_meet, spotify, strike-freedom-cockpit, ...
|
||||
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
|
||||
├── skills/ # Built-in skills bundled with the repo
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
@@ -486,9 +492,102 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
|
||||
|
||||
## Plugins
|
||||
|
||||
Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
|
||||
repo-shipped plugins can be discovered alongside user-installed ones in
|
||||
`~/.hermes/plugins/` and pip-installed entry points.
|
||||
Hermes uses a **plugin-first architecture**: every optional capability (model
|
||||
providers, platform adapters, TTS/STT, terminal backends, image generation)
|
||||
lives in its own installable Python package under `plugins/`. The core
|
||||
codebase (`agent/`, `hermes_cli/`, `gateway/`, `tools/`) **never** imports
|
||||
from a `hermes_agent_*` plugin package directly. Instead, plugins register
|
||||
their capabilities into typed registries during `register()`, and the core
|
||||
queries those registries at runtime.
|
||||
|
||||
Full architecture doc: `website/docs/developer-guide/plugin-architecture.md`
|
||||
|
||||
### Workspace layout
|
||||
|
||||
All 21 builtin plugins are uv workspace members — each has its own
|
||||
`pyproject.toml` (single source of truth for deps), `plugin.yaml`
|
||||
(directory-scanner manifest for dev mode), and `hermes_agent_<name>/` package
|
||||
with `register(ctx)`:
|
||||
|
||||
```
|
||||
plugins/
|
||||
├── model-providers/ # anthropic, bedrock, azure-foundry
|
||||
├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
|
||||
├── tts/ # text-to-speech (Edge TTS + ElevenLabs)
|
||||
├── stt/ # speech-to-text
|
||||
├── image_gen/fal_pkg/ # FAL image generation
|
||||
├── terminals/ # daytona, modal, vercel
|
||||
├── web/ # exa, firecrawl, parallel
|
||||
├── memory/ # honcho, hindsight
|
||||
├── dashboard/ # streamlit dashboard
|
||||
└── hermes-achievements/ # gamified achievement tracking
|
||||
```
|
||||
|
||||
### The hermetic core boundary
|
||||
|
||||
Core code MUST NOT import from `hermes_agent_*` packages. Instead it queries
|
||||
typed registries in `agent/plugin_registries.py`:
|
||||
|
||||
```python
|
||||
# ❌ BAD — core directly imports plugin
|
||||
from hermes_agent_bedrock import has_aws_credentials
|
||||
|
||||
# ✅ GOOD — core queries the registry
|
||||
from agent.plugin_registries import registries
|
||||
bedrock_auth = registries.get_auth_provider("bedrock")
|
||||
```
|
||||
|
||||
Registry types: `auth_providers`, `transport_builders`, `platform_adapters`,
|
||||
`tool_providers`, `model_metadata`, `credential_pools`.
|
||||
|
||||
Each plugin's `register(ctx)` populates the registries via `ctx.register_*()`:
|
||||
- `ctx.register_auth_provider(name, provider, ...)`
|
||||
- `ctx.register_transport(name, builder, ...)`
|
||||
- `ctx.register_platform(name, label, adapter_factory, check_fn, ...)`
|
||||
- `ctx.register_tool_provider(entry, ...)`
|
||||
- `ctx.register_model_metadata(entry, ...)`
|
||||
- `ctx.register_credential_pool(entry, ...)`
|
||||
- Plus existing: `register_tool()`, `register_hook()`, `register_cli_command()`,
|
||||
`register_tts_provider()`, `register_transcription_provider()`,
|
||||
`register_image_gen_provider()`, `register_video_gen_provider()`,
|
||||
`register_context_engine()`
|
||||
|
||||
### Plugin discovery
|
||||
|
||||
Three discovery paths (same as before, now workspace-aware):
|
||||
1. **Directory scanner** — `plugins/`, `~/.hermes/plugins/`, `.hermes/plugins/`
|
||||
(looks for `plugin.yaml`)
|
||||
2. **Entry points** — `[project.entry-points."hermes_agent.plugins"]`
|
||||
3. **uv workspace** — `uv sync --extra <name>` installs the plugin into venv
|
||||
|
||||
### Dependency management
|
||||
|
||||
- Each plugin's `pyproject.toml` is the **only** place its deps are declared
|
||||
- Root `pyproject.toml` maps extras to workspace members:
|
||||
`telegram = ["hermes-agent-telegram"]`
|
||||
- `uv.lock` resolves the whole workspace (236 packages)
|
||||
- No `LAZY_DEPS`, no `ensure()`, no runtime `pip install`
|
||||
- Custom PEP 517 build backend (`_build_backend.py`) inlines plugin deps
|
||||
at wheel build time for PyPI publishing
|
||||
|
||||
### NixOS
|
||||
|
||||
`loadWorkspace` discovers all workspace members from `uv.lock` automatically.
|
||||
`mkVirtualEnv { hermes-agent = ["all"] }` installs all plugins. Select specific
|
||||
plugins with `extraDependencyGroups = ["telegram", "anthropic"]`.
|
||||
|
||||
### Tests
|
||||
|
||||
Plugin tests live in `plugins/<category>/<name>/tests/`. The test runner
|
||||
discovers both `tests/` and `plugins/`. Running plugin tests requires the
|
||||
plugin to be installed (`uv sync --extra <name>`).
|
||||
|
||||
### The rule
|
||||
|
||||
**If it can be a plugin, it must be a plugin.** Adding optional capabilities
|
||||
to core files is a code review rejection. If the plugin surface doesn't
|
||||
support what you need, extend the surface (new registry type, new hook, new
|
||||
`ctx` method) — don't inline the capability.
|
||||
|
||||
### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
|
||||
|
||||
@@ -531,9 +630,14 @@ providers don't clutter `hermes --help`.
|
||||
**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
|
||||
(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
|
||||
If a plugin needs a capability the framework doesn't expose, expand the
|
||||
generic plugin surface (new hook, new ctx method) — never hardcode
|
||||
plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
|
||||
honcho argparse from `main.py` for exactly this reason.
|
||||
generic plugin surface (new hook, new ctx method, new registry type) — never
|
||||
hardcode plugin-specific logic into core. PR #5295 removed 95 lines of
|
||||
hardcoded honcho argparse from `main.py` for exactly this reason.
|
||||
|
||||
**Hermetic core boundary (May 2026):** core code (`agent/`, `hermes_cli/`,
|
||||
`gateway/`, `tools/`) MUST NOT import from `hermes_agent_*` plugin packages.
|
||||
Use the typed registries in `agent/plugin_registries.py` instead. See the
|
||||
**Plugins** section below for the full list of registry types.
|
||||
|
||||
**No new in-tree memory providers (policy, May 2026):** the set of
|
||||
built-in memory providers under `plugins/memory/` is closed. New memory
|
||||
@@ -1011,40 +1115,41 @@ def profile_env(tmp_path, monkeypatch):
|
||||
|
||||
## Testing
|
||||
|
||||
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
|
||||
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
|
||||
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
|
||||
on a 16+ core developer machine with API keys set diverges from CI in ways
|
||||
that have caused multiple "works locally, fails in CI" incidents (and the reverse).
|
||||
**ALWAYS use `scripts/run_tests.sh`** — do NOT call `pytest` directly on a directory.
|
||||
The script enforces hermetic environment parity with CI and provides per-file
|
||||
process isolation that prevents registry singleton / module-level state leakage
|
||||
between test files.
|
||||
|
||||
```bash
|
||||
scripts/run_tests.sh # full suite, CI-parity
|
||||
scripts/run_tests.sh tests/gateway/ # one directory
|
||||
scripts/run_tests.sh tests/agent/test_foo.py # one file
|
||||
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
|
||||
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
|
||||
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
|
||||
```
|
||||
|
||||
### Subprocess-per-test isolation
|
||||
For a **single test file or specific test**, bare `pytest` is fine:
|
||||
|
||||
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
|
||||
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
|
||||
ContextVars from one test cannot leak into the next — the historic
|
||||
`_reset_module_state` autouse fixture is gone.
|
||||
```bash
|
||||
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py -q
|
||||
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py::test_x --tb=short
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
Running bare `pytest` on a directory (e.g. `pytest tests/`) will print a warning
|
||||
from `conftest.py` telling you to use the script instead.
|
||||
|
||||
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
|
||||
Linux, macOS, and Windows alike (POSIX `fork` is not used).
|
||||
- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist
|
||||
parallelism amortizes this across cores; on a 20-core box the full suite
|
||||
finishes in roughly the same wall time as before, but flake-free.
|
||||
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
|
||||
Hangs are killed and surfaced as a failure report.
|
||||
- Pass `--no-isolate` to disable isolation — useful when debugging a single
|
||||
test interactively, or when you specifically want to verify state leakage.
|
||||
- The plugin disables itself in child processes (sentinel envvar
|
||||
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
|
||||
### Per-file process isolation
|
||||
|
||||
`scripts/run_tests.sh` calls `scripts/run_tests_parallel.py`, which spawns one
|
||||
`python -m pytest <file>` subprocess per test **file** (not per test), giving each
|
||||
a fresh Python interpreter. This means module-level dicts/sets, ContextVars, and
|
||||
registry singletons from one test file cannot leak into another — no shared state
|
||||
between files, no xdist required.
|
||||
|
||||
`HERMES_PARALLEL_RUNNER=1` is set in each subprocess so `conftest.py` knows tests
|
||||
are running under the managed runner. If you need to suppress the bare-pytest
|
||||
directory warning in a special case, set this variable yourself — but prefer the
|
||||
script.
|
||||
|
||||
### Why the wrapper (and why the old "just call pytest" doesn't work)
|
||||
|
||||
@@ -1056,31 +1161,13 @@ Five real sources of local-vs-CI drift the script closes:
|
||||
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
|
||||
| Timezone | Local TZ (PDT etc.) | UTC |
|
||||
| Locale | Whatever is set | C.UTF-8 |
|
||||
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
|
||||
| File isolation | Shared interpreter — state leaks between files | One subprocess per file |
|
||||
|
||||
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
|
||||
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
|
||||
is belt-and-suspenders.
|
||||
`tests/conftest.py` also enforces the credential/TZ/locale points as an autouse
|
||||
fixture so ANY pytest invocation (including IDE integrations) gets hermetic
|
||||
behavior — but the wrapper adds per-file process isolation on top.
|
||||
|
||||
### Running without the wrapper (only if you must)
|
||||
|
||||
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
|
||||
at minimum activate the venv. The isolation plugin loads automatically from
|
||||
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
|
||||
either way.
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # or: source venv/bin/activate
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
If you need to bypass isolation for fast feedback while debugging:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/agent/test_foo.py -q --no-isolate
|
||||
```
|
||||
|
||||
Always run the full suite before pushing changes.
|
||||
Always run the full suite via `scripts/run_tests.sh` before pushing changes.
|
||||
|
||||
### Don't write change-detector tests
|
||||
|
||||
|
||||
+4
-5
@@ -121,12 +121,11 @@ hermes chat -q "Hello"
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
|
||||
# Preferred — matches CI (hermetic env, per-file process isolation); see AGENTS.md
|
||||
scripts/run_tests.sh
|
||||
|
||||
# Alternative (activate the venv first). The wrapper is still recommended
|
||||
# for parity with GitHub Actions before you open a PR:
|
||||
pytest tests/ -v
|
||||
# For a single file or specific test, bare pytest is also fine:
|
||||
# python -m pytest tests/agent/test_foo.py -q
|
||||
```
|
||||
|
||||
---
|
||||
@@ -857,7 +856,7 @@ refactor/description # Code restructuring
|
||||
|
||||
### Before submitting
|
||||
|
||||
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated
|
||||
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI — hermetic env + per-file process isolation)
|
||||
2. **Test manually**: Run `hermes` and exercise the code path you changed
|
||||
3. **Check cross-platform impact**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2
|
||||
4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature.
|
||||
|
||||
+44
-2
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -187,6 +187,29 @@ RUN chmod -R a+rX /opt/hermes && \
|
||||
# this a fast (~1s) egg-link creation with no resolution or downloads.
|
||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||
|
||||
# ---------- Bake build-time git revision ----------
|
||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||
# container always returns nothing — meaning `hermes dump` reports
|
||||
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
|
||||
# That makes support triage from container bug reports impossible:
|
||||
# we can't tell which commit the user is actually running.
|
||||
#
|
||||
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
|
||||
# /opt/hermes/.hermes_build_sha at build time, and have
|
||||
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
|
||||
# banner.get_git_banner_state() try the baked SHA first, then fall back
|
||||
# to live `git rev-parse` for source installs (unchanged behaviour).
|
||||
#
|
||||
# The arg is optional — local `docker build` without --build-arg simply
|
||||
# omits the file, and the runtime falls back to live-git lookup. CI
|
||||
# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
|
||||
# every published image has it.
|
||||
ARG HERMES_GIT_SHA=
|
||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
||||
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
|
||||
fi
|
||||
|
||||
# ---------- s6-overlay service wiring ----------
|
||||
# Static services declared at build time: main-hermes + dashboard.
|
||||
# Per-profile gateway services are registered dynamically at runtime by
|
||||
@@ -213,13 +236,32 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||
# command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends
|
||||
# up root-owned and unreadable to the supervised gateway (UID 10000).
|
||||
# The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and
|
||||
# transparently re-exec's the real venv binary via `s6-setuidgid hermes`
|
||||
# when invoked as root. Non-root callers (supervised processes,
|
||||
# `--user hermes`, etc.) hit the short-circuit path with no overhead.
|
||||
# Recursion is impossible because the shim exec's the venv binary by
|
||||
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
||||
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
||||
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
||||
|
||||
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
||||
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
||||
# same for the container's main process, but `docker exec` and our
|
||||
# cont-init.d scripts don't pass through the wrapper. Expose the venv
|
||||
# bin globally so `docker exec <container> hermes ...` and any
|
||||
# subprocess that doesn't activate the venv first still find hermes.
|
||||
ENV PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
||||
#
|
||||
# /opt/hermes/bin is prepended ahead of the venv so the privilege-drop
|
||||
# shim wins PATH resolution. The shim's last act is to exec the venv
|
||||
# binary by absolute path, so this PATH ordering is transparent to
|
||||
# every other consumer.
|
||||
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
||||
RUN mkdir -p /opt/data
|
||||
VOLUME [ "/opt/data" ]
|
||||
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv venv --python 3.11
|
||||
source venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
python -m pytest tests/ -q
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
# Hermes Agent v0.15.0 (v2026.5.28)
|
||||
|
||||
**Release Date:** May 28, 2026
|
||||
**Since v0.14.0:** 1,302 commits · 747 merged PRs · 1,746 files changed · 282,712 insertions · 36,699 deletions · 560+ issues closed (15 P0, 65 P1, 19 security-tagged) · 321 community contributors (including co-authors)
|
||||
|
||||
> **The Velocity Release.** Hermes gets dramatically faster — to start, to run, to ship work, and to grow. The 16,083-line `run_agent.py` collapses to 3,821 (-76%) across 14 cohesive `agent/*` modules. Kanban grew into a real multi-agent platform across 104 PRs — orchestrator auto-decomposition, swarm topology, scheduled tasks, worktree-per-task, per-task model overrides. The cold-start perf wave keeps going: another second shaved off launch, 47% fewer per-conversation function calls, `hermes --version` flipping the head-to-head benchmark against Codex CLI. `session_search` is 4,500× faster and free now. Promptware defense lands against Brainworm-class attacks. Bitwarden Secrets Manager replaces N per-provider API keys with one bootstrap token. Skill bundles let one slash command load a whole workflow. The Ink TUI gets a multi-session orchestrator. Two new image_gen providers (Krea 2 Medium + Large, FAL ported to plugin), the Nous-approved MCP catalog with an interactive picker, an OpenHands orchestration skill, ntfy as the 23rd messaging platform, and a deep xAI integration round (Web Search plugin, xai-oauth `hermes proxy` upstream, retired-May-15 model detection + `hermes migrate xai`, natural TTS speech-tag pauses, base_url leak guard, OpenAI-style execution guidance for Grok). 15 P0 + 65 P1 closures alongside.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **The Big Refactor — `run_agent.py` is no longer 16,000 lines** — The file at the heart of Hermes — the agent conversation loop — has been reduced from 16,083 lines to 3,821 (-76%), with the extracted code redistributed across 14 cohesive modules under `agent/`. Behavior is unchanged: every extraction keeps a thin forwarder on `AIAgent`, every test patch path still works, every external caller is compatible. The reason you care: future Hermes development moves faster, plugin authors can finally grep the codebase, and the file that took 90 seconds to load in your editor opens in a blink. ([#27248](https://github.com/NousResearch/hermes-agent/pull/27248))
|
||||
|
||||
- **Kanban grew into a real multi-agent platform — 104 PRs end to end** — Triage auto-decomposes one task into a tree of sub-tasks. `hermes kanban swarm` creates a full Swarm v1 graph in one command — root, parallel workers, gated verifier, gated synthesizer, shared blackboard. Tasks support per-task model overrides (cheap models for boilerplate, expensive ones for hard sub-tasks), board-level default workdirs, per-task worktree paths and branches, scheduled start times, configurable claim TTL, retry fingerprinting, stale-task detection, respawn guards, and a drag-to-delete trash zone. Workers report through `/workers/active`, `/runs/{id}`, and `/inspect` endpoints. ([#27572](https://github.com/NousResearch/hermes-agent/pull/27572), [#28443](https://github.com/NousResearch/hermes-agent/pull/28443), [#28364](https://github.com/NousResearch/hermes-agent/pull/28364), [#28394](https://github.com/NousResearch/hermes-agent/pull/28394), [#28462](https://github.com/NousResearch/hermes-agent/pull/28462), [#28384](https://github.com/NousResearch/hermes-agent/pull/28384), [#28467](https://github.com/NousResearch/hermes-agent/pull/28467), [#28455](https://github.com/NousResearch/hermes-agent/pull/28455), [#28452](https://github.com/NousResearch/hermes-agent/pull/28452), [#28432](https://github.com/NousResearch/hermes-agent/pull/28432), [#28468](https://github.com/NousResearch/hermes-agent/pull/28468), [#28420](https://github.com/NousResearch/hermes-agent/pull/28420))
|
||||
|
||||
- **Cold-start perf wave keeps going — another second saved, 47% fewer per-turn function calls** — Three new optimization rounds: defer `openai._base_client` import (-240ms / -17MB on every CLI invocation), hot-path optimizations cut 47% of per-conversation function calls (399k → 213k for 31-turn chat), defer compression-feasibility check (-170 to -290ms on every agent construction), adaptive subprocess polling (-195ms per tool call, 1+ second per turn). Termux cold start drops from 2.9s to 0.8s. `hermes --version` cold drops 63% (701ms → 258ms), flipping the head-to-head benchmark against Codex CLI from 5/11 wins to 6/11. ([#28864](https://github.com/NousResearch/hermes-agent/pull/28864), [#28866](https://github.com/NousResearch/hermes-agent/pull/28866), [#28957](https://github.com/NousResearch/hermes-agent/pull/28957), [#29006](https://github.com/NousResearch/hermes-agent/pull/29006), [#29419](https://github.com/NousResearch/hermes-agent/pull/29419), [#30121](https://github.com/NousResearch/hermes-agent/pull/30121), [#30609](https://github.com/NousResearch/hermes-agent/pull/30609), [#31968](https://github.com/NousResearch/hermes-agent/pull/31968))
|
||||
|
||||
- **`session_search` rebuilt — no LLM, no cost, 4,500× faster** — The old `session_search` was an aux-LLM-powered tool that cost ~$0.30/call and took ~30 seconds to summarize three sessions, sometimes confabulating when the right session wasn't even in the FTS5 hit list. The new shape is one tool with three modes (discovery, scroll, browse) inferred from which args are set — no `mode` parameter, no aux-LLM, no config knob, no companion skill. Discovery is ~20ms instead of ~90s; scroll is ~1ms. Searching your past sessions for context is now free and instant. ([#27590](https://github.com/NousResearch/hermes-agent/pull/27590))
|
||||
|
||||
- **Promptware defense — Brainworm-class attacks blocked at three chokepoints** — Inspired by recent Brainworm / Promptware Kill Chain research (Origin HQ, arxiv 2601.09625), Hermes now defends the context window against prompt-injection attacks that try to hijack the agent via tool output, recalled memory, or stored skills. Single source of truth (`tools/threat_patterns.py`) with ~15 new Brainworm/C2 patterns; recalled memory is scanned at load time; tool results get delimiter markers so a malicious file or remote service can't impersonate Hermes' own system content. Paired with a new `security-guidance` plugin that pattern-matches dangerous code writes. ([#32269](https://github.com/NousResearch/hermes-agent/pull/32269), [#33131](https://github.com/NousResearch/hermes-agent/pull/33131), [#9151](https://github.com/NousResearch/hermes-agent/pull/9151))
|
||||
|
||||
- **Bitwarden Secrets Manager — one bootstrap token replaces every per-provider API key** — Stop keeping plaintext API keys in `~/.hermes/.env`. Install Bitwarden Secrets Manager (`bws` auto-installs lazily on first use), point Hermes at it with one bootstrap token (`BWS_ACCESS_TOKEN`), and every credential you need comes from Bitwarden at startup. Rotate a key in the Bitwarden web app and the rotation actually takes effect — Bitwarden defaults to source-of-truth so its values overwrite matching env vars on startup. Flip `secrets.bitwarden.override_existing: false` to invert. EU Cloud and self-hosted Bitwarden server URLs supported. Detected credentials are now labeled with their source so you can see at a glance which keys came from Bitwarden vs. the local env. ([#30035](https://github.com/NousResearch/hermes-agent/pull/30035), [#31378](https://github.com/NousResearch/hermes-agent/pull/31378), [#30364](https://github.com/NousResearch/hermes-agent/pull/30364))
|
||||
|
||||
- **ntfy as the 23rd messaging platform — push notifications without an account** — ntfy is the self-hostable push-notification service with no signup, no API key, just a topic URL. Hermes now adapts to it as a platform plugin (zero edits to core), so your agent can send you push notifications from any cron job, kanban task completion, or chat `send_message` — to your phone, your watch, your desktop, your homelab. (salvages [#30625](https://github.com/NousResearch/hermes-agent/pull/30625) → originally [#4043](https://github.com/NousResearch/hermes-agent/pull/4043)) ([#30867](https://github.com/NousResearch/hermes-agent/pull/30867))
|
||||
|
||||
- **Skill bundles — `/<name>` loads multiple skills at once** — A skill bundle is a named group of skills that loads them all together with one slash command. Set up your "writing day" bundle (humanizer + ideation + obsidian + youtube-content) and `/writing-day` activates all four for the session. Skills Hub now has health checks, a freshness badge, and a watchdog cron. Three new optional skills land: `code-wiki` (Karpathy's LLM-Wiki, persistent indexed dev wiki), `openhands` (delegate to OpenHands for parallel coding agents), and `web-pentest` (OWASP-style web pentest recipes). ([#28373](https://github.com/NousResearch/hermes-agent/pull/28373), [#32345](https://github.com/NousResearch/hermes-agent/pull/32345), [#32240](https://github.com/NousResearch/hermes-agent/pull/32240), [#32261](https://github.com/NousResearch/hermes-agent/pull/32261), [#32265](https://github.com/NousResearch/hermes-agent/pull/32265))
|
||||
|
||||
- **TUI session orchestrator — multiple live sessions in one TUI window** — The Ink TUI gained an active-session switcher overlay. List, switch between, refresh, and close multiple live process-local sessions without leaving the TUI; dispatch a new session with a session-scoped model picker. Plus a wave of TUI polish — mouse-tracking DEC mode presets, scrollback preservation across branches and termux, slash-dropdown fixes, x.com link rendering, and CJK / IME input rendering improvements. (salvages [#27642](https://github.com/NousResearch/hermes-agent/pull/27642)) ([#32980](https://github.com/NousResearch/hermes-agent/pull/32980), [#30084](https://github.com/NousResearch/hermes-agent/pull/30084))
|
||||
|
||||
- **Two new image_gen providers — Krea 2 Medium + Large, FAL ported to plugin** — Krea joins the image_gen lineup as a built-in plugin: `Krea 2 Medium` ($0.03) and `Krea 2 Large` ($0.06), auto-discovered, selectable via `hermes tools` → Image Generation → Krea. Available through both the native Krea plugin and the FAL.ai catalog. The FAL.ai backend got pulled out of the monolithic image-generation tool into `plugins/image_gen/fal/`, completing the four-way architectural parity already established by web, browser, and video_gen — new image providers are now one file, not a fork. ([#33236](https://github.com/NousResearch/hermes-agent/pull/33236), [#30380](https://github.com/NousResearch/hermes-agent/pull/30380), [#33506](https://github.com/NousResearch/hermes-agent/pull/33506))
|
||||
|
||||
- **Nous-approved MCP catalog with interactive picker** — A curated catalog of Nous-vetted MCP servers, mirroring the optional-skills shape. Run `hermes mcp` and you get an interactive picker; install with one keystroke, credentials prompted at install time and written to `~/.hermes/.env`. Ships with the n8n manifest first. Closes the discovery gap that left users hunting GitHub for trusted MCP servers. ([#30870](https://github.com/NousResearch/hermes-agent/pull/30870))
|
||||
|
||||
- **OpenHands orchestration skill** — A new optional skill under `optional-skills/autonomous-ai-agents/openhands/` lets the agent delegate coding tasks to the OpenHands CLI alongside `claude-code`, `codex`, and `opencode`. OpenHands is the model-agnostic member of that family — any LiteLLM-supported provider works (OpenAI, Anthropic, OpenRouter, your own), so you can route a sub-task to the cheapest model that can finish it. Drop-in worker for kanban swarms and `/delegate` flows. (closes [#477](https://github.com/NousResearch/hermes-agent/issues/477)) ([#32261](https://github.com/NousResearch/hermes-agent/pull/32261))
|
||||
|
||||
- **Deep xAI integration round — Web Search plugin, OAuth proxy upstream, May 15 retirement detection, natural TTS, security hardening** — Six interlocking xAI improvements:
|
||||
- **xAI Web Search** lands as a `plugins/web/xai/` provider, slots alongside Brave / Tavily / Exa / SearXNG / DDGS / Firecrawl — reuses your existing Grok OAuth or `XAI_API_KEY` credentials, no new env vars. ([#29042](https://github.com/NousResearch/hermes-agent/pull/29042))
|
||||
- **`hermes proxy` gains an xAI upstream** — your local OpenAI-compatible endpoint can now be backed by SuperGrok OAuth, no PKCE-refresh code to write in your client. ([#28356](https://github.com/NousResearch/hermes-agent/pull/28356))
|
||||
- **May 15 model retirement detection** — `grok-4`, `grok-4-fast{,-reasoning,-non-reasoning}`, `grok-3`, `grok-code-fast-1`, `grok-imagine-image-pro` etc. are detected in doctor and chat startup, with `hermes migrate xai` to one-shot config migration to the supported model. No more silent 404s after the retirement date. ([#29277](https://github.com/NousResearch/hermes-agent/pull/29277))
|
||||
- **Opt-in `auto_speech_tags`** for xAI TTS — inserts light `[pause]` tags between paragraphs and sentences for more natural-sounding voice replies. Default OFF. ([#29376](https://github.com/NousResearch/hermes-agent/pull/29376))
|
||||
- **`xai-oauth` `base_url` pinned to `x.ai` origin** — closes a silent credential-leak vector where `XAI_BASE_URL` could repoint OAuth-authenticated inference to an attacker-controlled host. ([#28952](https://github.com/NousResearch/hermes-agent/pull/28952))
|
||||
- **OpenAI-style execution guidance applied to Grok models** — Grok and xai-oauth now get the same family-specific execution discipline block GPT/Codex have, so the model stops claiming completion without tool calls and stops suggesting workarounds instead of using existing tools. ([#27797](https://github.com/NousResearch/hermes-agent/pull/27797))
|
||||
- Plus `x_search` degraded-results surfacing, tier-gated 403 with API-key fallback, PKCE `code_challenge` round-trip fix, dead-token quarantine on terminal refresh failure, MiniMax-style short-token refresh on per-request, and `WKE=unauthenticated` honor at both classifier sites. ([#29484](https://github.com/NousResearch/hermes-agent/pull/29484), [#28351](https://github.com/NousResearch/hermes-agent/pull/28351), [#27560](https://github.com/NousResearch/hermes-agent/pull/27560), [#28116](https://github.com/NousResearch/hermes-agent/pull/28116), [#30619](https://github.com/NousResearch/hermes-agent/pull/30619), [#30872](https://github.com/NousResearch/hermes-agent/pull/30872))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### The Big Refactor — `run_agent.py` 16k → 3.8k
|
||||
|
||||
- `run_agent.py` from 16,083 → 3,821 lines (-76%), extracted into 14 cohesive `agent/*` modules. `run_conversation` alone was 3,877 lines before the refactor. Every extraction keeps a thin forwarder on `AIAgent`, every test-patch path is preserved, every external caller stays compatible. ([#27248](https://github.com/NousResearch/hermes-agent/pull/27248))
|
||||
|
||||
### Agent loop & conversation
|
||||
|
||||
- Auxiliary task layered fallback (primary → chain → main agent → graceful fail) on capacity errors (402/429/connection). (salvages [#26811](https://github.com/NousResearch/hermes-agent/pull/26811) + [#26998](https://github.com/NousResearch/hermes-agent/pull/26998)) ([#27625](https://github.com/NousResearch/hermes-agent/pull/27625))
|
||||
- Buffer retry/fallback status; surface only on terminal failure (no more noisy "retrying..." spam in mid-run output). ([#33816](https://github.com/NousResearch/hermes-agent/pull/33816))
|
||||
- Host contract for external context engines — condenses 5 prior PRs into one extension surface. ([#33750](https://github.com/NousResearch/hermes-agent/pull/33750))
|
||||
- Fallback immediately on provider content-policy blocks. ([#33883](https://github.com/NousResearch/hermes-agent/pull/33883))
|
||||
- Re-pad `reasoning_content` on cross-provider fallback to require-side providers. (salvage [#33784](https://github.com/NousResearch/hermes-agent/pull/33784)) ([#33795](https://github.com/NousResearch/hermes-agent/pull/33795))
|
||||
- Per-turn tool-outcome verifier — patch tool gets indent preservation, CRLF preservation, per-file failure escalation. ([#32273](https://github.com/NousResearch/hermes-agent/pull/32273))
|
||||
- Single-knob native vision for custom-provider models. ([#29679](https://github.com/NousResearch/hermes-agent/pull/29679))
|
||||
- Background review fork isolated from external memory plugins. ([#27190](https://github.com/NousResearch/hermes-agent/pull/27190))
|
||||
- Background review inherits parent toolset config for `tools[]` cache parity. ([#29704](https://github.com/NousResearch/hermes-agent/pull/29704))
|
||||
- Recover from providers returning list-type tool content. ([#30259](https://github.com/NousResearch/hermes-agent/pull/30259))
|
||||
- Treat partial-stream stub responses as length truncation rather than clean stop. ([#30998](https://github.com/NousResearch/hermes-agent/pull/30998))
|
||||
- OpenAI execution guidance applied to xAI Grok / xai-oauth. ([#27797](https://github.com/NousResearch/hermes-agent/pull/27797))
|
||||
- ContextVars propagate to concurrent tool worker threads.
|
||||
- Preload `jiter` native parser. ([#33692](https://github.com/NousResearch/hermes-agent/pull/33692))
|
||||
- Expose context engine tools with saved toolsets. (salvage of [#31194](https://github.com/NousResearch/hermes-agent/pull/31194)) ([#33719](https://github.com/NousResearch/hermes-agent/pull/33719))
|
||||
|
||||
### Sessions & memory
|
||||
|
||||
- `session_search` rebuilt — single-shape (discovery + scroll + browse), no aux-LLM, ~20ms vs. ~90s. ([#27590](https://github.com/NousResearch/hermes-agent/pull/27590))
|
||||
- Salvage [#29182](https://github.com/NousResearch/hermes-agent/pull/29182) — opt-in JSON snapshot writer for sessions. ([#29278](https://github.com/NousResearch/hermes-agent/pull/29278))
|
||||
- Persist `platform_message_id` for recall across gateway restarts. ([#29449](https://github.com/NousResearch/hermes-agent/pull/29449))
|
||||
- Inline memory-context mentions stay visible in conversation. ([#28132](https://github.com/NousResearch/hermes-agent/pull/28132))
|
||||
- Recalled memory labeled informational, not authoritative. ([#28583](https://github.com/NousResearch/hermes-agent/pull/28583))
|
||||
- Memory + context-engine tool injection gated on `enabled_toolsets`. ([#30177](https://github.com/NousResearch/hermes-agent/pull/30177))
|
||||
- Guard against external drift in `MEMORY.md` / `USER.md`. ([#30877](https://github.com/NousResearch/hermes-agent/pull/30877))
|
||||
- Honcho runtime peer mapping — correctness follow-ups + setup wizard + docs. ([#30077](https://github.com/NousResearch/hermes-agent/pull/30077))
|
||||
- Periodic memory logging for leak detection. (salvage of [#17667](https://github.com/NousResearch/hermes-agent/pull/17667)) ([#27102](https://github.com/NousResearch/hermes-agent/pull/27102))
|
||||
|
||||
### Codex / Responses-API maturation
|
||||
|
||||
- TTFB watchdog for stalled Codex Responses streams. ([#32042](https://github.com/NousResearch/hermes-agent/pull/32042))
|
||||
- Actionable hint when stale-call detector fires on known silent-reject pattern. ([#32016](https://github.com/NousResearch/hermes-agent/pull/32016), [#33133](https://github.com/NousResearch/hermes-agent/pull/33133))
|
||||
- Drop SDK `responses.stream()` helper; consume events directly. ([#33042](https://github.com/NousResearch/hermes-agent/pull/33042))
|
||||
- Gracefully recover from `invalid_encrypted_content`. (salvage of [#10144](https://github.com/NousResearch/hermes-agent/pull/10144)) ([#33035](https://github.com/NousResearch/hermes-agent/pull/33035))
|
||||
- Recover Codex Responses streams with null output. ([#32963](https://github.com/NousResearch/hermes-agent/pull/32963), [#33390](https://github.com/NousResearch/hermes-agent/pull/33390))
|
||||
- Drop foreign-issuer reasoning and transient `rs_tmp` reasoning replay state. ([#33156](https://github.com/NousResearch/hermes-agent/pull/33156), [#33146](https://github.com/NousResearch/hermes-agent/pull/33146))
|
||||
- Codex 429 quota classified as rate-limit, not missing credentials. ([#33168](https://github.com/NousResearch/hermes-agent/pull/33168))
|
||||
- Codex chat path falls back to credential_pool when singleton is empty. ([#33189](https://github.com/NousResearch/hermes-agent/pull/33189))
|
||||
- Codex re-auth syncs credential_pool. ([#33164](https://github.com/NousResearch/hermes-agent/pull/33164))
|
||||
- Omit `tools` key when no tools registered. ([#33409](https://github.com/NousResearch/hermes-agent/pull/33409))
|
||||
- Parse Codex image-generation SSE directly. ([#32933](https://github.com/NousResearch/hermes-agent/pull/32933))
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ Kanban — Multi-Agent Maturation Wave
|
||||
|
||||
### Orchestration & dispatch
|
||||
|
||||
- Orchestrator-driven auto-decomposition on triage. ([#27572](https://github.com/NousResearch/hermes-agent/pull/27572))
|
||||
- Kanban swarm topology helper — `hermes kanban swarm` creates a Swarm v1 graph (root + parallel workers + gated verifier + gated synthesizer + shared blackboard). (salvages [#26791](https://github.com/NousResearch/hermes-agent/pull/26791) by @Niraven) ([#28443](https://github.com/NousResearch/hermes-agent/pull/28443))
|
||||
- Dispatcher wires review agents from the review column. ([#28449](https://github.com/NousResearch/hermes-agent/pull/28449))
|
||||
- Stale-detection for running tasks in dispatcher. ([#28452](https://github.com/NousResearch/hermes-agent/pull/28452))
|
||||
- Respawn guard blocks repeat worker storms. ([#28455](https://github.com/NousResearch/hermes-agent/pull/28455))
|
||||
- Respawn guard defers `blocker_auth` instead of auto-blocking. ([#28683](https://github.com/NousResearch/hermes-agent/pull/28683))
|
||||
- Cross-profile cron jobs surface in dashboard. ([#28457](https://github.com/NousResearch/hermes-agent/pull/28457))
|
||||
- Worker visibility endpoints: `/workers/active`, `/runs/{id}`, `/inspect`. (salvages [#23761](https://github.com/NousResearch/hermes-agent/pull/23761) by @Interstellar-code) ([#28432](https://github.com/NousResearch/hermes-agent/pull/28432))
|
||||
|
||||
### Task configuration & scheduling
|
||||
|
||||
- Per-task model override. ([#28364](https://github.com/NousResearch/hermes-agent/pull/28364))
|
||||
- Board-level default workdir. ([#28394](https://github.com/NousResearch/hermes-agent/pull/28394))
|
||||
- Configurable worktree paths and branches. ([#28462](https://github.com/NousResearch/hermes-agent/pull/28462))
|
||||
- Scheduled task start times. ([#28384](https://github.com/NousResearch/hermes-agent/pull/28384))
|
||||
- Scheduled status for delayed follow-ups. ([#28467](https://github.com/NousResearch/hermes-agent/pull/28467))
|
||||
- Trimmed task comments. ([#28399](https://github.com/NousResearch/hermes-agent/pull/28399))
|
||||
- Initial-status for human-ops cards. ([#28414](https://github.com/NousResearch/hermes-agent/pull/28414))
|
||||
- `max_in_progress` config to cap concurrent running tasks. ([#28420](https://github.com/NousResearch/hermes-agent/pull/28420))
|
||||
- Filter tasks by workflow fields. ([#28454](https://github.com/NousResearch/hermes-agent/pull/28454))
|
||||
- `--sort` for `hermes kanban list`. ([#28427](https://github.com/NousResearch/hermes-agent/pull/28427))
|
||||
- Optional `board` parameter on all MCP tools. ([#28444](https://github.com/NousResearch/hermes-agent/pull/28444))
|
||||
- Stamp originating ACP session_id on tasks. ([#28447](https://github.com/NousResearch/hermes-agent/pull/28447))
|
||||
- `auto_promote_children` config toggle. ([#28344](https://github.com/NousResearch/hermes-agent/pull/28344))
|
||||
- `archive --rm` to hard-delete archived tasks. ([#28355](https://github.com/NousResearch/hermes-agent/pull/28355))
|
||||
- Promote dependents when parent is archived. ([#28372](https://github.com/NousResearch/hermes-agent/pull/28372))
|
||||
- Promote blocked tasks when parent dependencies complete. ([#28377](https://github.com/NousResearch/hermes-agent/pull/28377))
|
||||
- Demote ready children when parent is reopened. ([#28382](https://github.com/NousResearch/hermes-agent/pull/28382))
|
||||
- `promote` verb for manual `todo→ready` recovery + bulk `--ids`. (salvage [#29464](https://github.com/NousResearch/hermes-agent/pull/29464)) ([#31334](https://github.com/NousResearch/hermes-agent/pull/31334))
|
||||
|
||||
### Dashboard
|
||||
|
||||
- Drag-to-delete trash zone + bulk delete. ([#28468](https://github.com/NousResearch/hermes-agent/pull/28468))
|
||||
- Surface per-task `model_override` in show + tool output. ([#28442](https://github.com/NousResearch/hermes-agent/pull/28442))
|
||||
- Cross-profile notification delivery via `kanban.notification_sources`. ([#28395](https://github.com/NousResearch/hermes-agent/pull/28395))
|
||||
- Scratch-workspace deletion warning for users. ([#30949](https://github.com/NousResearch/hermes-agent/pull/30949))
|
||||
- Mobile dashboard UX polish. ([#28127](https://github.com/NousResearch/hermes-agent/pull/28127))
|
||||
|
||||
### Reliability
|
||||
|
||||
- Worker log retention configurable. ([#27867](https://github.com/NousResearch/hermes-agent/pull/27867))
|
||||
- Configurable claim TTL. ([#28392](https://github.com/NousResearch/hermes-agent/pull/28392))
|
||||
- Fingerprint crash errors to prevent fleet-wide retry exhaustion. ([#28380](https://github.com/NousResearch/hermes-agent/pull/28380))
|
||||
- Reset failure counters on `unblock_task`. ([#28379](https://github.com/NousResearch/hermes-agent/pull/28379))
|
||||
- Detect cycles in `decompose_triage_task` sibling-link pre-validation. ([#28088](https://github.com/NousResearch/hermes-agent/pull/28088))
|
||||
- Surface unusable triage auxiliary model (auto-decompose aware). ([#27871](https://github.com/NousResearch/hermes-agent/pull/27871))
|
||||
- Align failure diagnostics with retry limit. ([#27868](https://github.com/NousResearch/hermes-agent/pull/27868))
|
||||
- Align worker terminal timeout with task runtime. ([#27864](https://github.com/NousResearch/hermes-agent/pull/27864))
|
||||
- Auto-install bundled skills (kanban-worker) on init. ([#28368](https://github.com/NousResearch/hermes-agent/pull/28368))
|
||||
- Make legacy task migration idempotent. ([#28397](https://github.com/NousResearch/hermes-agent/pull/28397))
|
||||
- Serialize DB initialization. ([#28383](https://github.com/NousResearch/hermes-agent/pull/28383))
|
||||
- Persist worker session metadata on completion. ([#28387](https://github.com/NousResearch/hermes-agent/pull/28387))
|
||||
- Pass `accept-hooks` to worker chat subprocess. ([#28393](https://github.com/NousResearch/hermes-agent/pull/28393))
|
||||
- Preserve worker tools with restricted toolsets. ([#28396](https://github.com/NousResearch/hermes-agent/pull/28396))
|
||||
- Avoid unsafe Windows worker Hermes shim resolution. ([#28398](https://github.com/NousResearch/hermes-agent/pull/28398))
|
||||
- Sync slash subcommands with live parser. ([#28376](https://github.com/NousResearch/hermes-agent/pull/28376))
|
||||
- Show scheduled kanban tasks in dashboard. ([#28400](https://github.com/NousResearch/hermes-agent/pull/28400))
|
||||
- Assign single-task kanban decompositions. ([#28401](https://github.com/NousResearch/hermes-agent/pull/28401))
|
||||
- Configurable `max_tokens` for kanban specify. ([#28374](https://github.com/NousResearch/hermes-agent/pull/28374))
|
||||
- Per-job profile support for cron. ([#28124](https://github.com/NousResearch/hermes-agent/pull/28124))
|
||||
- Codex app-server: include every Kanban-pinned path in `writable_roots`. ([#28435](https://github.com/NousResearch/hermes-agent/pull/28435))
|
||||
- Cache kanban worker guidance at session init for prompt-cache reuse. ([#28425](https://github.com/NousResearch/hermes-agent/pull/28425))
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- `openai._base_client` import deferred — 240ms / 17MB off every CLI cold start. ([#28864](https://github.com/NousResearch/hermes-agent/pull/28864))
|
||||
- Agent-loop hot-path optimizations — 47% fewer per-conversation function calls (399k → 213k for 31-turn chat). ([#28866](https://github.com/NousResearch/hermes-agent/pull/28866))
|
||||
- Compression-feasibility check deferred — 170-290ms off every agent construction. ([#28957](https://github.com/NousResearch/hermes-agent/pull/28957))
|
||||
- Adaptive subprocess poll — ~195ms off every tool call, 1+ second per turn. ([#29006](https://github.com/NousResearch/hermes-agent/pull/29006))
|
||||
- Termux TUI cold start speedup. ([#29419](https://github.com/NousResearch/hermes-agent/pull/29419))
|
||||
- Termux non-TUI cold start speedup. (salvage [#29438](https://github.com/NousResearch/hermes-agent/pull/29438)) ([#30121](https://github.com/NousResearch/hermes-agent/pull/30121))
|
||||
- Termux fast-path version + deferred bare-prompt agent startup. ([#30609](https://github.com/NousResearch/hermes-agent/pull/30609))
|
||||
- Cut hermes `--version` wall time 63% — flips head-to-head vs Codex CLI. ([#31968](https://github.com/NousResearch/hermes-agent/pull/31968))
|
||||
- Date-only timestamp + loud gateway-DB roundtrip logging — improves prompt-cache hit rate. ([#27675](https://github.com/NousResearch/hermes-agent/pull/27675))
|
||||
- Cache kanban worker guidance at session init for prompt-cache reuse. ([#28425](https://github.com/NousResearch/hermes-agent/pull/28425))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### Tool surface
|
||||
|
||||
- `patch`: indent preservation, CRLF preservation, per-file failure escalation. ([#32273](https://github.com/NousResearch/hermes-agent/pull/32273))
|
||||
- `terminal`: warn at call time when `background=true` runs silently. ([#31289](https://github.com/NousResearch/hermes-agent/pull/31289))
|
||||
- `terminal`: nudge homebrewed CI pollers at the tool surface. ([#33142](https://github.com/NousResearch/hermes-agent/pull/33142))
|
||||
- `x_search`: surface degraded results + validate dates. ([#29484](https://github.com/NousResearch/hermes-agent/pull/29484))
|
||||
- `x_search`: auto-enable toolset when xAI credentials are configured. ([#27376](https://github.com/NousResearch/hermes-agent/pull/27376))
|
||||
- `computer_use`: route SOM/vision captures via auxiliary.vision. ([#30126](https://github.com/NousResearch/hermes-agent/pull/30126))
|
||||
- `transcription`: reject symlinked audio inputs. ([#10082](https://github.com/NousResearch/hermes-agent/pull/10082))
|
||||
- TTS: prevent double `[pause]` in xAI auto speech tags. ([#32237](https://github.com/NousResearch/hermes-agent/pull/32237))
|
||||
- TTS: preserve native audio outside Telegram voice delivery. ([#28512](https://github.com/NousResearch/hermes-agent/pull/28512))
|
||||
- TTS: opt-in xAI `auto_speech_tags` speech-tag pauses for natural voice replies. ([#29376](https://github.com/NousResearch/hermes-agent/pull/29376))
|
||||
- Voice: chunk oversized CLI recordings. ([#30044](https://github.com/NousResearch/hermes-agent/pull/30044))
|
||||
- Voice: honor `PULSE_SERVER` / `PIPEWIRE_REMOTE` inside Docker. ([#22534](https://github.com/NousResearch/hermes-agent/pull/22534))
|
||||
|
||||
### Browser
|
||||
|
||||
- All cloud browser providers (Browserbase, Anchor, Camofox, Hyperbrowser, etc.) migrated to image_gen-style plugins. (salvages [#25580](https://github.com/NousResearch/hermes-agent/pull/25580)) ([#27403](https://github.com/NousResearch/hermes-agent/pull/27403))
|
||||
- Auto-launch Chromium-family browser for CDP. ([#29106](https://github.com/NousResearch/hermes-agent/pull/29106))
|
||||
- Docker: discover agent-browser Chromium binary at boot. ([#33184](https://github.com/NousResearch/hermes-agent/pull/33184))
|
||||
|
||||
### Image generation
|
||||
|
||||
- **Krea** provider plugin (Krea 2 Medium + Large). ([#33236](https://github.com/NousResearch/hermes-agent/pull/33236))
|
||||
- FAL backend ported to `plugins/image_gen/fal`. (salvage [#27966](https://github.com/NousResearch/hermes-agent/pull/27966)) ([#30380](https://github.com/NousResearch/hermes-agent/pull/30380))
|
||||
- Cache xAI ephemeral URL responses to disk. ([#31759](https://github.com/NousResearch/hermes-agent/pull/31759))
|
||||
|
||||
### Web search
|
||||
|
||||
- **xAI Web Search** as a provider plugin. ([#29042](https://github.com/NousResearch/hermes-agent/pull/29042))
|
||||
|
||||
### MCP
|
||||
|
||||
- **Nous-approved MCP catalog** with interactive picker. ([#30870](https://github.com/NousResearch/hermes-agent/pull/30870))
|
||||
- **TLS client certificate (mTLS) support** for HTTP and SSE MCP servers. ([#33721](https://github.com/NousResearch/hermes-agent/pull/33721))
|
||||
- Stdin paste-back fallback for headless OAuth flow. ([#32053](https://github.com/NousResearch/hermes-agent/pull/32053))
|
||||
- `skip` at paste prompt bypasses auth without disabling server. ([#32069](https://github.com/NousResearch/hermes-agent/pull/32069))
|
||||
- Registry-aware `mcp_` prefix on both ends of round-trip. ([#31700](https://github.com/NousResearch/hermes-agent/pull/31700))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills Ecosystem
|
||||
|
||||
### Skills system
|
||||
|
||||
- **Skill bundles** — `/<name>` loads multiple skills. ([#28373](https://github.com/NousResearch/hermes-agent/pull/28373))
|
||||
- Skills Hub: health checks, freshness badge, and a watchdog cron. ([#32345](https://github.com/NousResearch/hermes-agent/pull/32345))
|
||||
- Opt-in AST deep diagnostics on skill writes. (salvage of [#30918](https://github.com/NousResearch/hermes-agent/pull/30918)) ([#31198](https://github.com/NousResearch/hermes-agent/pull/31198))
|
||||
- Bundled/pinned skill protection in background-review prompts. ([#28338](https://github.com/NousResearch/hermes-agent/pull/28338))
|
||||
- Show user-modified skill names in bundled skill sync summary. ([#28671](https://github.com/NousResearch/hermes-agent/pull/28671))
|
||||
- Load symlinked skill slash commands. ([#27759](https://github.com/NousResearch/hermes-agent/pull/27759))
|
||||
- Deduplicate Skills Hub search results by identifier, not name. ([#29490](https://github.com/NousResearch/hermes-agent/pull/29490))
|
||||
|
||||
### New skills
|
||||
|
||||
- `openhands` — delegate-to-OpenHands orchestration skill (closes [#477](https://github.com/NousResearch/hermes-agent/issues/477)) ([#32261](https://github.com/NousResearch/hermes-agent/pull/32261))
|
||||
- `code-wiki` — persistent indexed dev wiki (closes [#486](https://github.com/NousResearch/hermes-agent/issues/486)) ([#32240](https://github.com/NousResearch/hermes-agent/pull/32240))
|
||||
- `web-pentest` — OWASP recipes (closes [#400](https://github.com/NousResearch/hermes-agent/issues/400)) ([#32265](https://github.com/NousResearch/hermes-agent/pull/32265))
|
||||
- `baoyu-article-illustrator` ([#28287](https://github.com/NousResearch/hermes-agent/pull/28287))
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Providers
|
||||
|
||||
### xAI deep integration
|
||||
|
||||
- **xAI Web Search** as a `plugins/web/xai/` provider plugin. ([#29042](https://github.com/NousResearch/hermes-agent/pull/29042))
|
||||
- **`hermes proxy` xAI upstream** — OpenAI-compatible local proxy backed by xai-oauth. ([#28356](https://github.com/NousResearch/hermes-agent/pull/28356))
|
||||
- **May 15 model retirement detection + `hermes migrate xai`** for grok-4 / grok-3 / grok-code-fast-1 / grok-imagine-image-pro. ([#29277](https://github.com/NousResearch/hermes-agent/pull/29277))
|
||||
- **Opt-in `auto_speech_tags`** for natural xAI TTS voice replies. ([#29376](https://github.com/NousResearch/hermes-agent/pull/29376))
|
||||
- **xai-oauth base_url pinned to x.ai origin** — closes silent credential-leak vector. ([#28952](https://github.com/NousResearch/hermes-agent/pull/28952))
|
||||
- **OpenAI-style execution guidance** applied to Grok / xai-oauth models. ([#27797](https://github.com/NousResearch/hermes-agent/pull/27797))
|
||||
- xAI: detect retired May 15 models in doctor/chat startup. ([#29277](https://github.com/NousResearch/hermes-agent/pull/29277))
|
||||
- xAI: resolve Grok Build context for OAuth. ([#30579](https://github.com/NousResearch/hermes-agent/pull/30579))
|
||||
- xAI OAuth: tier-gated 403 with API-key fallback. ([#28351](https://github.com/NousResearch/hermes-agent/pull/28351))
|
||||
- xAI OAuth: PKCE `code_challenge` echo. ([#27560](https://github.com/NousResearch/hermes-agent/pull/27560))
|
||||
- xAI OAuth: quarantine dead tokens on terminal refresh failure. ([#28116](https://github.com/NousResearch/hermes-agent/pull/28116))
|
||||
- xAI OAuth: honor `WKE=unauthenticated` disambiguator at both classifier sites. ([#30872](https://github.com/NousResearch/hermes-agent/pull/30872))
|
||||
- xAI OAuth: accept bare-code manual paste (state=None). (closes [#26923](https://github.com/NousResearch/hermes-agent/issues/26923)) ([#33880](https://github.com/NousResearch/hermes-agent/pull/33880))
|
||||
- xAI OAuth: fall back to manual paste on loopback timeout. ([#33231](https://github.com/NousResearch/hermes-agent/pull/33231))
|
||||
- xAI proxy: handle 429 rate-limit responses in proxy retry path. ([#33743](https://github.com/NousResearch/hermes-agent/pull/33743))
|
||||
|
||||
### Other providers
|
||||
|
||||
- **OpenAI API as a first-class provider** (distinct from Codex runtime). ([#31898](https://github.com/NousResearch/hermes-agent/pull/31898))
|
||||
- **Microsoft Entra ID** auth for Azure Foundry (with 1M Anthropic-Messages beta preserved on Bearer). (salvages [#27509](https://github.com/NousResearch/hermes-agent/pull/27509), [#27022](https://github.com/NousResearch/hermes-agent/pull/27022)) ([#28101](https://github.com/NousResearch/hermes-agent/pull/28101), [#28084](https://github.com/NousResearch/hermes-agent/pull/28084))
|
||||
- **OpenRouter** sticky routing — `session_id` passed via `extra_body` so a long-running session keeps landing on the same upstream provider. (@Cybourgeoisie) ([#33939](https://github.com/NousResearch/hermes-agent/pull/33939))
|
||||
- Nous: JWT token for inference; stop replaying invalid Nous refresh tokens. (@rewbs) ([#27663](https://github.com/NousResearch/hermes-agent/pull/27663))
|
||||
- Nous Portal: one-shot setup, status CLI, and Nous-included markers. ([#30860](https://github.com/NousResearch/hermes-agent/pull/30860))
|
||||
- Anthropic adapter: extract 7 helpers from `convert_messages_to_anthropic`. (salvage [#27784](https://github.com/NousResearch/hermes-agent/pull/27784)) ([#30386](https://github.com/NousResearch/hermes-agent/pull/30386))
|
||||
- Catalog: add `qwen3.7-max` to Alibaba + Alibaba-Coding-Plan model lists. ([#33129](https://github.com/NousResearch/hermes-agent/pull/33129))
|
||||
- opencode-go: route `qwen3.7-max` via `anthropic_messages`. (@beardthelion) ([#32780](https://github.com/NousResearch/hermes-agent/pull/32780))
|
||||
- opencode-go: expose Kimi K2 + DeepSeek reasoning controls. ([#30845](https://github.com/NousResearch/hermes-agent/pull/30845))
|
||||
- Remove Vercel AI Gateway and Vercel Sandbox.
|
||||
- MiniMax OAuth: refresh short-lived access tokens per request. ([#30619](https://github.com/NousResearch/hermes-agent/pull/30619))
|
||||
- Codex OAuth: quarantine terminal refresh errors. ([#28118](https://github.com/NousResearch/hermes-agent/pull/28118))
|
||||
- Codex: drop dead model slugs that HTTP 400 on ChatGPT Pro. ([#33424](https://github.com/NousResearch/hermes-agent/pull/33424))
|
||||
- Codex: sync `manual:device_code` pool entries on re-auth. ([#33744](https://github.com/NousResearch/hermes-agent/pull/33744))
|
||||
- MiniMax OAuth: quarantine terminal refresh errors. ([#28119](https://github.com/NousResearch/hermes-agent/pull/28119))
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Secrets
|
||||
|
||||
- **Bitwarden Secrets Manager** integration with lazy `bws` install. ([#30035](https://github.com/NousResearch/hermes-agent/pull/30035))
|
||||
- Bitwarden: EU Cloud + self-hosted server URL support. ([#31378](https://github.com/NousResearch/hermes-agent/pull/31378))
|
||||
- Label detected credentials with their source (Bitwarden). ([#30364](https://github.com/NousResearch/hermes-agent/pull/30364))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### Gateway core
|
||||
|
||||
- **Deliverable mode** — agents ship artifacts as native uploads from any platform (Slack/Discord/Telegram/Teams/Email). ([#27813](https://github.com/NousResearch/hermes-agent/pull/27813))
|
||||
- `hermes send` — pipe any script's output to any messaging platform. (salvage of [#19631](https://github.com/NousResearch/hermes-agent/pull/19631)) ([#27188](https://github.com/NousResearch/hermes-agent/pull/27188))
|
||||
- Debounce queued text follow-ups during active sessions. (salvage of [#31235](https://github.com/NousResearch/hermes-agent/pull/31235)) ([#31341](https://github.com/NousResearch/hermes-agent/pull/31341))
|
||||
- Plugin-transformed final_response delivered through streaming gate. ([#31433](https://github.com/NousResearch/hermes-agent/pull/31433))
|
||||
- Refresh cached agent tools on `/reload-mcp`. ([#32815](https://github.com/NousResearch/hermes-agent/pull/32815))
|
||||
- Harden kanban + provider cleanup races on long-running workloads. ([#29479](https://github.com/NousResearch/hermes-agent/pull/29479))
|
||||
|
||||
### New / reorganized adapters
|
||||
|
||||
- **ntfy** — 23rd platform, push notifications, plugin shape, zero core edits. (salvages [#30625](https://github.com/NousResearch/hermes-agent/pull/30625) → [#4043](https://github.com/NousResearch/hermes-agent/pull/4043)) ([#30867](https://github.com/NousResearch/hermes-agent/pull/30867))
|
||||
- **Discord** adapter migrated to bundled plugin. (salvage of [#24356](https://github.com/NousResearch/hermes-agent/pull/24356)) ([#30591](https://github.com/NousResearch/hermes-agent/pull/30591))
|
||||
- **Mattermost** adapter migrated to bundled plugin. (salvage of [#30916](https://github.com/NousResearch/hermes-agent/pull/30916)) ([#31748](https://github.com/NousResearch/hermes-agent/pull/31748))
|
||||
|
||||
### Telegram
|
||||
|
||||
- Edit status messages in place instead of appending. (based on [#30141](https://github.com/NousResearch/hermes-agent/pull/30141) by @qike-ms) ([#30864](https://github.com/NousResearch/hermes-agent/pull/30864))
|
||||
- Skip-STT audio path + 2GB cap via local Bot API server. ([#28541](https://github.com/NousResearch/hermes-agent/pull/28541))
|
||||
- Route image documents (.png/.jpg/.webp/.gif) through vision pipeline. ([#28519](https://github.com/NousResearch/hermes-agent/pull/28519))
|
||||
- Route audio file attachments away from STT pipeline. ([#28478](https://github.com/NousResearch/hermes-agent/pull/28478))
|
||||
- `disable_topic_auto_rename` gateway flag. ([#28523](https://github.com/NousResearch/hermes-agent/pull/28523))
|
||||
- `ignore_root_dm` config to drop messages without thread_id. ([#28536](https://github.com/NousResearch/hermes-agent/pull/28536))
|
||||
- Chat-scoped auth without sender user_id. ([#28525](https://github.com/NousResearch/hermes-agent/pull/28525))
|
||||
- Fail-closed auth fallback when `TELEGRAM_ALLOWED_USERS` is empty. ([#28494](https://github.com/NousResearch/hermes-agent/pull/28494))
|
||||
- Roll over tool progress bubbles + scope audio_file_paths. ([#28482](https://github.com/NousResearch/hermes-agent/pull/28482))
|
||||
- Avoid duplicate text after auto-TTS voice replies. ([#28509](https://github.com/NousResearch/hermes-agent/pull/28509))
|
||||
- Mark final voice reply notify-worthy so Telegram delivers it audibly. ([#28504](https://github.com/NousResearch/hermes-agent/pull/28504))
|
||||
|
||||
### Discord
|
||||
|
||||
- Recover Windows voice opus decoding. ([#33182](https://github.com/NousResearch/hermes-agent/pull/33182))
|
||||
- `allow_any_attachment` config to accept arbitrary file types. ([#27245](https://github.com/NousResearch/hermes-agent/pull/27245))
|
||||
- Transcribe native voice notes. ([#28993](https://github.com/NousResearch/hermes-agent/pull/28993))
|
||||
- Define UI view classes after lazy install. ([#28817](https://github.com/NousResearch/hermes-agent/pull/28817))
|
||||
|
||||
### Signal / Matrix / Feishu / Slack / WeCom
|
||||
|
||||
- Signal: `require_mention` filter for group chats. ([#28574](https://github.com/NousResearch/hermes-agent/pull/28574))
|
||||
- Matrix: warn on clock-skew silent message drops. ([#27330](https://github.com/NousResearch/hermes-agent/pull/27330))
|
||||
- Matrix E2EE installs full dep set; plugins respect `is_connected`. ([#31688](https://github.com/NousResearch/hermes-agent/pull/31688))
|
||||
- Feishu: require webhook auth secret + honor config extras. ([#30746](https://github.com/NousResearch/hermes-agent/pull/30746))
|
||||
- Feishu: enforce auth and chat binding for approval buttons. ([#30744](https://github.com/NousResearch/hermes-agent/pull/30744))
|
||||
- Slack: socket recovery + Windows restart dedupe. ([#28873](https://github.com/NousResearch/hermes-agent/pull/28873))
|
||||
- WeCom: safe-parse untrusted XML. ([#32442](https://github.com/NousResearch/hermes-agent/pull/32442))
|
||||
|
||||
### DingTalk / Webhooks / Microsoft Graph
|
||||
|
||||
- DingTalk: transcribe native voice notes. ([#28993](https://github.com/NousResearch/hermes-agent/pull/28993))
|
||||
- Webhook: enforce `INSECURE_NO_AUTH` safety rail on dynamic route reloads. ([#30863](https://github.com/NousResearch/hermes-agent/pull/30863))
|
||||
- Webhook: restrict default toolset capabilities. ([#30745](https://github.com/NousResearch/hermes-agent/pull/30745))
|
||||
- Microsoft Graph: harden webhook auth requirements. ([#30169](https://github.com/NousResearch/hermes-agent/pull/30169))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & TUI
|
||||
|
||||
### CLI
|
||||
|
||||
- `/update` slash command in CLI and TUI. ([#23854](https://github.com/NousResearch/hermes-agent/pull/23854))
|
||||
- Update auto-rollback when post-pull syntax check fails. ([#28669](https://github.com/NousResearch/hermes-agent/pull/28669))
|
||||
- `--branch` flag for `hermes update`. (@jquesnelle) ([#29591](https://github.com/NousResearch/hermes-agent/pull/29591))
|
||||
- `/exit --delete` flag to remove session on quit. (salvage of [#17665](https://github.com/NousResearch/hermes-agent/pull/17665)) ([#27101](https://github.com/NousResearch/hermes-agent/pull/27101))
|
||||
- `▶ N` indicator in status bar for running `/background` tasks. ([#27175](https://github.com/NousResearch/hermes-agent/pull/27175))
|
||||
- Live background terminal-process count in status bar. ([#32061](https://github.com/NousResearch/hermes-agent/pull/32061))
|
||||
- Append session recap to `/status` output. (salvage of [#18587](https://github.com/NousResearch/hermes-agent/pull/18587)) ([#27176](https://github.com/NousResearch/hermes-agent/pull/27176))
|
||||
- Configurable paste-collapse thresholds (TUI + CLI). (salvage [#29723](https://github.com/NousResearch/hermes-agent/pull/29723)) ([#32087](https://github.com/NousResearch/hermes-agent/pull/32087))
|
||||
- `/resume` accepts position numbers. ([#31709](https://github.com/NousResearch/hermes-agent/pull/31709))
|
||||
- Bring tool-call display back — verbose mode, specific failure reasons, todo progress. ([#31293](https://github.com/NousResearch/hermes-agent/pull/31293))
|
||||
- Validate runtime token refresh in Qwen auth status. ([#31196](https://github.com/NousResearch/hermes-agent/pull/31196))
|
||||
|
||||
### TUI
|
||||
|
||||
- **TUI session orchestrator** — multiple live sessions in one TUI window. (salvages [#27642](https://github.com/NousResearch/hermes-agent/pull/27642)) ([#32980](https://github.com/NousResearch/hermes-agent/pull/32980))
|
||||
- `mouse_tracking` DEC mode presets. (salvage of [#26681](https://github.com/NousResearch/hermes-agent/pull/26681) by @OutThisLife) ([#30084](https://github.com/NousResearch/hermes-agent/pull/30084))
|
||||
- Termux scrollback preservation + touch-friendly defaults. ([#28910](https://github.com/NousResearch/hermes-agent/pull/28910))
|
||||
- Full assistant text in scrollback (no history truncation). ([#28829](https://github.com/NousResearch/hermes-agent/pull/28829))
|
||||
- Preserve scrollback when branching sessions. ([#30162](https://github.com/NousResearch/hermes-agent/pull/30162))
|
||||
- Preserve Python dunder identifiers in markdown. ([#28582](https://github.com/NousResearch/hermes-agent/pull/28582))
|
||||
- Active profile shown in TUI prompt. ([#28581](https://github.com/NousResearch/hermes-agent/pull/28581))
|
||||
- Improve Charizard completion menu contrast. ([#28346](https://github.com/NousResearch/hermes-agent/pull/28346))
|
||||
- Stop slash dropdown chopping last char of `/goal`. ([#31311](https://github.com/NousResearch/hermes-agent/pull/31311))
|
||||
- Clipboard copy on linux/wayland. ([#29342](https://github.com/NousResearch/hermes-agent/pull/29342))
|
||||
- Anchor `splitReasoning` unclosed-tag regex; stop eating last paragraph. ([#29426](https://github.com/NousResearch/hermes-agent/pull/29426))
|
||||
- Surface verbose tool details. ([#30225](https://github.com/NousResearch/hermes-agent/pull/30225))
|
||||
- Load Linux skills on Termux + salvage @adybag14-cyber's Termux gates. ([#30166](https://github.com/NousResearch/hermes-agent/pull/30166))
|
||||
- Handle images with codex app-server. ([#31220](https://github.com/NousResearch/hermes-agent/pull/31220))
|
||||
- Refresh virtual transcript on viewport resize. ([#31077](https://github.com/NousResearch/hermes-agent/pull/31077))
|
||||
- Ignore late thinking deltas after completion. ([#31055](https://github.com/NousResearch/hermes-agent/pull/31055))
|
||||
- Commit composer input bursts immediately. ([#31053](https://github.com/NousResearch/hermes-agent/pull/31053))
|
||||
- Log parent gateway lifecycle exits. ([#31051](https://github.com/NousResearch/hermes-agent/pull/31051))
|
||||
- Clear TTS env var on voice off + TTS indicator in status bar. ([#30987](https://github.com/NousResearch/hermes-agent/pull/30987))
|
||||
- Pass `--expose-gc` as node argv instead of NODE_OPTIONS. ([#29998](https://github.com/NousResearch/hermes-agent/pull/29998))
|
||||
- Align composer cursorLayout with wrap-ansi to kill multiline cursor drift. ([#27489](https://github.com/NousResearch/hermes-agent/pull/27489))
|
||||
- Harden Terminal.app rendering and color paths. ([#27251](https://github.com/NousResearch/hermes-agent/pull/27251))
|
||||
- Keep `/goal` verdict out of compact status row. ([#27971](https://github.com/NousResearch/hermes-agent/pull/27971))
|
||||
- Clamp curses color 8 for 8-color terminals (Docker). ([#30260](https://github.com/NousResearch/hermes-agent/pull/30260))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
### Promptware & memory hardening
|
||||
|
||||
- **Promptware defense** — shared threat patterns + memory load-time scan + tool-result delimiters. ([#32269](https://github.com/NousResearch/hermes-agent/pull/32269))
|
||||
- Expand memory content scanning patterns to parity with skills guard. ([#9151](https://github.com/NousResearch/hermes-agent/pull/9151))
|
||||
- Harden Skills Guard multi-word prompt patterns. (@YLChen-007) ([#26852](https://github.com/NousResearch/hermes-agent/pull/26852))
|
||||
- Split cron scanner so skill prose stops false-positiving exfil patterns. ([#32339](https://github.com/NousResearch/hermes-agent/pull/32339))
|
||||
|
||||
### File safety
|
||||
|
||||
- Protect Hermes control-plane files from prompt injection (`auth.json`, `config.yaml`, `webhook_subscriptions.json`, `mcp-tokens/`). (salvages @PratikRai0101's [#14157](https://github.com/NousResearch/hermes-agent/pull/14157)) ([#30397](https://github.com/NousResearch/hermes-agent/pull/30397))
|
||||
- Write-deny `<root>/.env` when running under a profile. ([#29687](https://github.com/NousResearch/hermes-agent/pull/29687))
|
||||
- Defense-in-depth read-deny on credential stores. (salvages [#17659](https://github.com/NousResearch/hermes-agent/pull/17659) + [#8055](https://github.com/NousResearch/hermes-agent/pull/8055)) ([#30721](https://github.com/NousResearch/hermes-agent/pull/30721))
|
||||
- TTS `output_path` traversal + update ZIP symlink reject. (salvage [#6693](https://github.com/NousResearch/hermes-agent/pull/6693) + [#15881](https://github.com/NousResearch/hermes-agent/pull/15881)) ([#32056](https://github.com/NousResearch/hermes-agent/pull/32056))
|
||||
- Reject symlinked audio inputs. ([#10082](https://github.com/NousResearch/hermes-agent/pull/10082))
|
||||
|
||||
### Credential safety
|
||||
|
||||
- Avoid persisting borrowed credential secrets — runtime env-sourced keys no longer leak into `auth.json`. ([#31416](https://github.com/NousResearch/hermes-agent/pull/31416))
|
||||
- Validate Nous Portal `inference_base_url` against host allowlist. (salvages [#27612](https://github.com/NousResearch/hermes-agent/pull/27612)) ([#30611](https://github.com/NousResearch/hermes-agent/pull/30611))
|
||||
- Harden API server key placeholder handling. ([#30738](https://github.com/NousResearch/hermes-agent/pull/30738))
|
||||
- Harden Google Chat OAuth credential persistence. (@Zyrixtrex) ([#24788](https://github.com/NousResearch/hermes-agent/pull/24788))
|
||||
- xAI OAuth: pin inference `base_url` to x.ai origin. ([#28952](https://github.com/NousResearch/hermes-agent/pull/28952))
|
||||
- Quarantine dead OAuth tokens on terminal refresh failure (xAI, Codex, MiniMax). ([#28116](https://github.com/NousResearch/hermes-agent/pull/28116), [#28118](https://github.com/NousResearch/hermes-agent/pull/28118), [#28119](https://github.com/NousResearch/hermes-agent/pull/28119))
|
||||
|
||||
### Supply-chain
|
||||
|
||||
- **On-demand supply-chain audit via OSV.dev** — `hermes audit`. ([#31460](https://github.com/NousResearch/hermes-agent/pull/31460))
|
||||
- `hermes update` syntax-validates critical files post-pull, auto-rollback on failure. ([#28669](https://github.com/NousResearch/hermes-agent/pull/28669))
|
||||
- Quarantine `hermes.exe` vs concurrent Windows instance. ([#26677](https://github.com/NousResearch/hermes-agent/pull/26677))
|
||||
|
||||
### Other hardening
|
||||
|
||||
- Restrict default webhook toolset capabilities. ([#30745](https://github.com/NousResearch/hermes-agent/pull/30745))
|
||||
- Harden Microsoft Graph webhook auth requirements. ([#30169](https://github.com/NousResearch/hermes-agent/pull/30169))
|
||||
- Require source CIDR allowlisting for public msgraph webhook binds. ([#33722](https://github.com/NousResearch/hermes-agent/pull/33722))
|
||||
- Require `API_SERVER_KEY` before dispatching API server work. ([#33232](https://github.com/NousResearch/hermes-agent/pull/33232))
|
||||
- env_passthrough: apply GHSA-rhgp-j443-p4rf filter to config.yaml path. (@roadhero) ([#27794](https://github.com/NousResearch/hermes-agent/pull/27794))
|
||||
- Dashboard + WeCom: restrict markdown link schemes; safe-parse untrusted XML. ([#32442](https://github.com/NousResearch/hermes-agent/pull/32442))
|
||||
- Salvage project-plugin RCE bypass fix from PR [#29311](https://github.com/NousResearch/hermes-agent/pull/29311) (GHSA-5qr3-c538-wm9j). ([#30837](https://github.com/NousResearch/hermes-agent/pull/30837))
|
||||
- Cross-profile soft guard on file-write tools + system-prompt hint. ([#31290](https://github.com/NousResearch/hermes-agent/pull/31290))
|
||||
- Reject unsafe tar members in Android psutil compatibility installer. ([#33742](https://github.com/NousResearch/hermes-agent/pull/33742))
|
||||
- Reject non-regular tar members during tirith auto-install. ([#33786](https://github.com/NousResearch/hermes-agent/pull/33786))
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Native Windows (Beta Continued)
|
||||
|
||||
- Complete Windows bootstrap — `dep_ensure` + `install.ps1` + detection. (@alt-glitch) ([#27845](https://github.com/NousResearch/hermes-agent/pull/27845))
|
||||
- `install.ps1`: strip BOM, `-Commit`/`-Tag` pin params, harden git ops. (@jquesnelle) ([#28169](https://github.com/NousResearch/hermes-agent/pull/28169))
|
||||
- Consolidate ACP browser bootstrap into `install.{sh,ps1}`. (@alt-glitch) ([#27851](https://github.com/NousResearch/hermes-agent/pull/27851))
|
||||
- `hermes update` quarantines live `hermes.exe`. ([#26677](https://github.com/NousResearch/hermes-agent/pull/26677))
|
||||
- Discord voice opus decoding on Windows. ([#33182](https://github.com/NousResearch/hermes-agent/pull/33182))
|
||||
- Windows Docker Desktop compatible compose file. (@Sunil123135) ([#31031](https://github.com/NousResearch/hermes-agent/pull/31031))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Web Dashboard
|
||||
|
||||
- Hardened Slack socket recovery + Windows restart dedupe. ([#28873](https://github.com/NousResearch/hermes-agent/pull/28873))
|
||||
- Web dashboard: migrate checkboxes to `@nous-research/ui` + design-system polish. (@austinpickett) ([#28814](https://github.com/NousResearch/hermes-agent/pull/28814))
|
||||
- Web dashboard: collapsible sidebar. (@austinpickett) ([#33421](https://github.com/NousResearch/hermes-agent/pull/33421))
|
||||
- Dashboard typography & contrast pass. (salvage of [#28832](https://github.com/NousResearch/hermes-agent/pull/28832)) ([#30714](https://github.com/NousResearch/hermes-agent/pull/30714))
|
||||
- Skills page: lazy-fetch catalog instead of bundling 34MB into JS. ([#33809](https://github.com/NousResearch/hermes-agent/pull/33809))
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
- **s6-overlay container supervision** — abstract `ServiceManager` protocol (systemd/launchd/Windows/s6 backends), per-profile gateway supervision in-container, container-restart reconciliation, hadolint/shellcheck CI. (salvage of [#30136](https://github.com/NousResearch/hermes-agent/pull/30136), @benbarclay) ([#31760](https://github.com/NousResearch/hermes-agent/pull/31760))
|
||||
- Auto-redirect `gateway run` to supervised mode inside the s6 image. (@benbarclay) ([#33583](https://github.com/NousResearch/hermes-agent/pull/33583))
|
||||
- Tee supervised gateway stdout to docker logs. (@benbarclay) ([#33621](https://github.com/NousResearch/hermes-agent/pull/33621))
|
||||
- Drop `docker exec` to hermes uid before invoking the CLI. (@benbarclay) ([#33628](https://github.com/NousResearch/hermes-agent/pull/33628))
|
||||
- Align HOME for dashboard and s6 gateway services. (@Dusk1e) ([#33481](https://github.com/NousResearch/hermes-agent/pull/33481))
|
||||
- Bake build-time git SHA into image so `hermes dump` reports it. (@benbarclay) ([#33655](https://github.com/NousResearch/hermes-agent/pull/33655))
|
||||
- `hermes update` prints `docker pull` guidance instead of bogus git error. (@benbarclay) ([#33659](https://github.com/NousResearch/hermes-agent/pull/33659))
|
||||
- Upgrade Node to 22 LTS via multi-stage from `node:22-bookworm-slim`. (@benbarclay) ([#33060](https://github.com/NousResearch/hermes-agent/pull/33060))
|
||||
- Drop `build-essential` from apt install. (@benbarclay) ([#33028](https://github.com/NousResearch/hermes-agent/pull/33028))
|
||||
- Propagate env through s6 to cont-init and main CMD. ([#32412](https://github.com/NousResearch/hermes-agent/pull/32412))
|
||||
- Targeted chown to preserve host file ownership in `HERMES_HOME`. ([#33033](https://github.com/NousResearch/hermes-agent/pull/33033))
|
||||
- `mkdir HERMES_HOME` as root in stage2 before chown / privilege drop. ([#33078](https://github.com/NousResearch/hermes-agent/pull/33078))
|
||||
- chown `ui-tui` and `node_modules` on UID remap so TUI esbuild works. ([#33045](https://github.com/NousResearch/hermes-agent/pull/33045))
|
||||
- Include `anthropic`, `bedrock`, `azure-identity` extras in image. ([#30504](https://github.com/NousResearch/hermes-agent/pull/30504))
|
||||
- Stop pushing per-commit SHA tags to Docker Hub. ([#29387](https://github.com/NousResearch/hermes-agent/pull/29387))
|
||||
- Simplify Docker tagging — push both `:main` and `:latest` on main push. ([#33225](https://github.com/NousResearch/hermes-agent/pull/33225))
|
||||
- Test slicing across GH actions jobs. (@ethernet8023) ([#30575](https://github.com/NousResearch/hermes-agent/pull/30575))
|
||||
- Discover agent-browser Chromium binary at boot. ([#33184](https://github.com/NousResearch/hermes-agent/pull/33184))
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API Server
|
||||
|
||||
- **Session control API** — `/api/sessions/*` (list/create/read/patch/delete/fork) + SSE-streaming chat. (salvages [#29302](https://github.com/NousResearch/hermes-agent/pull/29302) by @Codename-11 + multimodal followup by @Schwartz10) ([#33134](https://github.com/NousResearch/hermes-agent/pull/33134))
|
||||
- `GET /v1/skills` and `/v1/toolsets`. ([#33016](https://github.com/NousResearch/hermes-agent/pull/33016))
|
||||
- Coerce stringified booleans in stream/store/approval payloads. (salvage [#26639](https://github.com/NousResearch/hermes-agent/pull/26639)) ([#27293](https://github.com/NousResearch/hermes-agent/pull/27293))
|
||||
- Honor `key_env` in auth-failure fallback resolution. ([#30840](https://github.com/NousResearch/hermes-agent/pull/30840))
|
||||
|
||||
---
|
||||
|
||||
## 🎟️ ACP (VS Code / Zed / JetBrains)
|
||||
|
||||
- Session edit auto-approval modes. (salvage of [#27034](https://github.com/NousResearch/hermes-agent/pull/27034)) ([#27862](https://github.com/NousResearch/hermes-agent/pull/27862))
|
||||
- Enrich Zed permission cards — command in title + `reject_always`. ([#28148](https://github.com/NousResearch/hermes-agent/pull/28148))
|
||||
- Replay session history before responding to `session/load`. ([#26957](https://github.com/NousResearch/hermes-agent/pull/26957), [#26943](https://github.com/NousResearch/hermes-agent/pull/26943))
|
||||
- Plugin-transformed final_response delivered through streaming gate. ([#31433](https://github.com/NousResearch/hermes-agent/pull/31433))
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Plugin Surface
|
||||
|
||||
- `register_tts_provider()` plugin hook. (salvage of [#30420](https://github.com/NousResearch/hermes-agent/pull/30420)) ([#31745](https://github.com/NousResearch/hermes-agent/pull/31745))
|
||||
- `register_transcription_provider()` hook + `stt.providers` command-provider registry. (salvage of [#30493](https://github.com/NousResearch/hermes-agent/pull/30493)) ([#31907](https://github.com/NousResearch/hermes-agent/pull/31907))
|
||||
- `register_auxiliary_task()` in PluginContext API. (salvage [#29817](https://github.com/NousResearch/hermes-agent/pull/29817)) ([#31177](https://github.com/NousResearch/hermes-agent/pull/31177))
|
||||
- Bundled `security-guidance` plugin. ([#33131](https://github.com/NousResearch/hermes-agent/pull/33131))
|
||||
- Discord and Mattermost migrated to bundled plugins. ([#30591](https://github.com/NousResearch/hermes-agent/pull/30591), [#31748](https://github.com/NousResearch/hermes-agent/pull/31748))
|
||||
- ntfy as platform plugin. ([#30867](https://github.com/NousResearch/hermes-agent/pull/30867))
|
||||
- Surface category-namespaced plugins in `hermes plugins list`. ([#27187](https://github.com/NousResearch/hermes-agent/pull/27187))
|
||||
- Plugin discovery failures raised to WARNING level. ([#28318](https://github.com/NousResearch/hermes-agent/pull/28318))
|
||||
- `hermes_plugins` included in gateway.log component filter. ([#28313](https://github.com/NousResearch/hermes-agent/pull/28313))
|
||||
- Seed plugin extras before `is_connected` gate. ([#31703](https://github.com/NousResearch/hermes-agent/pull/31703))
|
||||
- Dashboard: allowlist plugin assets + denylist subprocess-influencing env vars. ([#32277](https://github.com/NousResearch/hermes-agent/pull/32277))
|
||||
|
||||
---
|
||||
|
||||
## 📦 Distribution & Install
|
||||
|
||||
- Install-method stamping + Docker detection. (@alt-glitch) ([#27843](https://github.com/NousResearch/hermes-agent/pull/27843))
|
||||
- Nix `#messaging` and `#full` package variants. (@alt-glitch) ([#33108](https://github.com/NousResearch/hermes-agent/pull/33108))
|
||||
- Pre-load messaging gateway deps via `--extra messaging`. (salvage [#26394](https://github.com/NousResearch/hermes-agent/pull/26394)) ([#27558](https://github.com/NousResearch/hermes-agent/pull/27558))
|
||||
- Avoid piping installer directly into `iex` (Windows). ([#28347](https://github.com/NousResearch/hermes-agent/pull/28347))
|
||||
- Ship bundled skills in wheel. ([#28421](https://github.com/NousResearch/hermes-agent/pull/28421))
|
||||
- Ship dashboard plugin assets in wheel. ([#28406](https://github.com/NousResearch/hermes-agent/pull/28406))
|
||||
- Make Camofox lazy-installed instead of eager. ([#27055](https://github.com/NousResearch/hermes-agent/pull/27055))
|
||||
- Wire STT lazy-install into transcription_tools.py. ([#30256](https://github.com/NousResearch/hermes-agent/pull/30256))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes (highlights only)
|
||||
|
||||
- Match bare custom provider by active base URL in `hermes model`. ([#28908](https://github.com/NousResearch/hermes-agent/pull/28908))
|
||||
- Route `auxiliary.vision.provider=openai` to api.openai.com, skip text-only main. ([#31452](https://github.com/NousResearch/hermes-agent/pull/31452))
|
||||
- Lint: skip per-file shell linter when LSP will handle the file. ([#29054](https://github.com/NousResearch/hermes-agent/pull/29054))
|
||||
- Treat empty credential pool entries as unauthenticated in `/model` picker. ([#28312](https://github.com/NousResearch/hermes-agent/pull/28312))
|
||||
- Reverted within window: Firecrawl integration tag, send_message @username auto-mentions, Telegram quick-command-only menus, Telegram pin-on-turn.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- Disarm lazy-install probe so `_HAS_FASTER_WHISPER` patches work. ([#30334](https://github.com/NousResearch/hermes-agent/pull/30334))
|
||||
- Cover default board dashboard pin. ([#28361](https://github.com/NousResearch/hermes-agent/pull/28361))
|
||||
- Cover `_task_dict` `task_age` fallback. ([#28365](https://github.com/NousResearch/hermes-agent/pull/28365))
|
||||
- Allowlist `tmp_path` for `kanban_notify` artifact delivery tests. ([#30851](https://github.com/NousResearch/hermes-agent/pull/30851), [#30852](https://github.com/NousResearch/hermes-agent/pull/30852))
|
||||
- Cover null output stream terminal events in Codex. ([#33137](https://github.com/NousResearch/hermes-agent/pull/33137))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **30-day docs overhaul** — full correctness audit, every PR in the window covered, Nous Portal weave, sidebar reorg. ([#33782](https://github.com/NousResearch/hermes-agent/pull/33782))
|
||||
- Dedicated Nous Portal integration page and setup guide. ([#31296](https://github.com/NousResearch/hermes-agent/pull/31296))
|
||||
- Providers: move Nous Portal first, Google Gemini OAuth last. ([#31287](https://github.com/NousResearch/hermes-agent/pull/31287))
|
||||
- `session_search` rewrite for single-shape tool. ([#27840](https://github.com/NousResearch/hermes-agent/pull/27840))
|
||||
- Kanban: document failure_limit, max_retries, inline create shortcuts, goals & kanban settings. ([#28357](https://github.com/NousResearch/hermes-agent/pull/28357), [#28358](https://github.com/NousResearch/hermes-agent/pull/28358), [#28359](https://github.com/NousResearch/hermes-agent/pull/28359), [#28360](https://github.com/NousResearch/hermes-agent/pull/28360), [#28362](https://github.com/NousResearch/hermes-agent/pull/28362))
|
||||
- Kanban Codex lane skill. ([#28430](https://github.com/NousResearch/hermes-agent/pull/28430))
|
||||
- xAI OAuth: note X Premium+ also unlocks Grok OAuth. ([#29055](https://github.com/NousResearch/hermes-agent/pull/29055))
|
||||
- Docs site: Docker audio bridge notes, "Installing more tools in the container", xurl auth HOME in Docker.
|
||||
- Email: clarify gateway vs Himalaya setup. (@helix4u) ([#33634](https://github.com/NousResearch/hermes-agent/pull/33634))
|
||||
- Auth docs: replace stale `hermes login` references with `hermes auth add`. ([#32859](https://github.com/NousResearch/hermes-agent/pull/32859))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- @teknium1 (lead)
|
||||
|
||||
### Notable salvages & cherry-picks
|
||||
|
||||
- **@benbarclay** — s6-overlay container supervision (29 commits salvaged), Node 22 LTS upgrade, build-essential cleanup, `gateway run` auto-redirect in s6, tee supervised stdout to docker logs, `hermes update` Docker guidance, build-time SHA stamping
|
||||
- **@OutThisLife** — `mouse_tracking` DEC mode presets
|
||||
- **@jquesnelle** — Windows installer hardening, `--branch` flag for `hermes update`, install.ps1 BOM strip / commit-pin
|
||||
- **@alt-glitch** — Windows `dep_ensure` bootstrap, Nix package variants (`.#messaging`, `.#full`), install-method stamping, ACP browser bootstrap consolidation
|
||||
- **@austinpickett** — `/update` slash command, dashboard checkboxes → `@nous-research/ui`, mobile dashboard polish, collapsible sidebar
|
||||
- **@ethernet8023** — CI test slicing across GH Actions jobs, TUI clipboard copy fix
|
||||
- **@kshitijk4poor** — doctor section banner + fail-and-issue helpers extraction, post-tag salvage cluster (curator-fallout, kanban SQLite hardening, install world-readable uv dirs, xAI bare-code paste)
|
||||
- **@rewbs** — Nous JWT inference switch + refresh-token replay fix
|
||||
- **@Codename-11** + **@Schwartz10** — session control API (REST + SSE + multimodal followup)
|
||||
- **@Niraven** — kanban swarm topology helper
|
||||
- **@Interstellar-code** — kanban worker visibility endpoints
|
||||
- **@adybag14-cyber** — termux cold-start optimizations (multiple PRs)
|
||||
- **@qike-ms** — Telegram in-place status edits design
|
||||
- **@sprmn24** — ntfy adapter
|
||||
- **@Jaaneek** — xAI Web Search provider plugin
|
||||
- **@yannsunn** — xAI upstream adapter for `hermes proxy`
|
||||
- **@Cybourgeoisie** — OpenRouter sticky routing via session_id
|
||||
- **@memosr** — Nous Portal base_url allowlist validation
|
||||
- **@Sunil123135** — Windows Docker Desktop compose file
|
||||
- **@Dusk1e** — Docker HOME alignment for dashboard + s6 gateway services
|
||||
- **@beardthelion** — opencode-go anthropic_messages routing
|
||||
- **@YLChen-007** — Skills Guard multi-word prompt patterns
|
||||
- **@roadhero** — env_passthrough GHSA-rhgp-j443-p4rf filter
|
||||
- **@Zyrixtrex** — Google Chat OAuth credential persistence hardening
|
||||
- **@briandevans**, **@tomqiaozc** — defense-in-depth read-deny on credential stores
|
||||
- **@PratikRai0101** — control-plane file write protection
|
||||
- **@helix4u**, **@Bartok9**, **@zccyman** — auxiliary fallback ladder components
|
||||
- **@ms-alan**, **@ticketclosed-wontfix**, **@donovan-yohan** — TUI session orchestrator + follow-ups
|
||||
- **@daimon-nous[bot]** — cron per-job profile support
|
||||
- **@bisko** — re-pad `reasoning_content` on cross-provider fallback
|
||||
|
||||
### All Contributors
|
||||
|
||||
@02356abc, @0xchainer, @0xDevNinja, @0xjackyang, @0xsir0000, @0z1-ghb, @8bit64k, @aaronlab, @AceWattGit,
|
||||
@ACR27, @adam91holt, @AdamPlatin123, @Ade5954, @AdityaRajeshGadgil, @adybag14-cyber, @AhmetArif0, @ai-hana-ai,
|
||||
@alaamohanad169-ship-it, @alber70g, @albert748, @alt-glitch, @aqilaziz, @argabor, @asdlem, @austinpickett,
|
||||
@avifenesh, @awizemann, @B0Tch1, @Bartok9, @BaxBit, @Beandon13, @beardthelion, @benbarclay, @bensargotest-sys,
|
||||
@binhnt92, @bird, @bisko, @BlackishGreen33, @booker1207, @bradhallett, @briandevans, @Brixyy, @brndnsvr,
|
||||
@BROCCOLO1D, @btorresgil, @burjorjee, @carltonawong, @Carry00, @chaconne67, @chdlc, @chromalinx, @ChyuWei,
|
||||
@CipherFrame, @cmullins70, @CNSeniorious000, @codeblackhole1024, @Codename-11, @colin-chang, @counterposition,
|
||||
@cresslank, @CryptoByz, @cyb0rgk1tty, @Cybourgeoisie, @daizhonggeng, @darvsum, @davidcampbelldc, @deas,
|
||||
@dgians, @dillweed, @DoGMaTiiC, @donovan-yohan, @draplater, @Drexuxux, @dskwe, @dsr-restyn, @Dusk1e,
|
||||
@dusterbloom, @duyua9, @egilewski, @el-analista, @eliteworkstation94-ai, @eloklam, @EloquentBrush0x, @emonty,
|
||||
@emozilla, @erhnysr, @erikengervall, @Erosika, @ether-btc, @ethernet8023, @EvilHumphrey, @fabiosiqueira,
|
||||
@falasi, @falconexe, @fardoche6, @felix-windsor, @Fewmanism, @ffr31mr, @flamiinngo, @flanny7, @flooryyyy,
|
||||
@fonhal, @francip, @fujinice, @gianfrancopiana, @glennc, @Glucksberg, @godlin-gh, @Grogger, @guillaumemeyer,
|
||||
@Gutslabs, @H-Ali13381, @hanzckernel, @haran2001, @hawknewton, @hayka-pacha, @hehehe0803, @helix4u, @HenkDz,
|
||||
@Hermes, @hermesagent26, @Hinotoi-agent, @hongchen1993, @honor2030, @houenyang-momo, @ht1072, @hueilau,
|
||||
@iamfoz, @ilonagaja509-glitch, @InB4DevOps, @indigokarasu, @Interstellar-code, @iqdoctor, @iRonin, @Jaaneek,
|
||||
@JabberELF, @jacevys, @jackey8616, @jackjin1997, @jdelmerico, @jfuenmayor, @Jiahui-Gu, @JimLiu, @joe102084,
|
||||
@JohnC1009, @jonpol01, @Jpalmer95, @Julientalbot, @justemu, @justincc, @jvinals, @karthikeyann, @kasunvinod,
|
||||
@kchuang1015, @kenyonxu, @khungate, @kiranvk-2011, @kjames2001, @konsisumer, @kpadilha, @kriscolab,
|
||||
@krislidimo, @kronexoi, @kshitijk4poor, @kunci115, @Kylejeong2, @kylekahraman, @LaPhilosophie, @leeseoki0,
|
||||
@lemassykoi, @Lempkey, @LeonJS, @LeonSGP43, @lidge-jun, @LifeJiggy, @liuhao1024, @LizerAIDev, @loicnico96,
|
||||
@loongfay, @m0n3r0, @malaiwah, @matthewlai, @mavrickdeveloper, @maxmilian, @McClean-Edison, @memosr,
|
||||
@Mind-Dragon, @momowind, @MoonJuhan, @MoonRay305, @moortekweb-art, @MorAlekss, @ms-alan, @Nami4D,
|
||||
@nehaaprasaad, @nekwo, @nftpoetrist, @NickLarcombe, @nidhi-singh02, @Niraven, @nnnet, @noctilust, @novax635,
|
||||
@nthrow, @nv-kasikritc, @nycomar, @OCWC22, @oemtalks, @OmX, @ooovenenoso, @orcool, @oseftg, @outsourc-e,
|
||||
@OutThisLife, @Paperclip, @PaTTeeL, @pepelax, @phoenixshen, @Pluviobyte, @pnascimento9596, @pochi-gio, @pr7426,
|
||||
@PratikRai0101, @Prithvi1994, @psionic73, @ptichalouf, @Que0x, @QuenVix, @quocanh261997, @qWaitCrypto, @Qwinty,
|
||||
@r266-tech, @rak135, @rdasilva1016-ui, @rewbs, @roadhero, @rodrigoeqnit, @RonHillDev, @roycepersonalassistant,
|
||||
@rudi193-cmd, @RyanRana, @sadiksaifi, @samahn0601, @samggggflynn, @SamuelZ12, @sanghyuk-seo-nexcube,
|
||||
@Saurav0989, @savanne-kham, @Schrotti77, @Schwartz10, @SerenityTn, @sgtworkman, @sharziki, @shaun0927,
|
||||
@shellybotmoyer, @shunsuke-hikiyama, @SimbaKingjoe, @SimoKiihamaki, @sir-ad, @Slimydog21, @slowtokki0409,
|
||||
@Soju06, @someaka, @soynchux, @sprmn24, @Stark-X, @steezkelly, @stepanov1975, @stephenschoettler,
|
||||
@stevehq26-bot, @steveonjava, @Strontvod, @subtract0, @Sunil123135, @superearn-fisher, @Sylw3ster, @tchanee,
|
||||
@that-ambuj, @thedavidmurray, @TheOnlyMika, @therahul-yo, @thewillhuang, @ticketclosed-wontfix, @Timur00Kh,
|
||||
@tomqiaozc, @Tosko4, @Tranquil-Flow, @tw2818, @uzunkuyruk, @vaddisrinivas, @vanthinh6886, @vgocoder,
|
||||
@victorGPT, @vynxevainglory-ai, @waefrebeorn, @walli, @wangpuv, @wanwan2qq, @wesleysimplicio, @worlldz,
|
||||
@wpengpeng168, @WuKongAI-CMU, @wuli666, @Wysie, @wysie, @xxxigm, @yannsunn, @YanzhongSu, @YarrowQiao, @ygd58,
|
||||
@YLChen-007, @yoniebans, @yu-xin-c, @YuanHanzhong, @zapabob, @zccyman, @ziliangpeng, @zwolniony, @Zyrixtrex
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.5.16...v2026.5.28](https://github.com/NousResearch/hermes-agent/compare/v2026.5.16...v2026.5.28)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Custom PEP 517 build backend for hermes-agent.
|
||||
|
||||
At wheel build time, rewrites [project.optional-dependencies] so that
|
||||
plugin extras (e.g. ``anthropic = ["hermes-agent-anthropic"]``) are
|
||||
inlined with the actual deps from each plugin's pyproject.toml.
|
||||
|
||||
In the source repo (and on Nix), uv resolves workspace members natively
|
||||
so this backend is NOT used — it's only invoked when building a wheel
|
||||
for PyPI publication.
|
||||
|
||||
Usage in pyproject.toml::
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "_build_backend"
|
||||
backend-path = ["."]
|
||||
|
||||
How it works:
|
||||
1. ``build_wheel`` intercepts the call before setuptools sees pyproject.toml.
|
||||
2. It reads the workspace member dirs from [tool.uv.workspace].members.
|
||||
3. For each member, it reads the member's pyproject.toml and extracts
|
||||
``project.dependencies`` (excluding the ``hermes-agent`` base dep).
|
||||
4. It rewrites the main pyproject.toml's optional-dependencies to inline
|
||||
those deps instead of the workspace member references.
|
||||
5. It writes a temporary pyproject.toml, delegates to
|
||||
``setuptools.build_meta.build_wheel``, then restores the original.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomllib
|
||||
|
||||
# The original setuptools backend we delegate to.
|
||||
_BACKEND = "setuptools.build_meta"
|
||||
|
||||
|
||||
def _load_pyproject(path: Path) -> dict:
|
||||
with path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def _save_pyproject(path: Path, data: dict) -> None:
|
||||
"""Write a pyproject.toml. Uses a simple serializer since we only
|
||||
need to preserve the structure enough for setuptools to parse."""
|
||||
import tomli_w
|
||||
with path.open("wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
|
||||
def _inline_plugin_deps(root: Path, data: dict) -> dict:
|
||||
"""Rewrite optional-dependencies to inline plugin deps.
|
||||
|
||||
Maps each plugin extra (e.g. ``anthropic = ["hermes-agent-anthropic"]``)
|
||||
to the actual deps from that plugin's pyproject.toml, minus the
|
||||
``hermes-agent`` base dependency.
|
||||
"""
|
||||
opt_deps = data.get("project", {}).get("optional-dependencies", {})
|
||||
workspace = data.get("tool", {}).get("uv", {}).get("workspace", {})
|
||||
members = workspace.get("members", [])
|
||||
|
||||
# Build a map: package name → (member_dir, pyproject_data)
|
||||
pkg_to_deps: dict[str, list[str]] = {}
|
||||
for member_glob in members:
|
||||
for member_dir in sorted(root.glob(member_glob)):
|
||||
pptoml = member_dir / "pyproject.toml"
|
||||
if not pptoml.exists():
|
||||
continue
|
||||
member_data = _load_pyproject(pptoml)
|
||||
pkg_name = member_data.get("project", {}).get("name", "")
|
||||
if not pkg_name:
|
||||
continue
|
||||
# Extract deps, excluding the base hermes-agent dependency
|
||||
raw_deps = member_data.get("project", {}).get("dependencies", [])
|
||||
filtered = [
|
||||
d for d in raw_deps
|
||||
if not d.replace(" ", "").startswith("hermes-agent")
|
||||
]
|
||||
pkg_to_deps[pkg_name] = filtered
|
||||
|
||||
# Rewrite optional-dependencies
|
||||
new_opt_deps = {}
|
||||
for extra_name, specs in opt_deps.items():
|
||||
new_specs = []
|
||||
for spec in specs:
|
||||
# Check if this spec references a workspace member package
|
||||
if spec in pkg_to_deps:
|
||||
# Inline the plugin's deps
|
||||
new_specs.extend(pkg_to_deps[spec])
|
||||
else:
|
||||
new_specs.append(spec)
|
||||
new_opt_deps[extra_name] = new_specs
|
||||
|
||||
data["project"]["optional-dependencies"] = new_opt_deps
|
||||
|
||||
# Remove [tool.uv] section — it's not valid in a published wheel
|
||||
if "uv" in data.get("tool", {}):
|
||||
del data["tool"]["uv"]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PEP 517 hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_wheel(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
|
||||
"""Build a wheel with inlined plugin deps."""
|
||||
root = Path.cwd()
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
|
||||
# Read and rewrite
|
||||
data = _load_pyproject(pyproject_path)
|
||||
data = _inline_plugin_deps(root, data)
|
||||
|
||||
# Write a temporary pyproject.toml, build, then restore
|
||||
backup = pyproject_path.with_suffix(".toml.bak")
|
||||
shutil.copy2(pyproject_path, backup)
|
||||
try:
|
||||
_save_pyproject(pyproject_path, data)
|
||||
|
||||
# Delegate to setuptools
|
||||
import importlib
|
||||
backend = importlib.import_module(_BACKEND)
|
||||
return backend.build_wheel(wheel_directory, config_settings)
|
||||
finally:
|
||||
shutil.copy2(backup, pyproject_path)
|
||||
backup.unlink()
|
||||
|
||||
|
||||
def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str:
|
||||
"""Build an sdist — no rewriting needed."""
|
||||
import importlib
|
||||
backend = importlib.import_module(_BACKEND)
|
||||
return backend.build_sdist(sdist_directory, config_settings)
|
||||
|
||||
|
||||
def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) -> list[str]:
|
||||
return ["setuptools>=61.0", "tomli_w"]
|
||||
|
||||
|
||||
def get_requires_for_build_sdist(config_settings: dict[str, Any] | None = None) -> list[str]:
|
||||
return ["setuptools>=61.0"]
|
||||
|
||||
|
||||
def prepare_metadata_for_build_wheel(metadata_directory: str, config_settings: dict[str, Any] | None = None) -> str:
|
||||
"""Prepare metadata with inlined plugin deps."""
|
||||
root = Path.cwd()
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
|
||||
data = _load_pyproject(pyproject_path)
|
||||
data = _inline_plugin_deps(root, data)
|
||||
|
||||
backup = pyproject_path.with_suffix(".toml.bak")
|
||||
shutil.copy2(pyproject_path, backup)
|
||||
try:
|
||||
_save_pyproject(pyproject_path, data)
|
||||
|
||||
import importlib
|
||||
backend = importlib.import_module(_BACKEND)
|
||||
return backend.prepare_metadata_for_build_wheel(metadata_directory, config_settings)
|
||||
finally:
|
||||
shutil.copy2(backup, pyproject_path)
|
||||
backup.unlink()
|
||||
|
||||
|
||||
def build_editable(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
|
||||
"""Build an editable install — no rewriting needed (dev mode)."""
|
||||
import importlib
|
||||
backend = importlib.import_module(_BACKEND)
|
||||
kwargs: dict[str, Any] = {"config_settings": config_settings}
|
||||
if metadata_directory is not None:
|
||||
kwargs["metadata_directory"] = metadata_directory
|
||||
return backend.build_editable(wheel_directory, **kwargs)
|
||||
|
||||
|
||||
def get_requires_for_build_editable(config_settings: dict[str, Any] | None = None) -> list[str]:
|
||||
return ["setuptools>=61.0"]
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.14.0",
|
||||
"package": "hermes-agent[acp]==0.15.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,5 @@ These modules contain pure utility functions and self-contained classes
|
||||
that were previously embedded in the 3,600-line run_agent.py. Extracting
|
||||
them makes run_agent.py focused on the AIAgent orchestrator class.
|
||||
"""
|
||||
|
||||
from . import jiter_preload as _jiter_preload # noqa: F401
|
||||
|
||||
@@ -6,7 +6,9 @@ from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
|
||||
from agent.plugin_registries import registries
|
||||
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
|
||||
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
|
||||
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
@@ -176,7 +178,7 @@ def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
|
||||
token = (resolve_anthropic_token() or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
if not _is_oauth_token(token):
|
||||
if _is_oauth_token is not None and not _is_oauth_token(token):
|
||||
return AccountUsageSnapshot(
|
||||
provider="anthropic",
|
||||
source="oauth_usage_api",
|
||||
|
||||
+37
-28
@@ -183,6 +183,7 @@ def init_agent(
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
user_id_alt: str = None,
|
||||
user_name: str = None,
|
||||
chat_id: str = None,
|
||||
chat_name: str = None,
|
||||
@@ -265,6 +266,7 @@ def init_agent(
|
||||
agent.ephemeral_system_prompt = ephemeral_system_prompt
|
||||
agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
|
||||
agent._user_id = user_id # Platform user identifier (gateway sessions)
|
||||
agent._user_id_alt = user_id_alt # Optional stable alternate platform identifier
|
||||
agent._user_name = user_name
|
||||
agent._chat_id = chat_id
|
||||
agent._chat_name = chat_name
|
||||
@@ -402,7 +404,7 @@ def init_agent(
|
||||
agent.status_callback = status_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
|
||||
# Tool execution state — allows _vprint during tool execution
|
||||
# even when stream consumers are registered (no tokens streaming then)
|
||||
agent._executing_tools = False
|
||||
@@ -435,12 +437,12 @@ def init_agent(
|
||||
# their tids explicitly.
|
||||
agent._tool_worker_threads: set[int] = set()
|
||||
agent._tool_worker_threads_lock = threading.Lock()
|
||||
|
||||
|
||||
# Subagent delegation state
|
||||
agent._delegate_depth = 0 # 0 = top-level agent, incremented for children
|
||||
agent._active_children = [] # Running child AIAgents (for interrupt propagation)
|
||||
agent._active_children_lock = threading.Lock()
|
||||
|
||||
|
||||
# Store OpenRouter provider preferences
|
||||
agent.providers_allowed = providers_allowed
|
||||
agent.providers_ignored = providers_ignored
|
||||
@@ -453,7 +455,7 @@ def init_agent(
|
||||
# Store toolset filtering options
|
||||
agent.enabled_toolsets = enabled_toolsets
|
||||
agent.disabled_toolsets = disabled_toolsets
|
||||
|
||||
|
||||
# Model response configuration
|
||||
agent.max_tokens = max_tokens # None = use model default
|
||||
agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter)
|
||||
@@ -461,7 +463,7 @@ def init_agent(
|
||||
agent.request_overrides = dict(request_overrides or {})
|
||||
agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns
|
||||
agent._force_ascii_payload = False
|
||||
|
||||
|
||||
# Anthropic prompt caching: auto-enabled for Claude models on native
|
||||
# Anthropic, OpenRouter, and third-party gateways that speak the
|
||||
# Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces
|
||||
@@ -533,7 +535,7 @@ def init_agent(
|
||||
# console. Any future noise reduction belongs at the
|
||||
# handler level inside hermes_logging.py, not here.
|
||||
pass
|
||||
|
||||
|
||||
# Internal stream callback (set during streaming TTS).
|
||||
# Initialized here so _vprint can reference it before run_conversation.
|
||||
agent._stream_callback = None
|
||||
@@ -583,12 +585,14 @@ def init_agent(
|
||||
_provider_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
|
||||
from agent.plugin_registries import registries
|
||||
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
|
||||
# Bedrock + Claude → use AnthropicBedrock SDK for full feature parity
|
||||
# (prompt caching, thinking budgets, adaptive thinking).
|
||||
_is_bedrock_anthropic = agent.provider == "bedrock"
|
||||
if _is_bedrock_anthropic:
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
build_anthropic_bedrock_client = registries.get_provider_service("anthropic", "build_anthropic_bedrock_client")
|
||||
_region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
|
||||
_br_region = _region_match.group(1) if _region_match else "us-east-1"
|
||||
agent._bedrock_region = _br_region
|
||||
@@ -642,8 +646,8 @@ def init_agent(
|
||||
# so injects Claude-Code identity headers and system prompts
|
||||
# that cause 401/403 on their endpoints. Guards #1739 and
|
||||
# the third-party identity-injection bug.
|
||||
from agent.anthropic_adapter import _is_oauth_token as _is_oat
|
||||
agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_oauth_token is not None and _is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
|
||||
# No OpenAI client needed for Anthropic mode
|
||||
agent.client = None
|
||||
@@ -655,9 +659,10 @@ def init_agent(
|
||||
# The Anthropic adapter installs an httpx event hook
|
||||
# that mints a fresh JWT per request — we never
|
||||
# invoke or inspect the callable in the banner.
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
from agent.plugin_registries import registries
|
||||
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
|
||||
|
||||
if is_token_provider(effective_key):
|
||||
if is_token_provider and is_token_provider(effective_key):
|
||||
print("🔑 Using credentials: Microsoft Entra ID")
|
||||
elif isinstance(effective_key, str) and len(effective_key) > 12:
|
||||
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
|
||||
@@ -867,10 +872,11 @@ def init_agent(
|
||||
# provider (Azure Foundry). The OpenAI SDK mints a
|
||||
# fresh JWT per request internally — the banner
|
||||
# never invokes or inspects the callable.
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
from agent.plugin_registries import registries
|
||||
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
|
||||
|
||||
key_used = client_kwargs.get("api_key", "none")
|
||||
if is_token_provider(key_used):
|
||||
if is_token_provider and is_token_provider(key_used):
|
||||
print("🔑 Using credentials: Microsoft Entra ID")
|
||||
elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12:
|
||||
print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}")
|
||||
@@ -878,7 +884,7 @@ def init_agent(
|
||||
print("⚠️ Warning: API key appears invalid or missing")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
||||
|
||||
|
||||
# Provider fallback chain — ordered list of backup providers tried
|
||||
# when the primary is exhausted (rate-limit, overload, connection
|
||||
# failure). Supports both legacy single-dict ``fallback_model`` and
|
||||
@@ -910,7 +916,7 @@ def init_agent(
|
||||
disabled_toolsets=disabled_toolsets,
|
||||
quiet_mode=agent.quiet_mode,
|
||||
)
|
||||
|
||||
|
||||
# Show tool configuration and store valid tool names for validation
|
||||
agent.valid_tool_names = set()
|
||||
if agent.tools:
|
||||
@@ -943,16 +949,16 @@ def init_agent(
|
||||
missing_reqs = [name for name, available in requirements.items() if not available]
|
||||
if missing_reqs:
|
||||
print(f"⚠️ Some tools may not work due to missing requirements: {missing_reqs}")
|
||||
|
||||
|
||||
# Show trajectory saving status
|
||||
if agent.save_trajectories and not agent.quiet_mode:
|
||||
print("📝 Trajectory saving enabled")
|
||||
|
||||
|
||||
# Show ephemeral system prompt status
|
||||
if agent.ephemeral_system_prompt and not agent.quiet_mode:
|
||||
prompt_preview = agent.ephemeral_system_prompt[:60] + "..." if len(agent.ephemeral_system_prompt) > 60 else agent.ephemeral_system_prompt
|
||||
print(f"🔒 Ephemeral system prompt: '{prompt_preview}' (not saved to trajectories)")
|
||||
|
||||
|
||||
# Show prompt caching status
|
||||
if agent._use_prompt_caching and not agent.quiet_mode:
|
||||
if agent._use_native_cache_layout and agent.provider == "anthropic":
|
||||
@@ -962,7 +968,7 @@ def init_agent(
|
||||
else:
|
||||
source = "Claude via OpenRouter"
|
||||
print(f"💾 Prompt caching: ENABLED ({source}, {agent._cache_ttl} TTL)")
|
||||
|
||||
|
||||
# Session logging setup - auto-save conversation trajectories for debugging
|
||||
agent.session_start = datetime.now()
|
||||
if session_id:
|
||||
@@ -1002,7 +1008,7 @@ def init_agent(
|
||||
pass
|
||||
# logs_dir is retained unconditionally for request_dump_*.json (debug
|
||||
# breadcrumb path written by agent_runtime_helpers.dump_api_request_debug).
|
||||
|
||||
|
||||
# Track conversation messages for session logging
|
||||
agent._session_messages: List[Dict[str, Any]] = []
|
||||
# Responses encrypted reasoning replay state. Some OpenAI-compatible
|
||||
@@ -1014,10 +1020,10 @@ def init_agent(
|
||||
agent._codex_reasoning_replay_enabled = True
|
||||
agent._memory_write_origin = "assistant_tool"
|
||||
agent._memory_write_context = "foreground"
|
||||
|
||||
|
||||
# Cached system prompt -- built once per session, only rebuilt on compression
|
||||
agent._cached_system_prompt: Optional[str] = None
|
||||
|
||||
|
||||
# Filesystem checkpoint manager (transparent — not a tool)
|
||||
from tools.checkpoint_manager import CheckpointManager
|
||||
agent._checkpoint_mgr = CheckpointManager(
|
||||
@@ -1026,7 +1032,7 @@ def init_agent(
|
||||
max_total_size_mb=checkpoint_max_total_size_mb,
|
||||
max_file_size_mb=checkpoint_max_file_size_mb,
|
||||
)
|
||||
|
||||
|
||||
# SQLite session store (optional -- provided by CLI or gateway)
|
||||
agent._session_db = session_db
|
||||
agent._parent_session_id = parent_session_id
|
||||
@@ -1037,11 +1043,11 @@ def init_agent(
|
||||
"reasoning_config": reasoning_config,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
|
||||
# In-memory todo list for task planning (one per agent/session)
|
||||
from tools.todo_tool import TodoStore
|
||||
agent._todo_store = TodoStore()
|
||||
|
||||
|
||||
# Load config once for memory, skills, and compression sections
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_agent_config
|
||||
@@ -1083,7 +1089,7 @@ def init_agent(
|
||||
agent._memory_store.load_from_disk()
|
||||
except Exception:
|
||||
pass # Memory is optional -- don't break agent init
|
||||
|
||||
|
||||
|
||||
|
||||
# Memory provider plugin (external — one at a time, alongside built-in)
|
||||
@@ -1119,6 +1125,8 @@ def init_agent(
|
||||
# Thread gateway user identity for per-user memory scoping
|
||||
if agent._user_id:
|
||||
_init_kwargs["user_id"] = agent._user_id
|
||||
if agent._user_id_alt:
|
||||
_init_kwargs["user_id_alt"] = agent._user_id_alt
|
||||
if agent._user_name:
|
||||
_init_kwargs["user_name"] = agent._user_name
|
||||
if agent._chat_id:
|
||||
@@ -1518,6 +1526,7 @@ def init_agent(
|
||||
platform=agent.platform or "cli",
|
||||
model=agent.model,
|
||||
context_length=getattr(agent.context_compressor, "context_length", 0),
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
_ra().logger.debug("Context engine on_session_start: %s", _ce_err)
|
||||
@@ -1540,7 +1549,7 @@ def init_agent(
|
||||
agent.session_estimated_cost_usd = 0.0
|
||||
agent.session_cost_status = "unknown"
|
||||
agent.session_cost_source = "none"
|
||||
|
||||
|
||||
# ── Ollama num_ctx injection ──
|
||||
# Ollama defaults to 2048 context regardless of the model's capabilities.
|
||||
# When running against an Ollama server, detect the model's max context
|
||||
|
||||
+171
-74
@@ -560,6 +560,24 @@ def recover_with_credential_pool(
|
||||
if pool is None:
|
||||
return False, has_retried_429
|
||||
|
||||
# Defensive guard: if a fallback provider is active and its provider name
|
||||
# doesn't match the pool's provider, the pool belongs to the PRIMARY
|
||||
# provider. Mutating it based on fallback errors would corrupt the
|
||||
# primary's credential state (see #33088) and, via _swap_credential,
|
||||
# overwrite the agent's base_url back to the primary's endpoint — every
|
||||
# subsequent request then goes to the wrong host and 404s (see #33163).
|
||||
# The pool should only act when the agent is still on the same provider
|
||||
# that seeded the pool.
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
if status_code == 402:
|
||||
@@ -748,7 +766,8 @@ def try_recover_primary_transport(
|
||||
agent.api_key = rt["api_key"]
|
||||
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
from agent.plugin_registries import registries
|
||||
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
agent._anthropic_api_key = rt["anthropic_api_key"]
|
||||
agent._anthropic_base_url = rt["anthropic_base_url"]
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
@@ -912,7 +931,8 @@ def restore_primary_runtime(agent) -> bool:
|
||||
|
||||
# ── Rebuild client for the primary provider ──
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
from agent.plugin_registries import registries
|
||||
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
agent._anthropic_api_key = rt["anthropic_api_key"]
|
||||
agent._anthropic_base_url = rt["anthropic_base_url"]
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
@@ -1361,81 +1381,128 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
old_model = agent.model
|
||||
old_provider = agent.provider
|
||||
|
||||
# Clear the per-config context_length override so the new model's
|
||||
# actual context window is resolved via get_model_context_length()
|
||||
# instead of inheriting the stale value from the previous model.
|
||||
agent._config_context_length = None
|
||||
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
agent._transport_cache.clear()
|
||||
if api_key:
|
||||
agent.api_key = api_key
|
||||
|
||||
# ── Build new client ──
|
||||
if api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
_is_oauth_token,
|
||||
# ── Snapshot all fields the swap+rebuild can mutate ──
|
||||
# If the rebuild raises (bad API key, network error, build_anthropic_client
|
||||
# failure, etc.) we restore these atomically so the agent isn't left with a
|
||||
# new model/provider name paired with the OLD client — that mismatch causes
|
||||
# HTTP 400s like "claude-sonnet-4-6 is not supported on openai-codex" on the
|
||||
# next turn. Callers in cli.py / gateway/run.py / tui_gateway/server.py
|
||||
# catch the re-raised exception and show the user a warning; without this
|
||||
# rollback the warning is misleading because the swap partially succeeded.
|
||||
# Use a sentinel so we can distinguish "attribute was unset" from
|
||||
# "attribute was None" and skip the restore for genuinely-missing
|
||||
# attributes (tests construct bare agents via __new__ without all fields).
|
||||
_MISSING = object()
|
||||
_snapshot = {
|
||||
name: getattr(agent, name, _MISSING)
|
||||
for name in (
|
||||
"model",
|
||||
"provider",
|
||||
"base_url",
|
||||
"api_mode",
|
||||
"api_key",
|
||||
"client",
|
||||
"_anthropic_client",
|
||||
"_anthropic_api_key",
|
||||
"_anthropic_base_url",
|
||||
"_is_anthropic_oauth",
|
||||
"_config_context_length",
|
||||
)
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
|
||||
# API key — falling back would send Anthropic credentials to third-party endpoints.
|
||||
_is_native_anthropic = new_provider == "anthropic"
|
||||
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
|
||||
}
|
||||
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
|
||||
# live dict doesn't poison the rollback target.
|
||||
_snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {})
|
||||
|
||||
# MiniMax OAuth: swap static string for a per-request callable token
|
||||
# provider so the rebuilt client survives 15-min token expiry. See
|
||||
# the matching block in agent_init.py for the full rationale.
|
||||
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
# Clear the per-config context_length override so the new model's
|
||||
# actual context window is resolved via get_model_context_length()
|
||||
# instead of inheriting the stale value from the previous model.
|
||||
agent._config_context_length = None
|
||||
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
agent._transport_cache.clear()
|
||||
if api_key:
|
||||
agent.api_key = api_key
|
||||
|
||||
# ── Build new client ──
|
||||
if api_mode == "anthropic_messages":
|
||||
from agent.plugin_registries import registries
|
||||
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
|
||||
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
|
||||
# API key — falling back would send Anthropic credentials to third-party endpoints.
|
||||
_is_native_anthropic = new_provider == "anthropic"
|
||||
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
|
||||
|
||||
# MiniMax OAuth: swap static string for a per-request callable token
|
||||
# provider so the rebuilt client survives 15-min token expiry. See
|
||||
# the matching block in agent_init.py for the full rationale.
|
||||
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url,
|
||||
timeout=get_provider_request_timeout(agent.provider, agent.model),
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
effective_key = api_key or agent.api_key
|
||||
effective_base = base_url or agent.base_url
|
||||
agent._client_kwargs = {
|
||||
"api_key": effective_key,
|
||||
"base_url": effective_base,
|
||||
}
|
||||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
shared=True,
|
||||
)
|
||||
except Exception:
|
||||
# Rollback every mutated field to the pre-swap snapshot so the agent
|
||||
# is left consistent (old model + old provider + old client) and the
|
||||
# caller's exception handler can surface a meaningful warning. The
|
||||
# exception is re-raised; cli.py / gateway/run.py / tui_gateway catch
|
||||
# it and print "Agent swap failed; change applied to next session".
|
||||
for _name, _value in _snapshot.items():
|
||||
if _value is _MISSING:
|
||||
# Attribute did not exist before the swap — don't fabricate it.
|
||||
continue
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url,
|
||||
timeout=get_provider_request_timeout(agent.provider, agent.model),
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
effective_key = api_key or agent.api_key
|
||||
effective_base = base_url or agent.base_url
|
||||
agent._client_kwargs = {
|
||||
"api_key": effective_key,
|
||||
"base_url": effective_base,
|
||||
}
|
||||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
shared=True,
|
||||
)
|
||||
setattr(agent, _name, _value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
raise
|
||||
|
||||
# ── Re-evaluate prompt caching ──
|
||||
agent._use_prompt_caching, agent._use_native_cache_layout = (
|
||||
@@ -1928,6 +1995,36 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
|
||||
api_msg.pop("reasoning_content", None)
|
||||
|
||||
|
||||
def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
|
||||
"""Re-pad assistant turns with reasoning_content for the active provider.
|
||||
|
||||
``api_messages`` is built once, before the retry loop, while the *primary*
|
||||
provider is active. If a mid-conversation fallback then switches to a
|
||||
require-side provider (DeepSeek / Kimi / MiMo thinking mode), assistant
|
||||
turns that were built when the prior provider did NOT need the echo-back go
|
||||
out without ``reasoning_content`` and the new provider rejects them with
|
||||
HTTP 400 ("The reasoning_content in the thinking mode must be passed back").
|
||||
|
||||
Calling this immediately before building the request kwargs re-applies the
|
||||
pad against the *current* provider. It is idempotent and a no-op unless
|
||||
``_needs_thinking_reasoning_pad()`` is True for the active provider, so it
|
||||
is safe to call every iteration and covers every fallback path.
|
||||
|
||||
Returns the number of assistant turns that gained reasoning_content.
|
||||
"""
|
||||
if not agent._needs_thinking_reasoning_pad():
|
||||
return 0
|
||||
padded = 0
|
||||
for api_msg in api_messages:
|
||||
if api_msg.get("role") != "assistant":
|
||||
continue
|
||||
if api_msg.get("reasoning_content"):
|
||||
continue
|
||||
copy_reasoning_content_for_api(agent, api_msg, api_msg)
|
||||
if api_msg.get("reasoning_content"):
|
||||
padded += 1
|
||||
return padded
|
||||
|
||||
|
||||
def _iter_pool_sockets(client: Any):
|
||||
"""Yield raw sockets reachable from an OpenAI/httpx client pool.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Anthropic auxiliary client wrappers — core module, no SDK dependency.
|
||||
|
||||
Provides OpenAI-client-compatible shims over native Anthropic SDK clients,
|
||||
so auxiliary tasks (compression, vision, web extract, etc.) can call
|
||||
``client.chat.completions.create()`` regardless of the underlying SDK.
|
||||
|
||||
The wrapper classes themselves never import the anthropic SDK. They delegate
|
||||
wire-format conversion to :mod:`agent.anthropic_format` and response
|
||||
normalization to the ``anthropic_messages`` transport registered in
|
||||
:mod:`agent.transports`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.anthropic_format import (
|
||||
build_anthropic_kwargs,
|
||||
_forbids_sampling_params,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter: Anthropic SDK → OpenAI-compatible completions.create()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _AnthropicCompletionsAdapter:
|
||||
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
|
||||
self._client = real_client
|
||||
self._model = model
|
||||
self._is_oauth = is_oauth
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.transports import get_transport
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
tools = kwargs.get("tools")
|
||||
tool_choice = kwargs.get("tool_choice")
|
||||
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
|
||||
# models (glm-4v-flash etc.) with error code 1210. When the caller
|
||||
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
|
||||
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
|
||||
if _skip_mt:
|
||||
max_tokens = None
|
||||
else:
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
|
||||
temperature = kwargs.get("temperature")
|
||||
|
||||
normalized_tool_choice = None
|
||||
if isinstance(tool_choice, str):
|
||||
normalized_tool_choice = tool_choice
|
||||
elif isinstance(tool_choice, dict):
|
||||
choice_type = str(tool_choice.get("type", "")).lower()
|
||||
if choice_type == "function":
|
||||
normalized_tool_choice = tool_choice.get("function", {}).get("name")
|
||||
elif choice_type in {"auto", "required", "none"}:
|
||||
normalized_tool_choice = choice_type
|
||||
|
||||
anthropic_kwargs = build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=None,
|
||||
tool_choice=normalized_tool_choice,
|
||||
is_oauth=self._is_oauth,
|
||||
)
|
||||
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
|
||||
# temperature for models that still accept it. build_anthropic_kwargs
|
||||
# additionally strips these keys as a safety net — keep both layers.
|
||||
if temperature is not None:
|
||||
if not _forbids_sampling_params(model):
|
||||
anthropic_kwargs["temperature"] = temperature
|
||||
|
||||
response = self._client.messages.create(**anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_oauth
|
||||
)
|
||||
|
||||
assistant_message = SimpleNamespace(
|
||||
content=_nr.content,
|
||||
tool_calls=_nr.tool_calls,
|
||||
reasoning=_nr.reasoning,
|
||||
)
|
||||
finish_reason = _nr.finish_reason
|
||||
|
||||
usage = None
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
|
||||
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
|
||||
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=assistant_message,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
choices=[choice],
|
||||
model=model,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
class _AnthropicChatShim:
|
||||
def __init__(self, adapter: _AnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AnthropicAuxiliaryClient:
|
||||
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
|
||||
self._real_client = real_client
|
||||
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
|
||||
self.chat = _AnthropicChatShim(adapter)
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
def close(self):
|
||||
close_fn = getattr(self._real_client, "close", None)
|
||||
if callable(close_fn):
|
||||
close_fn()
|
||||
|
||||
|
||||
class _AsyncAnthropicCompletionsAdapter:
|
||||
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
|
||||
self._sync = sync_adapter
|
||||
|
||||
async def create(self, **kwargs) -> Any:
|
||||
return await asyncio.to_thread(self._sync.create, **kwargs)
|
||||
|
||||
|
||||
class _AsyncAnthropicChatShim:
|
||||
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AsyncAnthropicAuxiliaryClient:
|
||||
def __init__(self, sync_wrapper: AnthropicAuxiliaryClient):
|
||||
sync_adapter = sync_wrapper.chat.completions
|
||||
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
|
||||
self.chat = _AsyncAnthropicChatShim(async_adapter)
|
||||
self.api_key = sync_wrapper.api_key
|
||||
self.base_url = sync_wrapper.base_url
|
||||
# Mirror _real_client so cache eviction on a poisoned underlying
|
||||
# client also drops this entry.
|
||||
self._real_client = sync_wrapper._real_client
|
||||
File diff suppressed because it is too large
Load Diff
+206
-451
@@ -106,6 +106,41 @@ from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core anthropic wire-format modules (no SDK dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from agent.anthropic_aux import ( # noqa: F401
|
||||
AnthropicAuxiliaryClient,
|
||||
AsyncAnthropicAuxiliaryClient,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin-registry helper — access *plugin-provided* anthropic services
|
||||
# (resolve.py functions: maybe_wrap_anthropic, is_anthropic_compat_endpoint, etc.)
|
||||
# Wire-format code (message conversion, aux client wrappers) lives in core
|
||||
# and is imported directly above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _anthropic_plugin_service(name: str):
|
||||
"""Lazy accessor for anthropic plugin resolve services.
|
||||
|
||||
Only the SDK-dependent orchestration (maybe_wrap_anthropic,
|
||||
is_anthropic_compat_endpoint, convert_openai_images_to_anthropic) lives
|
||||
in the plugin. Core accesses it through
|
||||
``registries.get_provider_service("anthropic", name)`` so that:
|
||||
- Core never imports from a plugin package directly.
|
||||
- The plugin need only be installed when the user actually uses it.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
svc = registries.get_provider_service("anthropic", name)
|
||||
if svc is None:
|
||||
raise ImportError(
|
||||
f"anthropic plugin service {name!r} not available — "
|
||||
f"the hermes_agent_anthropic package may not be installed"
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
|
||||
"""Return False instead of raising when a patched symbol is not a type."""
|
||||
@@ -417,7 +452,6 @@ auxiliary_is_nous: bool = False
|
||||
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
|
||||
_NOUS_MODEL = "google/gemini-3-flash-preview"
|
||||
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
|
||||
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
|
||||
|
||||
# Codex OAuth endpoint used when a caller explicitly requests
|
||||
@@ -828,7 +862,7 @@ class _CodexCompletionsAdapter:
|
||||
val = obj.get(key, default)
|
||||
return val if val is not None else default
|
||||
|
||||
for item in getattr(final, "output", []):
|
||||
for item in (getattr(final, "output", None) or []):
|
||||
item_type = _item_get(item, "type")
|
||||
if item_type == "message":
|
||||
for part in (_item_get(item, "content") or []):
|
||||
@@ -948,253 +982,6 @@ class AsyncCodexAuxiliaryClient:
|
||||
self._real_client = sync_wrapper._real_client
|
||||
|
||||
|
||||
class _AnthropicCompletionsAdapter:
|
||||
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
|
||||
self._client = real_client
|
||||
self._model = model
|
||||
self._is_oauth = is_oauth
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
from agent.transports import get_transport
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
tools = kwargs.get("tools")
|
||||
tool_choice = kwargs.get("tool_choice")
|
||||
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
|
||||
# models (glm-4v-flash etc.) with error code 1210. When the caller
|
||||
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
|
||||
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
|
||||
if _skip_mt:
|
||||
max_tokens = None
|
||||
else:
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
|
||||
temperature = kwargs.get("temperature")
|
||||
|
||||
normalized_tool_choice = None
|
||||
if isinstance(tool_choice, str):
|
||||
normalized_tool_choice = tool_choice
|
||||
elif isinstance(tool_choice, dict):
|
||||
choice_type = str(tool_choice.get("type", "")).lower()
|
||||
if choice_type == "function":
|
||||
normalized_tool_choice = tool_choice.get("function", {}).get("name")
|
||||
elif choice_type in {"auto", "required", "none"}:
|
||||
normalized_tool_choice = choice_type
|
||||
|
||||
anthropic_kwargs = build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=None,
|
||||
tool_choice=normalized_tool_choice,
|
||||
is_oauth=self._is_oauth,
|
||||
)
|
||||
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
|
||||
# temperature for models that still accept it. build_anthropic_kwargs
|
||||
# additionally strips these keys as a safety net — keep both layers.
|
||||
if temperature is not None:
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
if not _forbids_sampling_params(model):
|
||||
anthropic_kwargs["temperature"] = temperature
|
||||
|
||||
response = self._client.messages.create(**anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_oauth
|
||||
)
|
||||
|
||||
# ToolCall already duck-types as OpenAI shape (.type, .function.name,
|
||||
# .function.arguments) via properties, so no wrapping needed.
|
||||
assistant_message = SimpleNamespace(
|
||||
content=_nr.content,
|
||||
tool_calls=_nr.tool_calls,
|
||||
reasoning=_nr.reasoning,
|
||||
)
|
||||
finish_reason = _nr.finish_reason
|
||||
|
||||
usage = None
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
|
||||
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
|
||||
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=assistant_message,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
choices=[choice],
|
||||
model=model,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
class _AnthropicChatShim:
|
||||
def __init__(self, adapter: _AnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AnthropicAuxiliaryClient:
|
||||
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
|
||||
|
||||
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
|
||||
self._real_client = real_client
|
||||
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
|
||||
self.chat = _AnthropicChatShim(adapter)
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
def close(self):
|
||||
close_fn = getattr(self._real_client, "close", None)
|
||||
if callable(close_fn):
|
||||
close_fn()
|
||||
|
||||
|
||||
class _AsyncAnthropicCompletionsAdapter:
|
||||
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
|
||||
self._sync = sync_adapter
|
||||
|
||||
async def create(self, **kwargs) -> Any:
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._sync.create, **kwargs)
|
||||
|
||||
|
||||
class _AsyncAnthropicChatShim:
|
||||
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AsyncAnthropicAuxiliaryClient:
|
||||
def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"):
|
||||
sync_adapter = sync_wrapper.chat.completions
|
||||
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
|
||||
self.chat = _AsyncAnthropicChatShim(async_adapter)
|
||||
self.api_key = sync_wrapper.api_key
|
||||
self.base_url = sync_wrapper.base_url
|
||||
# See AsyncCodexAuxiliaryClient: mirror _real_client so cache
|
||||
# eviction on a poisoned underlying client also drops this entry.
|
||||
self._real_client = sync_wrapper._real_client
|
||||
|
||||
|
||||
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
|
||||
protocol instead of OpenAI chat.completions.
|
||||
|
||||
Mirrors ``hermes_cli.runtime_provider._detect_api_mode_for_url`` so the
|
||||
auxiliary client and the main agent stay in sync on transport selection.
|
||||
Covers:
|
||||
|
||||
- Any URL ending in ``/anthropic`` (MiniMax, Zhipu GLM, LiteLLM proxies,
|
||||
Anthropic-compatible gateways).
|
||||
- ``api.kimi.com/coding`` (Kimi Coding Plan — the /coding route only
|
||||
speaks Claude-Code's native Anthropic shape; ``chat.completions``
|
||||
returns 404 on Anthropic-only model aliases like ``kimi-for-coding``).
|
||||
- ``api.anthropic.com`` (native Anthropic).
|
||||
"""
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.endswith("/anthropic"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
return True
|
||||
if hostname == "api.kimi.com" and "/coding" in normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _maybe_wrap_anthropic(
|
||||
client_obj: Any,
|
||||
model: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
api_mode: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
|
||||
the endpoint actually speaks Anthropic Messages.
|
||||
|
||||
This is the single chokepoint for aux-client transport correction.
|
||||
Runs at the end of every ``resolve_provider_client`` branch so that
|
||||
api_key providers (Kimi Coding Plan), the ``custom`` endpoint, and
|
||||
future /anthropic gateways all land on the right wire format
|
||||
regardless of which branch built the client.
|
||||
|
||||
Returns ``client_obj`` unchanged when:
|
||||
|
||||
- It's already an Anthropic/Codex/Gemini/CopilotACP wrapper.
|
||||
- The endpoint is an OpenAI-wire endpoint.
|
||||
- ``api_mode`` is explicitly set to a non-Anthropic transport.
|
||||
- The ``anthropic`` SDK is not installed (falls back to OpenAI wire).
|
||||
"""
|
||||
# Already wrapped — don't double-wrap.
|
||||
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
# Other specialized adapters we should never re-dispatch.
|
||||
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
if _safe_isinstance(client_obj, GeminiNativeClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
if _safe_isinstance(client_obj, CopilotACPClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Explicit non-anthropic api_mode wins over URL heuristics.
|
||||
if api_mode and api_mode != "anthropic_messages":
|
||||
return client_obj
|
||||
|
||||
should_wrap = (
|
||||
api_mode == "anthropic_messages"
|
||||
or _endpoint_speaks_anthropic_messages(base_url)
|
||||
)
|
||||
if not should_wrap:
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
|
||||
"not installed — falling back to OpenAI-wire (will likely 404).",
|
||||
base_url,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
real_client = build_anthropic_client(api_key, base_url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to build Anthropic client for %s (%s) — falling back to "
|
||||
"OpenAI-wire client.", base_url, exc,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
logger.debug(
|
||||
"Auxiliary transport: wrapping client in AnthropicAuxiliaryClient "
|
||||
"(model=%s, base_url=%s, api_mode=%s)",
|
||||
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
|
||||
)
|
||||
return AnthropicAuxiliaryClient(
|
||||
real_client, model, api_key, base_url, is_oauth=False,
|
||||
)
|
||||
|
||||
|
||||
def _read_nous_auth() -> Optional[dict]:
|
||||
"""Read and validate ~/.hermes/auth.json for an active Nous provider.
|
||||
@@ -1405,7 +1192,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
continue
|
||||
except ImportError:
|
||||
pass
|
||||
return _try_anthropic()
|
||||
# Delegate to the anthropic plugin resolver via the registry
|
||||
from agent.plugin_registries import registries as _ar
|
||||
_anthro_resolver = _ar.get_provider_resolver("anthropic")
|
||||
if _anthro_resolver is not None:
|
||||
_ac, _am = _anthro_resolver()
|
||||
if _ac is not None:
|
||||
return _ac, _am
|
||||
continue
|
||||
|
||||
pool_present, entry = _select_pool_entry(provider_id)
|
||||
if pool_present:
|
||||
@@ -1442,7 +1236,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
except Exception:
|
||||
pass
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider_id)
|
||||
@@ -1479,7 +1273,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
except Exception:
|
||||
pass
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
return None, None
|
||||
@@ -1488,7 +1282,6 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
# ── Provider resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
pool_present, entry = _select_pool_entry("openrouter")
|
||||
if pool_present:
|
||||
@@ -1810,7 +1603,11 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
# LiteLLM proxies, etc.). Must NEVER be treated as OAuth —
|
||||
# Anthropic OAuth claims only apply to api.anthropic.com.
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
build_anthropic_client = _anthropic.get("build_anthropic_client")
|
||||
if build_anthropic_client is None:
|
||||
raise ImportError("anthropic provider not registered")
|
||||
real_client = build_anthropic_client(custom_key, custom_base)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
@@ -1825,7 +1622,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
# URL-based anthropic detection for custom endpoints that didn't set
|
||||
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
|
||||
_fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
_fallback_client = _maybe_wrap_anthropic(
|
||||
_fallback_client = _anthropic_plugin_service("maybe_wrap_anthropic")(
|
||||
_fallback_client, model, custom_key, custom_base, custom_mode,
|
||||
)
|
||||
return _fallback_client, model
|
||||
@@ -2003,7 +1800,7 @@ def _try_azure_foundry(
|
||||
# for Entra ID it's a callable. ``_maybe_wrap_anthropic`` →
|
||||
# ``build_anthropic_client`` detects the callable and installs
|
||||
# the bearer-injecting httpx hook.
|
||||
return _maybe_wrap_anthropic(
|
||||
return _anthropic_plugin_service("maybe_wrap_anthropic")(
|
||||
client, final_model, api_key,
|
||||
base_url, runtime_api_mode,
|
||||
), final_model
|
||||
@@ -2012,54 +1809,6 @@ def _try_azure_foundry(
|
||||
return client, final_model
|
||||
|
||||
|
||||
def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]:
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
|
||||
except ImportError:
|
||||
return None, None
|
||||
|
||||
pool_present, entry = _select_pool_entry("anthropic")
|
||||
if pool_present:
|
||||
if entry is None:
|
||||
return None, None
|
||||
token = explicit_api_key or _pool_runtime_api_key(entry)
|
||||
else:
|
||||
entry = None
|
||||
token = explicit_api_key or resolve_anthropic_token()
|
||||
if not token:
|
||||
return None, None
|
||||
|
||||
# Allow base URL override from config.yaml model.base_url, but only
|
||||
# when the configured provider is anthropic — otherwise a non-Anthropic
|
||||
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
|
||||
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if cfg_provider == "anthropic":
|
||||
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if cfg_base_url:
|
||||
base_url = cfg_base_url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
is_oauth = _is_oauth_token(token)
|
||||
model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
|
||||
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
|
||||
try:
|
||||
real_client = build_anthropic_client(token, base_url)
|
||||
except ImportError:
|
||||
# The anthropic_adapter module imports fine but the SDK itself is
|
||||
# missing — build_anthropic_client raises ImportError at call time
|
||||
# when _anthropic_sdk is None. Treat as unavailable.
|
||||
return None, None
|
||||
return AnthropicAuxiliaryClient(real_client, model, token, base_url, is_oauth=is_oauth), model
|
||||
|
||||
|
||||
_AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
@@ -2244,11 +1993,15 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
# but sometimes wrap them in 429 or other codes.
|
||||
# Daily quota exhaustion from Bedrock, Vertex AI, and similar providers
|
||||
# uses different language but is semantically identical to credit exhaustion.
|
||||
if status in {402, 429, None}:
|
||||
if status in {402, 404, 429, None}:
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds",
|
||||
"can only afford", "billing",
|
||||
"payment required",
|
||||
"out of funds", "run out of funds",
|
||||
"balance_depleted", "no usable credits",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
# Daily / monthly / weekly quota exhaustion keywords
|
||||
"quota exceeded", "quota_exceeded",
|
||||
"too many tokens per day", "daily limit",
|
||||
@@ -2260,6 +2013,18 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _nous_portal_account_has_fresh_paid_access() -> bool:
|
||||
"""Return True only when the fresh Nous account API says paid access is allowed."""
|
||||
try:
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
return account_info.paid_service_access is True
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary Nous paid-entitlement refresh check failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _is_rate_limit_error(exc: Exception) -> bool:
|
||||
"""Detect rate-limit errors that warrant provider fallback.
|
||||
|
||||
@@ -2288,6 +2053,10 @@ def _is_rate_limit_error(exc: Exception) -> bool:
|
||||
if not any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing",
|
||||
"payment required", "can only afford",
|
||||
"out of funds", "run out of funds",
|
||||
"balance_depleted", "no usable credits",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
)):
|
||||
return True
|
||||
return False
|
||||
@@ -2609,8 +2378,8 @@ def _retry_same_provider_sync(
|
||||
extra_body=effective_extra_body,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
)
|
||||
@@ -2666,8 +2435,8 @@ async def _retry_same_provider_async(
|
||||
extra_body=effective_extra_body,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
)
|
||||
@@ -2701,12 +2470,19 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "anthropic":
|
||||
from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
|
||||
_refresh_oauth_token = _anthropic.get("_refresh_oauth_token")
|
||||
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
|
||||
if read_claude_code_credentials is None:
|
||||
return False
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") else None
|
||||
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") and _refresh_oauth_token else None
|
||||
if not str(token or "").strip():
|
||||
token = resolve_anthropic_token()
|
||||
if resolve_anthropic_token is not None:
|
||||
token = resolve_anthropic_token()
|
||||
if not str(token or "").strip():
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
@@ -3027,7 +2803,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
|
||||
if isinstance(sync_client, CodexAuxiliaryClient):
|
||||
return AsyncCodexAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
if _safe_isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
return AsyncAnthropicAuxiliaryClient(sync_client), model
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
|
||||
@@ -3213,7 +2989,7 @@ def resolve_provider_client(
|
||||
return CodexAuxiliaryClient(client_obj, final_model_str)
|
||||
# Anthropic-wire endpoints: rewrap plain OpenAI clients so
|
||||
# chat.completions.create() is translated to /v1/messages.
|
||||
return _maybe_wrap_anthropic(
|
||||
return _anthropic_plugin_service("maybe_wrap_anthropic")(
|
||||
client_obj, final_model_str, api_key_str, base_url_str, api_mode,
|
||||
)
|
||||
|
||||
@@ -3445,7 +3221,11 @@ def resolve_provider_client(
|
||||
# branch in _try_custom_endpoint(). See #15033.
|
||||
if entry_api_mode == "anthropic_messages":
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
build_anthropic_client = _anthropic.get("build_anthropic_client")
|
||||
if build_anthropic_client is None:
|
||||
raise ImportError("anthropic provider not registered")
|
||||
real_client = build_anthropic_client(custom_key, custom_base)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
@@ -3488,39 +3268,32 @@ def resolve_provider_client(
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── Azure Foundry (delegates to runtime resolver for auth_mode-aware routing) ─
|
||||
#
|
||||
# The generic PROVIDER_REGISTRY path below uses
|
||||
# ``resolve_api_key_provider_credentials`` which only knows about the
|
||||
# static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important
|
||||
# cases for the ``azure-foundry`` provider:
|
||||
#
|
||||
# 1. ``model.auth_mode: entra_id`` — no static key exists; we need
|
||||
# a callable bearer-token provider from ``azure_identity_adapter``.
|
||||
# 2. Non-default ``model.base_url`` (Foundry projects path) — the
|
||||
# env-var-only resolver doesn't apply config-yaml-driven URL
|
||||
# overrides.
|
||||
#
|
||||
# Delegate to the same runtime resolver the main agent uses so
|
||||
# auxiliary tasks (title generation, compression, vision, embedding,
|
||||
# session search) inherit the user's full Azure config.
|
||||
if provider == "azure-foundry":
|
||||
client, default_model = _try_azure_foundry(
|
||||
# ── Plugin-registered resolvers (azure-foundry, etc.) ──────────────
|
||||
# Providers with complex auth (Entra ID, OAuth, etc.) register a
|
||||
# resolver callable so core doesn't need per-provider if/elif branches.
|
||||
from agent.plugin_registries import registries as _reg_early
|
||||
_early_resolver = _reg_early.get_provider_resolver(provider)
|
||||
if _early_resolver is not None:
|
||||
client, default_model = _early_resolver(
|
||||
model=model,
|
||||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=explicit_base_url,
|
||||
async_mode=async_mode,
|
||||
is_vision=is_vision,
|
||||
main_runtime=main_runtime,
|
||||
api_mode=api_mode,
|
||||
)
|
||||
if client is None:
|
||||
logger.warning(
|
||||
"resolve_provider_client: azure-foundry requested but "
|
||||
"runtime resolution failed (run: hermes doctor for "
|
||||
"diagnostics)"
|
||||
)
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
if client is not None:
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
# Resolver returned None — provider unavailable
|
||||
logger.warning(
|
||||
"resolve_provider_client: %s requested but resolver returned "
|
||||
"no client (run: hermes doctor for diagnostics)",
|
||||
provider,
|
||||
)
|
||||
return None, None
|
||||
|
||||
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
|
||||
try:
|
||||
@@ -3539,14 +3312,6 @@ def resolve_provider_client(
|
||||
return None, None
|
||||
|
||||
if pconfig.auth_type == "api_key":
|
||||
if provider == "anthropic":
|
||||
client, default_model = _try_anthropic(explicit_api_key=explicit_api_key)
|
||||
if client is None:
|
||||
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model))
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider)
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
# Honour an explicit api_key override (e.g. from a fallback_model entry
|
||||
@@ -3679,37 +3444,14 @@ def resolve_provider_client(
|
||||
return None, None
|
||||
|
||||
elif pconfig.auth_type == "aws_sdk":
|
||||
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
|
||||
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
except ImportError:
|
||||
logger.warning("resolve_provider_client: bedrock requested but "
|
||||
"boto3 or anthropic SDK not installed")
|
||||
return None, None
|
||||
|
||||
if not has_aws_credentials():
|
||||
logger.debug("resolve_provider_client: bedrock requested but "
|
||||
"no AWS credentials found")
|
||||
return None, None
|
||||
|
||||
region = resolve_bedrock_region()
|
||||
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
# AWS SDK providers (e.g. Bedrock) — handled by the early resolver
|
||||
# catch above when a plugin registers one. If we reach here, no
|
||||
# resolver was registered.
|
||||
logger.warning(
|
||||
"resolve_provider_client: aws_sdk provider %s has no "
|
||||
"registered resolver (plugin not loaded?)", provider,
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
return None, None
|
||||
|
||||
elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}:
|
||||
# OAuth providers — route through their specific try functions
|
||||
@@ -3833,7 +3575,12 @@ def _resolve_strict_vision_backend(
|
||||
# allow-list); callers must specify via auxiliary.<task>.model.
|
||||
return resolve_provider_client("openai-codex", model, is_vision=True)
|
||||
if provider == "anthropic":
|
||||
return _try_anthropic()
|
||||
from agent.plugin_registries import registries as _reg
|
||||
_resolver = _reg.get_provider_resolver("anthropic")
|
||||
if _resolver is not None:
|
||||
return _resolver(model=model)
|
||||
# Fallback: no resolver registered (plugin not loaded)
|
||||
return None, None
|
||||
if provider == "custom":
|
||||
return _try_custom_endpoint()
|
||||
return None, None
|
||||
@@ -4563,69 +4310,6 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
|
||||
# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper).
|
||||
# Their image content blocks must use Anthropic format, not OpenAI format.
|
||||
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
|
||||
|
||||
|
||||
def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
"""Detect if an endpoint expects Anthropic-format content blocks.
|
||||
|
||||
Returns True for known Anthropic-compatible providers (MiniMax) and
|
||||
any endpoint whose URL contains ``/anthropic`` in the path.
|
||||
"""
|
||||
if provider in _ANTHROPIC_COMPAT_PROVIDERS:
|
||||
return True
|
||||
url_lower = (base_url or "").lower()
|
||||
return "/anthropic" in url_lower
|
||||
|
||||
|
||||
def _convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
|
||||
|
||||
Only touches messages that have list-type content with ``image_url`` blocks;
|
||||
plain text messages pass through unchanged.
|
||||
"""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
converted.append(msg)
|
||||
continue
|
||||
new_content = []
|
||||
changed = False
|
||||
for block in content:
|
||||
if block.get("type") == "image_url":
|
||||
image_url_val = (block.get("image_url") or {}).get("url", "")
|
||||
if image_url_val.startswith("data:"):
|
||||
# Parse data URI: data:<media_type>;base64,<data>
|
||||
header, _, b64data = image_url_val.partition(",")
|
||||
media_type = "image/png"
|
||||
if ":" in header and ";" in header:
|
||||
media_type = header.split(":", 1)[1].split(";", 1)[0]
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": b64data,
|
||||
},
|
||||
})
|
||||
else:
|
||||
# URL-based image
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": image_url_val,
|
||||
},
|
||||
})
|
||||
changed = True
|
||||
else:
|
||||
new_content.append(block)
|
||||
converted.append({**msg, "content": new_content} if changed else msg)
|
||||
return converted
|
||||
|
||||
|
||||
|
||||
def _build_call_kwargs(
|
||||
provider: str,
|
||||
model: str,
|
||||
@@ -4655,8 +4339,10 @@ def _build_call_kwargs(
|
||||
# structured-JSON extraction) don't 400 the moment
|
||||
# the aux model is flipped to 4.7.
|
||||
if temperature is not None:
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
if _forbids_sampling_params(model):
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
_forbids_sampling_params = _anthropic.get("_forbids_sampling_params")
|
||||
if _forbids_sampling_params is not None and _forbids_sampling_params(model):
|
||||
temperature = None
|
||||
|
||||
if temperature is not None:
|
||||
@@ -4868,8 +4554,8 @@ def call_llm(
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
_client_base = str(getattr(client, "base_url", "") or "")
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
|
||||
|
||||
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
|
||||
# then payment fallback.
|
||||
@@ -4937,6 +4623,41 @@ def call_llm(
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
|
||||
)
|
||||
if (
|
||||
_is_payment_error(first_err)
|
||||
and client_is_nous
|
||||
and _nous_portal_account_has_fresh_paid_access()
|
||||
):
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
model=final_model,
|
||||
async_mode=False,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=(task == "vision"),
|
||||
)
|
||||
if refreshed_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: refreshed Nous runtime credentials after paid account check, retrying",
|
||||
task or "call",
|
||||
)
|
||||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
or _is_payment_error(retry_err)
|
||||
or _is_connection_error(retry_err)
|
||||
or _is_rate_limit_error(retry_err)
|
||||
):
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
if _is_auth_error(first_err) and client_is_nous:
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
@@ -5276,8 +4997,8 @@ async def async_call_llm(
|
||||
base_url=_client_base or resolved_base_url)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
|
||||
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
|
||||
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
@@ -5339,6 +5060,40 @@ async def async_call_llm(
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
|
||||
)
|
||||
if (
|
||||
_is_payment_error(first_err)
|
||||
and client_is_nous
|
||||
and _nous_portal_account_has_fresh_paid_access()
|
||||
):
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
model=final_model,
|
||||
async_mode=True,
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
is_vision=(task == "vision"),
|
||||
)
|
||||
if refreshed_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s (async): refreshed Nous runtime credentials after paid account check, retrying",
|
||||
task or "call",
|
||||
)
|
||||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
or _is_payment_error(retry_err)
|
||||
or _is_connection_error(retry_err)
|
||||
or _is_rate_limit_error(retry_err)
|
||||
):
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
if _is_auth_error(first_err) and client_is_nous:
|
||||
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
|
||||
cache_provider=resolved_provider or "nous",
|
||||
|
||||
@@ -483,6 +483,11 @@ def _run_review_in_thread(
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
|
||||
# Snapshot review actions before teardown. close() is allowed to
|
||||
# clean per-session state, but the user-visible self-improvement
|
||||
# summary still needs the completed review agent's tool results.
|
||||
review_messages = list(getattr(review_agent, "_session_messages", []))
|
||||
|
||||
# Tear down memory providers while stdout is still
|
||||
# redirected so background thread teardown (Honcho flush,
|
||||
# Hindsight sync, etc.) stays silent. The finally block
|
||||
@@ -495,7 +500,6 @@ def _run_review_in_thread(
|
||||
review_agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
review_messages = list(getattr(review_agent, "_session_messages", []))
|
||||
review_agent = None
|
||||
|
||||
# Scan the review agent's messages for successful tool actions
|
||||
|
||||
@@ -129,6 +129,24 @@ def estimate_request_context_tokens(api_payload: Any) -> int:
|
||||
return _chars(api_payload) // 4
|
||||
|
||||
|
||||
def _is_openai_codex_backend(agent) -> bool:
|
||||
base_url_lower = str(getattr(agent, "_base_url_lower", "") or "")
|
||||
base_url_hostname = str(getattr(agent, "_base_url_hostname", "") or "")
|
||||
return (
|
||||
getattr(agent, "provider", None) == "openai-codex"
|
||||
or (
|
||||
base_url_hostname == "chatgpt.com"
|
||||
and "/backend-api/codex" in base_url_lower
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def interruptible_api_call(agent, api_kwargs: dict):
|
||||
"""
|
||||
@@ -217,12 +235,14 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
# normalize_converse_response produces an OpenAI-compatible
|
||||
# SimpleNamespace so the rest of the agent loop can treat
|
||||
# bedrock responses like chat_completions responses.
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
normalize_converse_response,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock = registries.get_provider_namespace("bedrock")
|
||||
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
|
||||
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
|
||||
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
|
||||
normalize_converse_response = _bedrock.get("normalize_converse_response")
|
||||
if _get_bedrock_runtime_client is None or normalize_converse_response is None:
|
||||
raise ImportError("bedrock provider not registered")
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
@@ -256,32 +276,89 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
# apply richer recovery (credential rotation, provider fallback).
|
||||
_stale_timeout = agent._compute_non_stream_stale_timeout(api_kwargs)
|
||||
|
||||
# ── Time-to-first-byte (TTFB) watchdog for the Codex Responses stream ──
|
||||
# ── Codex Responses stream watchdogs ────────────────────────────────
|
||||
# The chatgpt.com/backend-api/codex endpoint has an intermittent failure
|
||||
# mode where it accepts the connection but never emits a single stream
|
||||
# event (observed directly: 0 events, no HTTP status, the socket just
|
||||
# hangs). A fresh reconnect succeeds in ~2s, but the wall-clock stale
|
||||
# timeout (often 180–900s) makes us wait minutes before retrying. While no
|
||||
# stream event has arrived yet we apply a much shorter TTFB cutoff so the
|
||||
# main retry loop can reconnect promptly. Once the first event arrives the
|
||||
# stream is healthy, so we fall back to the wall-clock stale timeout and
|
||||
# never interrupt a legitimate long generation. Gated to codex_responses:
|
||||
# only that path streams events incrementally (the chat_completions
|
||||
# non-stream, anthropic and bedrock branches here have no first-event
|
||||
# signal). The marker advances on *any* event (see codex_runtime), so
|
||||
# reasoning-only / tool-call-only turns are not mistaken for a stall.
|
||||
# Operators can tune via HERMES_CODEX_TTFB_TIMEOUT_SECONDS (0 disables).
|
||||
_ttfb_enabled = agent.api_mode == "codex_responses"
|
||||
try:
|
||||
_ttfb_timeout = float(os.getenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "45"))
|
||||
except (TypeError, ValueError):
|
||||
_ttfb_timeout = 45.0
|
||||
# main retry loop can reconnect promptly. Large subscription-backed Codex
|
||||
# requests can legitimately spend tens of seconds in backend admission /
|
||||
# prompt prefill before the first SSE event, so the no-byte TTFB watchdog
|
||||
# is disabled for large chatgpt.com/backend-api/codex requests. A second
|
||||
# failure mode emits an opening SSE frame and then stalls forever in SSL
|
||||
# read; for that we watch the gap since the last Codex stream event. This
|
||||
# matches Codex CLI's stream_idle_timeout model: any valid SSE event is
|
||||
# activity. Operators can tune via HERMES_CODEX_TTFB_TIMEOUT_SECONDS and
|
||||
# HERMES_CODEX_EVENT_STALE_TIMEOUT_SECONDS (0 disables each).
|
||||
_codex_watchdog_enabled = agent.api_mode == "codex_responses"
|
||||
_openai_codex_backend = _is_openai_codex_backend(agent)
|
||||
_est_tokens_for_codex_watchdog = estimate_request_context_tokens(api_kwargs)
|
||||
if _codex_watchdog_enabled and _openai_codex_backend:
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_stale_timeout = max(_stale_timeout, 1200.0)
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
_stale_timeout = max(_stale_timeout, 900.0)
|
||||
elif _est_tokens_for_codex_watchdog > 25_000:
|
||||
_stale_timeout = max(_stale_timeout, 600.0)
|
||||
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_codex_idle_timeout_default = 180.0
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
_codex_idle_timeout_default = 120.0
|
||||
elif _est_tokens_for_codex_watchdog > 10_000:
|
||||
_codex_idle_timeout_default = 60.0
|
||||
else:
|
||||
_codex_idle_timeout_default = 12.0
|
||||
|
||||
_ttfb_enabled = _codex_watchdog_enabled
|
||||
_ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 12.0)
|
||||
if _ttfb_timeout <= 0:
|
||||
_ttfb_enabled = False
|
||||
if _ttfb_enabled:
|
||||
elif _openai_codex_backend:
|
||||
_ttfb_disable_above = _env_float("HERMES_CODEX_TTFB_DISABLE_ABOVE_TOKENS", 25_000.0)
|
||||
_ttfb_strict = os.environ.get("HERMES_CODEX_TTFB_STRICT", "").strip().lower() in {
|
||||
"1", "true", "yes", "on"
|
||||
}
|
||||
if (
|
||||
not _ttfb_strict
|
||||
and _ttfb_disable_above > 0
|
||||
and _est_tokens_for_codex_watchdog >= _ttfb_disable_above
|
||||
):
|
||||
_ttfb_enabled = False
|
||||
logger.info(
|
||||
"Disabling openai-codex no-byte TTFB watchdog for large request "
|
||||
"(context=~%s tokens >= %.0f). Waiting for backend response instead. "
|
||||
"Set HERMES_CODEX_TTFB_STRICT=1 to force early reconnects.",
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
_ttfb_disable_above,
|
||||
)
|
||||
else:
|
||||
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 20.0)
|
||||
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
|
||||
logger.info(
|
||||
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
|
||||
"(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.",
|
||||
_ttfb_timeout,
|
||||
_ttfb_cap,
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
)
|
||||
_ttfb_timeout = _ttfb_cap
|
||||
|
||||
_codex_idle_enabled = _codex_watchdog_enabled
|
||||
_codex_idle_timeout = _env_float(
|
||||
"HERMES_CODEX_EVENT_STALE_TIMEOUT_SECONDS",
|
||||
_codex_idle_timeout_default,
|
||||
)
|
||||
if _codex_idle_timeout <= 0:
|
||||
_codex_idle_enabled = False
|
||||
|
||||
if _codex_watchdog_enabled:
|
||||
# Reset before the worker starts so a marker left over from a previous
|
||||
# call on this agent can't be misread as first-byte for this one.
|
||||
agent._codex_stream_last_event_ts = None
|
||||
agent._codex_stream_last_progress_ts = None
|
||||
|
||||
_call_start = time.time()
|
||||
agent._touch_activity("waiting for non-streaming API response")
|
||||
@@ -328,13 +405,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
_elapsed, _ttfb_timeout, api_kwargs.get("model", "unknown"),
|
||||
)
|
||||
if _silent_hint:
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"⚠️ No first byte from provider in {int(_elapsed)}s "
|
||||
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting. {_silent_hint}"
|
||||
)
|
||||
else:
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"⚠️ No first byte from provider in {int(_elapsed)}s "
|
||||
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting."
|
||||
@@ -361,6 +438,45 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
)
|
||||
break
|
||||
|
||||
# Stream-idle detector: the Codex backend emitted at least one SSE
|
||||
# frame, then stopped emitting events. Valid keepalive / in_progress
|
||||
# frames refresh _codex_stream_last_event_ts and should not be killed.
|
||||
_last_codex_event_ts = getattr(agent, "_codex_stream_last_event_ts", None)
|
||||
if (
|
||||
_codex_idle_enabled
|
||||
and _last_codex_event_ts is not None
|
||||
and (time.time() - _last_codex_event_ts) > _codex_idle_timeout
|
||||
):
|
||||
_event_stale_elapsed = time.time() - _last_codex_event_ts
|
||||
logger.warning(
|
||||
"Codex stream produced no SSE events for %.0fs after first byte "
|
||||
"(threshold %.0fs, model=%s, context=~%s tokens). Killing "
|
||||
"connection so the retry loop can reconnect.",
|
||||
_event_stale_elapsed,
|
||||
_codex_idle_timeout,
|
||||
api_kwargs.get("model", "unknown"),
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
)
|
||||
agent._buffer_status(
|
||||
f"⚠️ Codex stream sent no events for {int(_event_stale_elapsed)}s "
|
||||
f"after first byte (model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting."
|
||||
)
|
||||
try:
|
||||
_close_request_client_once("codex_stream_idle_kill")
|
||||
except Exception:
|
||||
pass
|
||||
agent._touch_activity(
|
||||
f"codex stream killed after {int(_event_stale_elapsed)}s with no SSE events"
|
||||
)
|
||||
t.join(timeout=2.0)
|
||||
if result["error"] is None and result["response"] is None:
|
||||
result["error"] = TimeoutError(
|
||||
f"Codex stream produced no SSE events for {int(_event_stale_elapsed)}s "
|
||||
f"after first byte (threshold: {int(_codex_idle_timeout)}s)"
|
||||
)
|
||||
break
|
||||
|
||||
# Stale-call detector: kill the connection if no response
|
||||
# arrives within the configured timeout.
|
||||
if _elapsed > _stale_timeout:
|
||||
@@ -379,13 +495,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
api_kwargs.get("model", "unknown"), f"{_est_ctx:,}",
|
||||
)
|
||||
if _silent_hint:
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"⚠️ No response from provider for {int(_elapsed)}s "
|
||||
f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"{_silent_hint}"
|
||||
)
|
||||
else:
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"⚠️ No response from provider for {int(_elapsed)}s "
|
||||
f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Aborting call."
|
||||
@@ -582,8 +698,11 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
_ant_max = None
|
||||
if (_is_or or _is_nous) and "claude" in (agent.model or "").lower():
|
||||
try:
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output
|
||||
_ant_max = _get_anthropic_max_output(agent.model)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
_get_anthropic_max_output = _anthropic.get("_get_anthropic_max_output")
|
||||
if _get_anthropic_max_output is not None:
|
||||
_ant_max = _get_anthropic_max_output(agent.model)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1042,6 +1161,25 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
agent._transport_cache.clear()
|
||||
agent._fallback_activated = True
|
||||
|
||||
# Clear the credential pool when the fallback provider doesn't match
|
||||
# the pool's provider. The pool was seeded for the primary provider;
|
||||
# leaving it attached means downstream recovery (rate_limit / billing /
|
||||
# auth) calls ``_swap_credential`` with a primary entry which overwrites
|
||||
# the agent's ``base_url`` back to the primary's endpoint — every
|
||||
# fallback request then 404s against the wrong host. See #33163.
|
||||
# When the fallback shares the pool's provider (e.g. both openrouter
|
||||
# entries with different routing) the pool is preserved.
|
||||
_existing_pool = getattr(agent, "_credential_pool", None)
|
||||
if _existing_pool is not None:
|
||||
_pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower()
|
||||
if _pool_provider and _pool_provider != fb_provider:
|
||||
logger.info(
|
||||
"Fallback to %s/%s: clearing primary credential pool "
|
||||
"(pool_provider=%s) to prevent cross-provider contamination",
|
||||
fb_provider, fb_model, _pool_provider,
|
||||
)
|
||||
agent._credential_pool = None
|
||||
|
||||
# Honor per-provider / per-model request_timeout_seconds for the
|
||||
# fallback target (same knob the primary client uses). None = use
|
||||
# SDK default.
|
||||
@@ -1049,15 +1187,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
|
||||
if fb_api_mode == "anthropic_messages":
|
||||
# Build native Anthropic client instead of using OpenAI client
|
||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
|
||||
effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
build_anthropic_client = _anthropic.get("build_anthropic_client")
|
||||
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
|
||||
_is_oauth_token = _anthropic.get("_is_oauth_token")
|
||||
effective_key = (fb_client.api_key or (resolve_anthropic_token() if resolve_anthropic_token else "") or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = fb_base_url
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" else False
|
||||
if build_anthropic_client is not None:
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" and _is_oauth_token else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
@@ -1129,7 +1272,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"🔄 Primary model failed — switching to fallback: "
|
||||
f"{fb_model} via {fb_provider}"
|
||||
)
|
||||
@@ -1441,12 +1584,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
|
||||
def _bedrock_call():
|
||||
try:
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
stream_converse_with_callbacks,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock = registries.get_provider_namespace("bedrock")
|
||||
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
|
||||
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
|
||||
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
|
||||
stream_converse_with_callbacks = _bedrock.get("stream_converse_with_callbacks")
|
||||
if _get_bedrock_runtime_client is None or stream_converse_with_callbacks is None:
|
||||
raise ImportError("bedrock provider not registered")
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
@@ -2118,7 +2263,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
mid_tool_call=False,
|
||||
diag=request_client_holder.get("diag"),
|
||||
)
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
"❌ Provider returned malformed streaming data after "
|
||||
f"{_max_stream_retries + 1} attempts. "
|
||||
"The provider may be experiencing issues — "
|
||||
@@ -2225,7 +2370,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_stale_elapsed, _stream_stale_timeout,
|
||||
api_kwargs.get("model", "unknown"), f"{_est_ctx:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"⚠️ No response from provider for {int(_stale_elapsed)}s "
|
||||
f"(model: {api_kwargs.get('model', 'unknown')}, "
|
||||
f"context: ~{_est_ctx:,} tokens). "
|
||||
|
||||
@@ -913,6 +913,26 @@ def _preflight_codex_api_kwargs(
|
||||
elif "stream" in api_kwargs:
|
||||
raise ValueError("Codex Responses stream flag is only allowed in fallback streaming requests.")
|
||||
|
||||
# Safety-net sanitization for xAI Responses (#28490): defense-in-depth
|
||||
# for the same slash-enum strip that ``chat_completion_helpers`` and
|
||||
# ``auxiliary_client`` apply at request-build time. If a future code
|
||||
# path forgets to sanitize before calling us, this catches the bypass
|
||||
# so xAI doesn't 400 with ``Invalid arguments passed to the model``
|
||||
# (HuggingFace IDs like ``Qwen/Qwen3.5-0.8B`` from MCP tool schemas).
|
||||
#
|
||||
# Gated on the model name pattern because native Codex (OpenAI) DOES
|
||||
# accept slash-containing enum values — stripping them there would
|
||||
# silently degrade tool-schema constraints. xAI is the only
|
||||
# Responses-API surface that rejects the shape.
|
||||
model_name_for_provider_check = str(api_kwargs.get("model") or "").lower()
|
||||
is_xai_model = model_name_for_provider_check.startswith(("grok-", "x-ai/grok-"))
|
||||
if is_xai_model and normalized.get("tools"):
|
||||
try:
|
||||
from tools.schema_sanitizer import strip_slash_enum
|
||||
normalized["tools"], _ = strip_slash_enum(normalized["tools"])
|
||||
except Exception:
|
||||
pass # Best-effort — the caller-level sanitization should have handled it
|
||||
|
||||
unexpected = sorted(key for key in api_kwargs if key not in allowed_keys)
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
|
||||
@@ -71,7 +71,12 @@ class ContextEngine(ABC):
|
||||
def update_from_response(self, usage: Dict[str, Any]) -> None:
|
||||
"""Update tracked token usage from an API response.
|
||||
|
||||
Called after every LLM call with the usage dict from the response.
|
||||
Called after every LLM call with a normalized usage dict. The legacy
|
||||
keys ``prompt_tokens``, ``completion_tokens``, and ``total_tokens``
|
||||
are always present. Newer hosts also include canonical buckets:
|
||||
``input_tokens``, ``output_tokens``, ``cache_read_tokens``,
|
||||
``cache_write_tokens``, and ``reasoning_tokens``. Engines should
|
||||
treat those fields as optional for compatibility with older hosts.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -421,6 +421,7 @@ def compress_context(
|
||||
agent.session_id or "",
|
||||
boundary_reason="compression",
|
||||
old_session_id=_old_sid,
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
logger.debug("context engine on_session_start (compression): %s", _ce_err)
|
||||
|
||||
+376
-120
@@ -27,7 +27,7 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
from agent.plugin_registries import registries as _registries
|
||||
from agent.auxiliary_client import set_runtime_main
|
||||
from agent.codex_responses_adapter import _summarize_user_message_for_log
|
||||
from agent.display import KawaiiSpinner
|
||||
@@ -49,9 +49,8 @@ from agent.model_metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
estimate_messages_tokens_rough,
|
||||
estimate_request_tokens_rough,
|
||||
get_next_probe_tier,
|
||||
get_context_length_from_provider_error,
|
||||
parse_available_output_tokens_from_error,
|
||||
parse_context_limit_from_error,
|
||||
save_context_length,
|
||||
)
|
||||
from agent.nous_rate_guard import (
|
||||
@@ -127,6 +126,106 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _nous_entitlement_message(capability: str) -> str:
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
message = format_nous_portal_entitlement_message(
|
||||
account_info,
|
||||
capability=capability,
|
||||
)
|
||||
return message or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _print_nous_entitlement_guidance(agent, capability: str) -> bool:
|
||||
message = _nous_entitlement_message(capability)
|
||||
if not message:
|
||||
return False
|
||||
for line in message.splitlines():
|
||||
agent._vprint(f"{agent.log_prefix} 💡 {line}", force=True)
|
||||
return True
|
||||
|
||||
|
||||
def _is_nous_inference_route(provider: str, base_url: str) -> bool:
|
||||
provider = (provider or "").strip().lower()
|
||||
if provider == "nous":
|
||||
return True
|
||||
base = str(base_url or "")
|
||||
return (
|
||||
base_url_host_matches(base, "inference-api.nousresearch.com")
|
||||
or base_url_host_matches(base, "inference.nousresearch.com")
|
||||
)
|
||||
|
||||
|
||||
def _billing_or_entitlement_message(
|
||||
*,
|
||||
capability: str,
|
||||
provider: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
) -> str:
|
||||
if _is_nous_inference_route(provider, base_url):
|
||||
return _nous_entitlement_message(capability)
|
||||
|
||||
provider_label = (provider or "").strip() or "the selected provider"
|
||||
model_label = (model or "").strip() or "the selected model"
|
||||
lines = [
|
||||
(
|
||||
f"{provider_label} reported that billing, credits, or account "
|
||||
f"entitlement is exhausted for {model_label}."
|
||||
),
|
||||
"Add credits or update billing with that provider, then retry.",
|
||||
]
|
||||
if base_url_host_matches(str(base_url or ""), "openrouter.ai"):
|
||||
lines.append("OpenRouter credits: https://openrouter.ai/settings/credits")
|
||||
lines.append("You can switch providers temporarily with /model <model> --provider <provider>.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _print_billing_or_entitlement_guidance(
|
||||
agent,
|
||||
*,
|
||||
capability: str,
|
||||
provider: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
) -> bool:
|
||||
message = _billing_or_entitlement_message(
|
||||
capability=capability,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
)
|
||||
if not message:
|
||||
return False
|
||||
for line in message.splitlines():
|
||||
agent._vprint(f"{agent.log_prefix} 💡 {line}", force=True)
|
||||
return True
|
||||
|
||||
|
||||
def _try_refresh_nous_paid_entitlement_credentials(agent) -> bool:
|
||||
"""Refresh Nous runtime credentials after a fresh paid-entitlement check."""
|
||||
try:
|
||||
from hermes_cli.auth import NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
if account_info.paid_service_access is not True:
|
||||
return False
|
||||
return agent._try_refresh_nous_client_credentials(
|
||||
force=False,
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _restore_or_build_system_prompt(agent, system_message, conversation_history):
|
||||
"""Restore the cached system prompt from the session DB or build it fresh.
|
||||
|
||||
@@ -1017,6 +1116,7 @@ def run_conversation(
|
||||
codex_auth_retry_attempted=False
|
||||
anthropic_auth_retry_attempted=False
|
||||
nous_auth_retry_attempted=False
|
||||
nous_paid_entitlement_refresh_attempted=False
|
||||
copilot_auth_retry_attempted=False
|
||||
thinking_sig_retry_attempted = False
|
||||
invalid_encrypted_content_retry_attempted = False
|
||||
@@ -1050,17 +1150,18 @@ def run_conversation(
|
||||
f"Nous Portal rate limit active — "
|
||||
f"resets in {_fmt_nous_remaining(_nous_remaining)}."
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⏳ {_nous_msg} Trying fallback...",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f"⏳ {_nous_msg} Trying fallback..."
|
||||
)
|
||||
agent._emit_status(f"⏳ {_nous_msg}")
|
||||
agent._buffer_status(f"⏳ {_nous_msg}")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
primary_recovery_attempted = False
|
||||
continue
|
||||
# No fallback available — return with clear message
|
||||
# No fallback available — surface buffered context
|
||||
# so user sees the rate-limit message that led here.
|
||||
agent._flush_status_buffer()
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"final_response": (
|
||||
@@ -1082,6 +1183,14 @@ def run_conversation(
|
||||
|
||||
try:
|
||||
agent._reset_stream_delivery_tracking()
|
||||
# api_messages is built once, before this retry loop, while the
|
||||
# primary provider is active. A mid-conversation fallback can
|
||||
# switch to a require-side provider (DeepSeek / Kimi / MiMo) that
|
||||
# rejects assistant turns lacking reasoning_content. Re-apply the
|
||||
# echo-back pad for the *current* provider here (idempotent no-op
|
||||
# unless the active provider needs it) so the fallback request
|
||||
# isn't sent with stale, primary-shaped reasoning fields.
|
||||
agent._reapply_reasoning_echo_for_provider(api_messages)
|
||||
api_kwargs = agent._build_api_kwargs(api_messages)
|
||||
if agent._force_ascii_payload:
|
||||
_sanitize_structure_non_ascii(api_kwargs)
|
||||
@@ -1275,9 +1384,10 @@ def run_conversation(
|
||||
error_details.append("response.choices is empty")
|
||||
|
||||
if response_invalid:
|
||||
# Stop spinner before printing error messages
|
||||
# Stop spinner silently — retry status is now buffered
|
||||
# and only surfaced if every retry+fallback exhausts.
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop("(´;ω;`) oops, retrying...")
|
||||
thinking_spinner.stop("")
|
||||
thinking_spinner = None
|
||||
if agent.thinking_callback:
|
||||
agent.thinking_callback("")
|
||||
@@ -1290,7 +1400,7 @@ def run_conversation(
|
||||
# rate-limit symptom. Switch to fallback immediately
|
||||
# rather than retrying with extended backoff.
|
||||
if agent._fallback_index < len(agent._fallback_chain):
|
||||
agent._emit_status("⚠️ Empty/malformed response — switching to fallback...")
|
||||
agent._buffer_status("⚠️ Empty/malformed response — switching to fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -1352,20 +1462,22 @@ def run_conversation(
|
||||
else:
|
||||
_failure_hint = f"response time {api_duration:.1f}s"
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 🏢 Provider: {provider_name}", force=True)
|
||||
agent._buffer_vprint(f"⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}")
|
||||
agent._buffer_vprint(f" 🏢 Provider: {provider_name}")
|
||||
cleaned_provider_error = agent._clean_error_message(error_msg)
|
||||
agent._vprint(f"{agent.log_prefix} 📝 Provider message: {cleaned_provider_error}", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} ⏱️ {_failure_hint}", force=True)
|
||||
agent._buffer_vprint(f" 📝 Provider message: {cleaned_provider_error}")
|
||||
agent._buffer_vprint(f" ⏱️ {_failure_hint}")
|
||||
|
||||
if retry_count >= max_retries:
|
||||
# Try fallback before giving up
|
||||
agent._emit_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
primary_recovery_attempted = False
|
||||
continue
|
||||
# Terminal — flush buffered retry trace so user sees what happened.
|
||||
agent._flush_status_buffer()
|
||||
agent._emit_status(f"❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.")
|
||||
logger.error(f"{agent.log_prefix}Invalid API response after {max_retries} retries.")
|
||||
agent._persist_session(messages, conversation_history)
|
||||
@@ -1379,7 +1491,7 @@ def run_conversation(
|
||||
|
||||
# Backoff before retry — jittered exponential: 5s base, 120s cap
|
||||
wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0)
|
||||
agent._vprint(f"{agent.log_prefix}⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...", force=True)
|
||||
agent._buffer_vprint(f"⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...")
|
||||
logger.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}")
|
||||
|
||||
# Sleep in small increments to stay responsive to interrupts
|
||||
@@ -1606,14 +1718,14 @@ def run_conversation(
|
||||
if assistant_message is not None and _trunc_has_tool_calls:
|
||||
if truncated_tool_call_retries < 1:
|
||||
truncated_tool_call_retries += 1
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Truncated tool call detected — retrying API call...",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Truncated tool call detected — retrying API call..."
|
||||
)
|
||||
# Don't append the broken response to messages;
|
||||
# just re-run the same API call from the current
|
||||
# message state, giving the model another chance.
|
||||
continue
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Truncated tool call response detected again — refusing to execute incomplete tool arguments.",
|
||||
force=True,
|
||||
@@ -1647,6 +1759,7 @@ def run_conversation(
|
||||
}
|
||||
else:
|
||||
# First message was truncated - mark as failed
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ First response truncated - cannot recover", force=True)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
@@ -1668,10 +1781,19 @@ def run_conversation(
|
||||
prompt_tokens = canonical_usage.prompt_tokens
|
||||
completion_tokens = canonical_usage.output_tokens
|
||||
total_tokens = canonical_usage.total_tokens
|
||||
# Forward canonical token + cache buckets so context engines
|
||||
# can make decisions on cache hit ratios / reasoning costs,
|
||||
# not just legacy aggregate tokens. Legacy keys stay for
|
||||
# back-compat with engines that only read prompt/completion/total.
|
||||
usage_dict = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"input_tokens": canonical_usage.input_tokens,
|
||||
"output_tokens": canonical_usage.output_tokens,
|
||||
"cache_read_tokens": canonical_usage.cache_read_tokens,
|
||||
"cache_write_tokens": canonical_usage.cache_write_tokens,
|
||||
"reasoning_tokens": canonical_usage.reasoning_tokens,
|
||||
}
|
||||
agent.context_compressor.update_from_response(usage_dict)
|
||||
|
||||
@@ -1789,6 +1911,11 @@ def run_conversation(
|
||||
)
|
||||
|
||||
has_retried_429 = False # Reset on success
|
||||
# Note: don't clear the retry buffer here — an "API call
|
||||
# success" only means we got bytes back, not that we got
|
||||
# usable content. Empty responses still loop through the
|
||||
# empty-retry path below; the buffer is cleared when
|
||||
# genuinely successful content is detected later (~L4127).
|
||||
# Clear Nous rate limit state on successful request —
|
||||
# proves the limit has reset and other sessions can
|
||||
# resume hitting Nous.
|
||||
@@ -1815,9 +1942,10 @@ def run_conversation(
|
||||
break
|
||||
|
||||
except Exception as api_error:
|
||||
# Stop spinner before printing error messages
|
||||
# Stop spinner silently — retry status is buffered and
|
||||
# only flushed when every retry+fallback is exhausted.
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop("(╥_╥) error, retrying...")
|
||||
thinking_spinner.stop("")
|
||||
thinking_spinner = None
|
||||
if agent.thinking_callback:
|
||||
agent.thinking_callback("")
|
||||
@@ -1872,14 +2000,12 @@ def run_conversation(
|
||||
if _surrogates_found or _is_surrogate_error:
|
||||
agent._unicode_sanitization_passes += 1
|
||||
if _surrogates_found:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Stripped invalid surrogate characters from messages. Retrying...",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Stripped invalid surrogate characters from messages. Retrying..."
|
||||
)
|
||||
else:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Surrogate encoding error — retrying after full-payload sanitization...",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Surrogate encoding error — retrying after full-payload sanitization..."
|
||||
)
|
||||
continue
|
||||
if _is_ascii_codec:
|
||||
@@ -2093,6 +2219,23 @@ def run_conversation(
|
||||
classified.should_rotate_credential, classified.should_fallback,
|
||||
)
|
||||
|
||||
if (
|
||||
classified.reason == FailoverReason.billing
|
||||
and _is_nous_inference_route(
|
||||
getattr(agent, "provider", "") or "",
|
||||
getattr(agent, "base_url", "") or "",
|
||||
)
|
||||
and not nous_paid_entitlement_refresh_attempted
|
||||
):
|
||||
nous_paid_entitlement_refresh_attempted = True
|
||||
if _try_refresh_nous_paid_entitlement_credentials(agent):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}🔐 Nous paid access verified — "
|
||||
"refreshed runtime credentials and retrying request...",
|
||||
force=True,
|
||||
)
|
||||
continue
|
||||
|
||||
recovered_with_pool, has_retried_429 = agent._recover_with_credential_pool(
|
||||
status_code=status_code,
|
||||
has_retried_429=has_retried_429,
|
||||
@@ -2190,7 +2333,7 @@ def run_conversation(
|
||||
codex_auth_retry_attempted = True
|
||||
if agent._try_refresh_codex_client_credentials(force=True):
|
||||
_label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex"
|
||||
agent._vprint(f"{agent.log_prefix}🔐 {_label} auth refreshed after 401. Retrying request...")
|
||||
agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...")
|
||||
continue
|
||||
if (
|
||||
agent.api_mode == "chat_completions"
|
||||
@@ -2217,7 +2360,8 @@ def run_conversation(
|
||||
print(f"{agent.log_prefix}🔐 Nous 401 — Portal authentication failed.")
|
||||
if _body_text:
|
||||
print(f"{agent.log_prefix} Response: {_body_text}")
|
||||
print(f"{agent.log_prefix} Most likely: Portal OAuth expired, account out of credits, or agent key revoked.")
|
||||
if not _print_nous_entitlement_guidance(agent, "Nous model access"):
|
||||
print(f"{agent.log_prefix} Most likely: Portal OAuth expired, account out of credits, or agent key revoked.")
|
||||
print(f"{agent.log_prefix} Troubleshooting:")
|
||||
print(f"{agent.log_prefix} • Re-authenticate: hermes auth add nous")
|
||||
print(f"{agent.log_prefix} • Check credits / billing: https://portal.nousresearch.com")
|
||||
@@ -2230,7 +2374,7 @@ def run_conversation(
|
||||
):
|
||||
copilot_auth_retry_attempted = True
|
||||
if agent._try_refresh_copilot_client_credentials():
|
||||
agent._vprint(f"{agent.log_prefix}🔐 Copilot credentials refreshed after 401. Retrying request...")
|
||||
agent._buffer_vprint(f"🔐 Copilot credentials refreshed after 401. Retrying request...")
|
||||
continue
|
||||
if (
|
||||
agent.api_mode == "anthropic_messages"
|
||||
@@ -2239,8 +2383,8 @@ def run_conversation(
|
||||
and not anthropic_auth_retry_attempted
|
||||
):
|
||||
anthropic_auth_retry_attempted = True
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
_is_oauth_token = _registries.get_provider_service("anthropic", "_is_oauth_token")
|
||||
is_token_provider = _registries.get_provider_service("azure", "is_token_provider")
|
||||
if agent._try_refresh_anthropic_client_credentials():
|
||||
print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
|
||||
continue
|
||||
@@ -2257,7 +2401,7 @@ def run_conversation(
|
||||
print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or")
|
||||
print(f"{agent.log_prefix} `az login` if your developer session expired.")
|
||||
else:
|
||||
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
|
||||
auth_method = "Bearer (OAuth/setup-token)" if (_is_oauth_token is not None and _is_oauth_token(key)) else "x-api-key (API key)"
|
||||
print(f"{agent.log_prefix} Auth method: {auth_method}")
|
||||
print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)")
|
||||
print(f"{agent.log_prefix} Troubleshooting:")
|
||||
@@ -2405,41 +2549,37 @@ def run_conversation(
|
||||
_base = getattr(agent, "base_url", "unknown")
|
||||
_model = getattr(agent, "model", "unknown")
|
||||
_status_code_str = f" [HTTP {status_code}]" if status_code else ""
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ API call failed (attempt {retry_count}/{max_retries}): {error_type}{_status_code_str}", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 🌐 Endpoint: {_base}", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 📝 Error: {_error_summary}", force=True)
|
||||
agent._buffer_vprint(f"⚠️ API call failed (attempt {retry_count}/{max_retries}): {error_type}{_status_code_str}")
|
||||
agent._buffer_vprint(f" 🔌 Provider: {_provider} Model: {_model}")
|
||||
agent._buffer_vprint(f" 🌐 Endpoint: {_base}")
|
||||
agent._buffer_vprint(f" 📝 Error: {_error_summary}")
|
||||
if status_code and status_code < 500:
|
||||
_err_body = getattr(api_error, "body", None)
|
||||
_err_body_str = str(_err_body)[:300] if _err_body else None
|
||||
if _err_body_str:
|
||||
agent._vprint(f"{agent.log_prefix} 📋 Details: {_err_body_str}", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} ⏱️ Elapsed: {elapsed_time:.2f}s Context: {len(api_messages)} msgs, ~{approx_tokens:,} tokens")
|
||||
agent._buffer_vprint(f" 📋 Details: {_err_body_str}")
|
||||
agent._buffer_vprint(f" ⏱️ Elapsed: {elapsed_time:.2f}s Context: {len(api_messages)} msgs, ~{approx_tokens:,} tokens")
|
||||
|
||||
# Actionable hint for OpenRouter "no tool endpoints" error.
|
||||
# This fires regardless of whether fallback succeeds — the
|
||||
# user needs to know WHY their model failed so they can fix
|
||||
# their provider routing, not just silently fall back.
|
||||
# Buffered like the rest of the retry trace — surfaced only
|
||||
# if every retry+fallback exhausts. Avoids spamming users
|
||||
# who recover automatically via fallback.
|
||||
if (
|
||||
agent._is_openrouter_url()
|
||||
and "support tool use" in error_msg
|
||||
):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 No OpenRouter providers for {_model} support tool calling with your current settings.",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f" 💡 No OpenRouter providers for {_model} support tool calling with your current settings."
|
||||
)
|
||||
if agent.providers_allowed:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} Your provider_routing.only restriction is filtering out tool-capable providers.",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f" Your provider_routing.only restriction is filtering out tool-capable providers."
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} Try removing the restriction or adding providers that support tools for this model.",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f" Try removing the restriction or adding providers that support tools for this model."
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} Check which providers support tools: https://openrouter.ai/models/{_model}",
|
||||
force=True,
|
||||
agent._buffer_vprint(
|
||||
f" Check which providers support tools: https://openrouter.ai/models/{_model}"
|
||||
)
|
||||
|
||||
# Check for interrupt before deciding to retry
|
||||
@@ -2489,11 +2629,10 @@ def run_conversation(
|
||||
# user later enables extra usage the 1M limit
|
||||
# should come back automatically.
|
||||
compressor._context_probe_persistable = False
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Anthropic long-context tier "
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Anthropic long-context tier "
|
||||
f"requires extra usage — reducing context: "
|
||||
f"{old_ctx:,} → {_reduced_ctx:,} tokens",
|
||||
force=True,
|
||||
f"{old_ctx:,} → {_reduced_ctx:,} tokens"
|
||||
)
|
||||
|
||||
compression_attempts += 1
|
||||
@@ -2509,7 +2648,7 @@ def run_conversation(
|
||||
# messages to the new session, not skipping them.
|
||||
conversation_history = None
|
||||
if len(messages) < original_len or old_ctx > _reduced_ctx:
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
|
||||
f"(was {old_ctx:,}), retrying..."
|
||||
)
|
||||
@@ -2538,7 +2677,12 @@ def run_conversation(
|
||||
base_url=getattr(agent, "base_url", None),
|
||||
)
|
||||
if not pool_may_recover:
|
||||
agent._emit_status("⚠️ Rate limited — switching to fallback provider...")
|
||||
if classified.reason == FailoverReason.billing:
|
||||
agent._buffer_status(
|
||||
"⚠️ Billing or credits exhausted — switching to fallback provider..."
|
||||
)
|
||||
else:
|
||||
agent._buffer_status("⚠️ Rate limited — switching to fallback provider...")
|
||||
if agent._try_activate_fallback(reason=classified.reason):
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -2650,6 +2794,8 @@ def run_conversation(
|
||||
if is_payload_too_large:
|
||||
compression_attempts += 1
|
||||
if compression_attempts > max_compression_attempts:
|
||||
# Terminal — surface the buffered retry trace.
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached for payload-too-large error.", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
|
||||
logger.error(f"{agent.log_prefix}413 compression failed after {max_compression_attempts} attempts.")
|
||||
@@ -2663,7 +2809,7 @@ def run_conversation(
|
||||
"failed": True,
|
||||
"compression_exhausted": True,
|
||||
}
|
||||
agent._emit_status(f"⚠️ Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...")
|
||||
agent._buffer_status(f"⚠️ Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...")
|
||||
|
||||
original_len = len(messages)
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
@@ -2676,11 +2822,14 @@ def run_conversation(
|
||||
conversation_history = None
|
||||
|
||||
if len(messages) < original_len:
|
||||
agent._emit_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
|
||||
agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
|
||||
time.sleep(2) # Brief pause between compression retries
|
||||
restart_with_compressed_messages = True
|
||||
break
|
||||
else:
|
||||
# Terminal — surface buffered context so the user
|
||||
# sees what compression attempts were made.
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Payload too large and cannot compress further.", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
|
||||
logger.error(f"{agent.log_prefix}413 payload too large. Cannot compress further.")
|
||||
@@ -2724,16 +2873,16 @@ def run_conversation(
|
||||
# touching context_length or triggering compression.
|
||||
safe_out = max(1, available_out - 64) # small safety margin
|
||||
agent._ephemeral_max_output_tokens = safe_out
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Output cap too large for current prompt — "
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Output cap too large for current prompt — "
|
||||
f"retrying with max_tokens={safe_out:,} "
|
||||
f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})",
|
||||
force=True,
|
||||
f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})"
|
||||
)
|
||||
# Still count against compression_attempts so we don't
|
||||
# loop forever if the error keeps recurring.
|
||||
compression_attempts += 1
|
||||
if compression_attempts > max_compression_attempts:
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
|
||||
logger.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
|
||||
@@ -2750,9 +2899,13 @@ def run_conversation(
|
||||
restart_with_compressed_messages = True
|
||||
break
|
||||
|
||||
# Error is about the INPUT being too large — reduce context_length.
|
||||
# Try to parse the actual limit from the error message
|
||||
parsed_limit = parse_context_limit_from_error(error_msg)
|
||||
# Error is about the INPUT being too large. Only reduce
|
||||
# context_length when the provider explicitly reports the
|
||||
# real lower limit. If the provider only says "input
|
||||
# exceeds the context window", keep the configured window
|
||||
# and try compression; guessing probe tiers can incorrectly
|
||||
# turn a user-configured 1M window into 256K/128K/64K.
|
||||
new_ctx = get_context_length_from_provider_error(error_msg, old_ctx)
|
||||
_provider_lower = (getattr(agent, "provider", "") or "").lower()
|
||||
_base_lower = (getattr(agent, "base_url", "") or "").rstrip("/").lower()
|
||||
is_minimax_provider = (
|
||||
@@ -2764,24 +2917,12 @@ def run_conversation(
|
||||
)
|
||||
minimax_delta_only_overflow = (
|
||||
is_minimax_provider
|
||||
and parsed_limit is None
|
||||
and new_ctx is None
|
||||
and "context window exceeds limit (" in error_msg
|
||||
)
|
||||
if parsed_limit and parsed_limit < old_ctx:
|
||||
new_ctx = parsed_limit
|
||||
agent._vprint(f"{agent.log_prefix}Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})", force=True)
|
||||
elif minimax_delta_only_overflow:
|
||||
new_ctx = old_ctx
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}Provider reported overflow amount only; "
|
||||
f"keeping context_length at {old_ctx:,} tokens and compressing.",
|
||||
force=True,
|
||||
)
|
||||
else:
|
||||
# Step down to the next probe tier
|
||||
new_ctx = get_next_probe_tier(old_ctx)
|
||||
|
||||
if new_ctx and new_ctx < old_ctx:
|
||||
if new_ctx is not None:
|
||||
agent._buffer_vprint(f"Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})")
|
||||
compressor.update_model(
|
||||
model=agent.model,
|
||||
context_length=new_ctx,
|
||||
@@ -2791,23 +2932,26 @@ def run_conversation(
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
# Context probing flags — only set on built-in
|
||||
# compressor (plugin engines manage their own).
|
||||
# compressor (plugin engines manage their own). This
|
||||
# value came from the provider, so it is safe to cache.
|
||||
if hasattr(compressor, "_context_probed"):
|
||||
compressor._context_probed = True
|
||||
# Only persist limits parsed from the provider's
|
||||
# error message (a real number). Guessed fallback
|
||||
# tiers from get_next_probe_tier() should stay
|
||||
# in-memory only — persisting them pollutes the
|
||||
# cache with wrong values.
|
||||
compressor._context_probe_persistable = bool(
|
||||
parsed_limit and parsed_limit == new_ctx
|
||||
)
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens", force=True)
|
||||
compressor._context_probe_persistable = True
|
||||
agent._buffer_vprint(f"⚠️ Context length exceeded — using provider limit: {old_ctx:,} → {new_ctx:,} tokens")
|
||||
elif minimax_delta_only_overflow:
|
||||
agent._buffer_vprint(
|
||||
f"Provider reported overflow amount only; "
|
||||
f"keeping context_length at {old_ctx:,} tokens and compressing."
|
||||
)
|
||||
else:
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ Context length exceeded at minimum tier — attempting compression...", force=True)
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Context length exceeded, but provider did not report a max context length; "
|
||||
f"keeping context_length at {old_ctx:,} tokens and compressing."
|
||||
)
|
||||
|
||||
compression_attempts += 1
|
||||
if compression_attempts > max_compression_attempts:
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True)
|
||||
logger.error(f"{agent.log_prefix}Context compression failed after {max_compression_attempts} attempts.")
|
||||
@@ -2821,7 +2965,7 @@ def run_conversation(
|
||||
"failed": True,
|
||||
"compression_exhausted": True,
|
||||
}
|
||||
agent._emit_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
|
||||
agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
|
||||
|
||||
original_len = len(messages)
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
@@ -2835,12 +2979,13 @@ def run_conversation(
|
||||
|
||||
if len(messages) < original_len or new_ctx and new_ctx < old_ctx:
|
||||
if len(messages) < original_len:
|
||||
agent._emit_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
|
||||
agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
|
||||
time.sleep(2) # Brief pause between compression retries
|
||||
restart_with_compressed_messages = True
|
||||
break
|
||||
else:
|
||||
# Can't compress further and already at minimum tier
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Context length exceeded and cannot compress further.", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 💡 The conversation has accumulated too much content. Try /new to start fresh, or /compress to manually trigger compression.", force=True)
|
||||
logger.error(f"{agent.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.")
|
||||
@@ -2879,6 +3024,21 @@ def run_conversation(
|
||||
# ssl.SSLError explicitly so the error classifier's
|
||||
# retryable=True mapping takes effect instead.
|
||||
and not isinstance(api_error, ssl.SSLError)
|
||||
# Provider/SDK "NoneType is not iterable" failures are
|
||||
# shape mismatches from upstream (e.g. chatgpt.com Codex
|
||||
# backend response.completed.output=null) — not local
|
||||
# programming bugs. Even after #33042 made our own
|
||||
# consumer immune, third-party shims and mocked clients
|
||||
# can still surface this shape via TypeError. Treat
|
||||
# them as retryable so the error classifier's normal
|
||||
# retry/fallback path runs instead of killing the turn
|
||||
# as non-retryable (which left Telegram users staring
|
||||
# at a bare "Non-retryable error" with no recovery).
|
||||
and not (
|
||||
isinstance(api_error, TypeError)
|
||||
and "nonetype" in str(api_error).lower()
|
||||
and "not iterable" in str(api_error).lower()
|
||||
)
|
||||
)
|
||||
# ``FailoverReason.billing`` (HTTP 402) is NOT in this
|
||||
# exclusion set. By the time we reach this block:
|
||||
@@ -2914,7 +3074,10 @@ def run_conversation(
|
||||
if is_client_error:
|
||||
# Try fallback before aborting — a different provider
|
||||
# may not have the same issue (rate limit, auth, etc.)
|
||||
agent._emit_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
|
||||
else:
|
||||
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -2924,16 +3087,38 @@ def run_conversation(
|
||||
agent._dump_api_request_debug(
|
||||
api_kwargs, reason="non_retryable_client_error", error=api_error,
|
||||
)
|
||||
agent._emit_status(
|
||||
f"❌ Non-retryable error (HTTP {status_code}): "
|
||||
f"{agent._summarize_api_error(api_error)}"
|
||||
)
|
||||
# Terminal — flush buffered context so the user sees
|
||||
# what was tried before the abort.
|
||||
agent._flush_status_buffer()
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._emit_status(
|
||||
f"❌ Provider safety filter blocked this request: "
|
||||
f"{agent._summarize_api_error(api_error)}"
|
||||
)
|
||||
else:
|
||||
agent._emit_status(
|
||||
f"❌ Non-retryable error (HTTP {status_code}): "
|
||||
f"{agent._summarize_api_error(api_error)}"
|
||||
)
|
||||
agent._vprint(f"{agent.log_prefix}❌ Non-retryable client error (HTTP {status_code}). Aborting.", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 🌐 Endpoint: {_base}", force=True)
|
||||
# Actionable guidance for common auth errors
|
||||
if classified.is_auth or classified.reason == FailoverReason.billing:
|
||||
if _provider in {"openai-codex", "xai-oauth", "nous"} and status_code == 401:
|
||||
if classified.reason == FailoverReason.billing and _print_billing_or_entitlement_guidance(
|
||||
agent,
|
||||
capability="model access",
|
||||
provider=_provider,
|
||||
base_url=str(_base),
|
||||
model=_model,
|
||||
):
|
||||
pass
|
||||
elif _provider == "nous" and _print_nous_entitlement_guidance(
|
||||
agent,
|
||||
"Nous model access",
|
||||
):
|
||||
pass
|
||||
elif _provider in {"openai-codex", "xai-oauth", "nous"} and status_code == 401:
|
||||
if _provider == "openai-codex":
|
||||
agent._vprint(f"{agent.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True)
|
||||
@@ -2961,6 +3146,28 @@ def run_conversation(
|
||||
agent._vprint(f"{agent.log_prefix} • Check credits: https://openrouter.ai/settings/credits", force=True)
|
||||
else:
|
||||
agent._vprint(f"{agent.log_prefix} 💡 This type of error won't be fixed by retrying.", force=True)
|
||||
# Content-policy blocks deserve their own actionable
|
||||
# guidance — neither "fix your API key" nor "retry won't
|
||||
# help" tells the user what to actually do. The provider
|
||||
# has refused this specific prompt, so the recovery is
|
||||
# either a rephrase or routing to a different model.
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 The provider's safety filter rejected this specific prompt.",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} • Try rephrasing the request, narrowing the context, or splitting into smaller steps.",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} • Configure a fallback provider so future blocks route automatically:",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} hermes fallback add (interactive picker — same as `hermes model`)",
|
||||
force=True,
|
||||
)
|
||||
logger.error(f"{agent.log_prefix}Non-retryable client error: {api_error}")
|
||||
# Skip session persistence when the error is likely
|
||||
# context-overflow related (status 400 + large session).
|
||||
@@ -2975,6 +3182,23 @@ def run_conversation(
|
||||
)
|
||||
else:
|
||||
agent._persist_session(messages, conversation_history)
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
_summary = agent._summarize_api_error(api_error)
|
||||
_policy_response = (
|
||||
f"⚠️ The model provider's safety filter blocked this request "
|
||||
f"(not a Hermes/gateway failure).\n\n"
|
||||
f"Provider message: {_summary}\n\n"
|
||||
f"Try rephrasing the request, narrowing the context, or "
|
||||
f"adding a fallback provider with `hermes fallback add`."
|
||||
)
|
||||
return {
|
||||
"final_response": _policy_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": f"content_policy_blocked: {_summary}",
|
||||
}
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": messages,
|
||||
@@ -2996,14 +3220,32 @@ def run_conversation(
|
||||
retry_count = 0
|
||||
continue
|
||||
# Try fallback before giving up entirely
|
||||
agent._emit_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
primary_recovery_attempted = False
|
||||
continue
|
||||
# Terminal — flush buffered retry/fallback trace.
|
||||
agent._flush_status_buffer()
|
||||
_final_summary = agent._summarize_api_error(api_error)
|
||||
if is_rate_limited:
|
||||
_billing_guidance = ""
|
||||
if classified.reason == FailoverReason.billing:
|
||||
agent._emit_status(f"❌ Billing or credits exhausted — {_final_summary}")
|
||||
_billing_guidance = _billing_or_entitlement_message(
|
||||
capability="model access",
|
||||
provider=_provider,
|
||||
base_url=str(_base),
|
||||
model=_model,
|
||||
)
|
||||
_print_billing_or_entitlement_guidance(
|
||||
agent,
|
||||
capability="model access",
|
||||
provider=_provider,
|
||||
base_url=str(_base),
|
||||
model=_model,
|
||||
)
|
||||
elif is_rate_limited:
|
||||
agent._emit_status(f"❌ Rate limited after {max_retries} retries — {_final_summary}")
|
||||
else:
|
||||
agent._emit_status(f"❌ API failed after {max_retries} retries — {_final_summary}")
|
||||
@@ -3048,7 +3290,12 @@ def run_conversation(
|
||||
api_kwargs, reason="max_retries_exhausted", error=api_error,
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
|
||||
if classified.reason == FailoverReason.billing:
|
||||
_final_response = f"Billing or credits exhausted: {_final_summary}"
|
||||
if _billing_guidance:
|
||||
_final_response += f"\n\n{_billing_guidance}"
|
||||
else:
|
||||
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
|
||||
if _is_stream_drop:
|
||||
_final_response += (
|
||||
"\n\nThe provider's stream connection keeps "
|
||||
@@ -3080,9 +3327,9 @@ def run_conversation(
|
||||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
if is_rate_limited:
|
||||
agent._emit_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
|
||||
agent._buffer_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
|
||||
else:
|
||||
agent._emit_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
|
||||
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
|
||||
logger.warning(
|
||||
"Retrying API call in %ss (attempt %s/%s) %s error=%s",
|
||||
wait_time,
|
||||
@@ -3241,14 +3488,15 @@ def run_conversation(
|
||||
if has_incomplete_scratchpad(assistant_message.content or ""):
|
||||
agent._incomplete_scratchpad_retries += 1
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)")
|
||||
agent._buffer_vprint(f"⚠️ Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)")
|
||||
|
||||
if agent._incomplete_scratchpad_retries <= 2:
|
||||
agent._vprint(f"{agent.log_prefix}🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...")
|
||||
agent._buffer_vprint(f"🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...")
|
||||
# Don't add the broken message, just retry
|
||||
continue
|
||||
else:
|
||||
# Max retries - discard this turn and save as partial
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.", force=True)
|
||||
agent._incomplete_scratchpad_retries = 0
|
||||
|
||||
@@ -3356,9 +3604,10 @@ def run_conversation(
|
||||
available = ", ".join(sorted(agent.valid_tool_names))
|
||||
invalid_name = invalid_tool_calls[0]
|
||||
invalid_preview = invalid_name[:80] + "..." if len(invalid_name) > 80 else invalid_name
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ Unknown tool '{invalid_preview}' — sending error to model for agent-correction ({agent._invalid_tool_retries}/3)")
|
||||
agent._buffer_vprint(f"⚠️ Unknown tool '{invalid_preview}' — sending error to model for agent-correction ({agent._invalid_tool_retries}/3)")
|
||||
|
||||
if agent._invalid_tool_retries >= 3:
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.", force=True)
|
||||
agent._invalid_tool_retries = 0
|
||||
agent._persist_session(messages, conversation_history)
|
||||
@@ -3442,16 +3691,16 @@ def run_conversation(
|
||||
agent._invalid_json_retries += 1
|
||||
|
||||
tool_name, error_msg = invalid_json_args[0]
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ Invalid JSON in tool call arguments for '{tool_name}': {error_msg}")
|
||||
agent._buffer_vprint(f"⚠️ Invalid JSON in tool call arguments for '{tool_name}': {error_msg}")
|
||||
|
||||
if agent._invalid_json_retries < 3:
|
||||
agent._vprint(f"{agent.log_prefix}🔄 Retrying API call ({agent._invalid_json_retries}/3)...")
|
||||
agent._buffer_vprint(f"🔄 Retrying API call ({agent._invalid_json_retries}/3)...")
|
||||
# Don't add anything to messages, just retry the API call
|
||||
continue
|
||||
else:
|
||||
# Instead of returning partial, inject tool error results so the model can recover.
|
||||
# Using tool results (not user messages) preserves role alternation.
|
||||
agent._vprint(f"{agent.log_prefix}⚠️ Injecting recovery tool results for invalid JSON...")
|
||||
agent._buffer_vprint(f"⚠️ Injecting recovery tool results for invalid JSON...")
|
||||
agent._invalid_json_retries = 0 # Reset for next attempt
|
||||
|
||||
# Append the assistant message with its (broken) tool_calls
|
||||
@@ -3759,7 +4008,7 @@ def run_conversation(
|
||||
"Empty response after tool calls — nudging model "
|
||||
"to continue processing"
|
||||
)
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
"⚠️ Model returned empty after tool calls — "
|
||||
"nudging to continue"
|
||||
)
|
||||
@@ -3805,7 +4054,7 @@ def run_conversation(
|
||||
"prefilling to continue (%d/2)",
|
||||
agent._thinking_prefill_retries,
|
||||
)
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"↻ Thinking-only response — prefilling to continue "
|
||||
f"({agent._thinking_prefill_retries}/2)"
|
||||
)
|
||||
@@ -3840,7 +4089,7 @@ def run_conversation(
|
||||
"retry %d/3 (model=%s)",
|
||||
agent._empty_content_retries, agent.model,
|
||||
)
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"⚠️ Empty response from model — retrying "
|
||||
f"({agent._empty_content_retries}/3)"
|
||||
)
|
||||
@@ -3859,13 +4108,13 @@ def run_conversation(
|
||||
agent._empty_content_retries, agent.model,
|
||||
agent.provider,
|
||||
)
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
"⚠️ Model returning empty responses — "
|
||||
"switching to fallback provider..."
|
||||
)
|
||||
if agent._try_activate_fallback():
|
||||
agent._empty_content_retries = 0
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"↻ Switched to fallback: {agent.model} "
|
||||
f"({agent.provider})"
|
||||
)
|
||||
@@ -3879,6 +4128,9 @@ def run_conversation(
|
||||
# Exhausted retries and fallback chain (or no
|
||||
# fallback configured). Fall through to the
|
||||
# "(empty)" terminal.
|
||||
# Surface the buffered retry/fallback trace so the
|
||||
# user can see what was attempted before "(empty)".
|
||||
agent._flush_status_buffer()
|
||||
_turn_exit_reason = "empty_response_exhausted"
|
||||
reasoning_text = agent._extract_reasoning(assistant_message)
|
||||
agent._drop_trailing_empty_response_scaffolding(messages)
|
||||
@@ -3923,6 +4175,9 @@ def run_conversation(
|
||||
# Reset retry counter/signature on successful content
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
# Successful content reached — drop any buffered retry
|
||||
# status from earlier failed attempts in this turn.
|
||||
agent._clear_status_buffer()
|
||||
|
||||
if (
|
||||
agent.api_mode == "codex_responses"
|
||||
@@ -4306,6 +4561,7 @@ def run_conversation(
|
||||
original_user_message=original_user_message,
|
||||
final_response=final_response,
|
||||
interrupted=interrupted,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Background memory/skill review — runs AFTER the response is delivered
|
||||
|
||||
+49
-196
@@ -458,43 +458,6 @@ class CredentialPool:
|
||||
self._persist()
|
||||
return updated
|
||||
|
||||
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
|
||||
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
|
||||
|
||||
OAuth refresh tokens are single-use. When something external (e.g.
|
||||
Claude Code CLI, or another profile's pool) refreshes the token, it
|
||||
writes the new pair to ~/.claude/.credentials.json. The pool entry's
|
||||
refresh token becomes stale. This method detects that and syncs.
|
||||
"""
|
||||
if self.provider != "anthropic" or entry.source != "claude_code":
|
||||
return entry
|
||||
try:
|
||||
from agent.anthropic_adapter import read_claude_code_credentials
|
||||
creds = read_claude_code_credentials()
|
||||
if not creds:
|
||||
return entry
|
||||
file_refresh = creds.get("refreshToken", "")
|
||||
file_access = creds.get("accessToken", "")
|
||||
file_expires = creds.get("expiresAt", 0)
|
||||
# If the credentials file has a different token pair, sync it
|
||||
if file_refresh and file_refresh != entry.refresh_token:
|
||||
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
|
||||
updated = replace(
|
||||
entry,
|
||||
access_token=file_access,
|
||||
refresh_token=file_refresh,
|
||||
expires_at_ms=file_expires,
|
||||
last_status=None,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
)
|
||||
self._replace_entry(entry, updated)
|
||||
self._persist()
|
||||
return updated
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync from credentials file: %s", exc)
|
||||
return entry
|
||||
|
||||
def _sync_codex_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
|
||||
"""Sync a Codex device_code pool entry from auth.json if tokens differ.
|
||||
|
||||
@@ -784,32 +747,11 @@ class CredentialPool:
|
||||
return None
|
||||
|
||||
try:
|
||||
if self.provider == "anthropic":
|
||||
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
|
||||
|
||||
refreshed = refresh_anthropic_oauth_pure(
|
||||
entry.refresh_token,
|
||||
use_json=entry.source.endswith("hermes_pkce"),
|
||||
)
|
||||
updated = replace(
|
||||
entry,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
)
|
||||
# Keep ~/.claude/.credentials.json in sync so that the
|
||||
# fallback path (resolve_anthropic_token) and other profiles
|
||||
# see the latest tokens.
|
||||
if entry.source == "claude_code":
|
||||
try:
|
||||
from agent.anthropic_adapter import _write_claude_code_credentials
|
||||
_write_claude_code_credentials(
|
||||
refreshed["access_token"],
|
||||
refreshed["refresh_token"],
|
||||
refreshed["expires_at_ms"],
|
||||
)
|
||||
except Exception as wexc:
|
||||
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
from agent.plugin_registries import registries as _cph_reg2
|
||||
_hook = _cph_reg2.get_credential_pool_hook(self.provider)
|
||||
if _hook is not None and _hook.refresh_oauth is not None:
|
||||
updated = _hook.refresh_oauth(entry, pool=self)
|
||||
elif self.provider == "openai-codex":
|
||||
# Adopt fresher tokens from auth.json before spending the
|
||||
# refresh_token — single-use tokens consumed by another Hermes
|
||||
@@ -864,46 +806,18 @@ class CredentialPool:
|
||||
return entry
|
||||
except Exception as exc:
|
||||
logger.debug("Credential refresh failed for %s/%s: %s", self.provider, entry.id, exc)
|
||||
# For anthropic claude_code entries: the refresh token may have been
|
||||
# consumed by another process. Check if ~/.claude/.credentials.json
|
||||
# has a newer token pair and retry once.
|
||||
if self.provider == "anthropic" and entry.source == "claude_code":
|
||||
synced = self._sync_anthropic_entry_from_credentials_file(entry)
|
||||
if synced.refresh_token != entry.refresh_token:
|
||||
logger.debug("Retrying refresh with synced token from credentials file")
|
||||
try:
|
||||
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
|
||||
refreshed = refresh_anthropic_oauth_pure(
|
||||
synced.refresh_token,
|
||||
use_json=synced.source.endswith("hermes_pkce"),
|
||||
)
|
||||
updated = replace(
|
||||
synced,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
last_status=STATUS_OK,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
)
|
||||
self._replace_entry(synced, updated)
|
||||
self._persist()
|
||||
try:
|
||||
from agent.anthropic_adapter import _write_claude_code_credentials
|
||||
_write_claude_code_credentials(
|
||||
refreshed["access_token"],
|
||||
refreshed["refresh_token"],
|
||||
refreshed["expires_at_ms"],
|
||||
)
|
||||
except Exception as wexc:
|
||||
logger.debug("Failed to write refreshed token to credentials file (retry path): %s", wexc)
|
||||
return updated
|
||||
except Exception as retry_exc:
|
||||
logger.debug("Retry refresh also failed: %s", retry_exc)
|
||||
elif not self._entry_needs_refresh(synced):
|
||||
# Credentials file had a valid (non-expired) token — use it directly
|
||||
logger.debug("Credentials file has valid token, using without refresh")
|
||||
return synced
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
# The hook's refresh_oauth already handles retry-with-sync internally,
|
||||
# so if we got here it means a non-hook provider failed.
|
||||
from agent.plugin_registries import registries as _cph_reg3
|
||||
_hook = _cph_reg3.get_credential_pool_hook(self.provider)
|
||||
if _hook is not None and _hook.sync_from_credentials_file is not None:
|
||||
# Give the hook a chance to sync from external file
|
||||
synced = _hook.sync_from_credentials_file(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
self._replace_entry(entry, synced)
|
||||
self._persist()
|
||||
# For xai-oauth: same race as nous — another process may have
|
||||
# consumed the refresh token between our proactive sync and the
|
||||
# HTTP call. Re-check auth.json and adopt the fresh tokens if
|
||||
@@ -1124,10 +1038,11 @@ class CredentialPool:
|
||||
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
|
||||
if entry.auth_type != AUTH_TYPE_OAUTH:
|
||||
return False
|
||||
if self.provider == "anthropic":
|
||||
if entry.expires_at_ms is None:
|
||||
return False
|
||||
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
from agent.plugin_registries import registries as _cph_reg
|
||||
_hook = _cph_reg.get_credential_pool_hook(self.provider)
|
||||
if _hook is not None and _hook.needs_refresh is not None:
|
||||
return _hook.needs_refresh(entry)
|
||||
if self.provider == "openai-codex":
|
||||
return _codex_access_token_is_expiring(
|
||||
entry.access_token,
|
||||
@@ -1160,12 +1075,16 @@ class CredentialPool:
|
||||
cleared_any = False
|
||||
available: List[PooledCredential] = []
|
||||
for entry in self._entries:
|
||||
# For anthropic claude_code entries, sync from the credentials file
|
||||
# before any status/refresh checks. This picks up tokens refreshed
|
||||
# by other processes (Claude Code CLI, other Hermes profiles).
|
||||
if (self.provider == "anthropic" and entry.source == "claude_code"
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
# Sync exhausted entries from external credentials files before
|
||||
# status/refresh checks. This picks up tokens refreshed by other
|
||||
# processes (e.g. Claude Code CLI, other Hermes profiles).
|
||||
from agent.plugin_registries import registries as _cph_reg4
|
||||
_avail_hook = _cph_reg4.get_credential_pool_hook(self.provider)
|
||||
if (_avail_hook is not None
|
||||
and _avail_hook.sync_from_credentials_file is not None
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_anthropic_entry_from_credentials_file(entry)
|
||||
synced = _avail_hook.sync_from_credentials_file(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
cleared_any = True
|
||||
@@ -1515,84 +1434,15 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
def _is_suppressed(_p, _s): # type: ignore[misc]
|
||||
return False
|
||||
|
||||
if provider == "anthropic":
|
||||
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
|
||||
# when the user has explicitly configured anthropic as their provider.
|
||||
# Without this gate, auxiliary client fallback chains silently read
|
||||
# ~/.claude/.credentials.json without user consent. See PR #4210.
|
||||
try:
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
if not is_provider_explicitly_configured("anthropic"):
|
||||
return changed, active_sources
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# API-key vs OAuth is a user-visible choice at `hermes setup` ("Claude
|
||||
# Pro/Max subscription" vs "Anthropic API key"). The signal that the
|
||||
# user picked the API-key path is: ANTHROPIC_API_KEY set in the env,
|
||||
# AND no OAuth env vars set — `save_anthropic_api_key()` writes the
|
||||
# API key and zeros ANTHROPIC_TOKEN; `save_anthropic_oauth_token()`
|
||||
# does the inverse. When that signal is present we MUST NOT seed
|
||||
# autodiscovered OAuth tokens (~/.claude/.credentials.json from the
|
||||
# Claude Code CLI, hermes_pkce creds from a previous OAuth login)
|
||||
# into the anthropic pool — otherwise rotation on a 401/429 silently
|
||||
# flips the session onto an OAuth credential, which forces the Claude
|
||||
# Code identity injection, `mcp_` tool-name rewrite, and claude-cli
|
||||
# User-Agent header (`agent/anthropic_adapter.py:2128`). Users who
|
||||
# explicitly opted into the API-key path are explicitly opting OUT of
|
||||
# that masquerade. Prefer ~/.hermes/.env over os.environ for the
|
||||
# same reason `_seed_from_env` does — that's the authoritative file
|
||||
# that `hermes setup` writes.
|
||||
_env_file = load_env()
|
||||
|
||||
def _env_val(key: str) -> str:
|
||||
return (_env_file.get(key) or os.environ.get(key) or "").strip()
|
||||
|
||||
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
|
||||
anthropic_oauth_env = (
|
||||
_env_val("ANTHROPIC_TOKEN") or _env_val("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
# ── Plugin-registered credential pool hooks ──
|
||||
from agent.plugin_registries import registries as _cp_reg
|
||||
_cp_hook = _cp_reg.get_credential_pool_hook(provider)
|
||||
if _cp_hook is not None and _cp_hook.discover_credentials is not None:
|
||||
hook_changed, hook_sources = _cp_hook.discover_credentials(
|
||||
entries, provider, _is_suppressed,
|
||||
)
|
||||
api_key_path_explicit = bool(anthropic_api_key and not anthropic_oauth_env)
|
||||
|
||||
if api_key_path_explicit:
|
||||
# Prune any stale autodiscovered OAuth entries that may have been
|
||||
# seeded into the on-disk pool during a previous OAuth session.
|
||||
# Without this, switching OAuth -> API key at setup leaves the
|
||||
# OAuth entries dormant in auth.json forever and rotation on a
|
||||
# transient 401 could revive them.
|
||||
retained = [
|
||||
entry for entry in entries
|
||||
if entry.source not in {"hermes_pkce", "claude_code"}
|
||||
]
|
||||
if len(retained) != len(entries):
|
||||
entries[:] = retained
|
||||
changed = True
|
||||
return changed, active_sources
|
||||
|
||||
from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials
|
||||
|
||||
for source_name, creds in (
|
||||
("hermes_pkce", read_hermes_oauth_credentials()),
|
||||
("claude_code", read_claude_code_credentials()),
|
||||
):
|
||||
if creds and creds.get("accessToken"):
|
||||
if _is_suppressed(provider, source_name):
|
||||
continue
|
||||
active_sources.add(source_name)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
source_name,
|
||||
{
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"access_token": creds.get("accessToken", ""),
|
||||
"refresh_token": creds.get("refreshToken"),
|
||||
"expires_at_ms": creds.get("expiresAt"),
|
||||
"label": label_from_token(creds.get("accessToken", ""), source_name),
|
||||
},
|
||||
)
|
||||
|
||||
changed |= hook_changed
|
||||
active_sources |= hook_sources
|
||||
elif provider == "nous":
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
has_runtime_material = bool(
|
||||
@@ -1903,12 +1753,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
env_url = _get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip("/")
|
||||
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
if provider == "anthropic":
|
||||
env_vars = [
|
||||
"ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
]
|
||||
# ── Plugin-registered credential pool hooks: env var order override ──
|
||||
from agent.plugin_registries import registries as _env_reg
|
||||
_env_hook = _env_reg.get_credential_pool_hook(provider)
|
||||
if _env_hook is not None and _env_hook.env_var_order is not None:
|
||||
env_vars = _env_hook.env_var_order
|
||||
|
||||
for env_var in env_vars:
|
||||
# Prefer ~/.hermes/.env over os.environ
|
||||
@@ -1919,7 +1768,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
if _is_source_suppressed(provider, source):
|
||||
continue
|
||||
active_sources.add(source)
|
||||
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
|
||||
# ── Plugin-registered credential pool hooks: auth type detection ──
|
||||
if _env_hook is not None and _env_hook.detect_auth_type is not None:
|
||||
auth_type = _env_hook.detect_auth_type(token)
|
||||
else:
|
||||
auth_type = AUTH_TYPE_API_KEY
|
||||
base_url = env_url or pconfig.inference_base_url
|
||||
if provider == "kimi-coding":
|
||||
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
|
||||
|
||||
+20
-1
@@ -390,7 +390,26 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"(verification scripts, fixture generators, probes)\n"
|
||||
" Then archive the old sibling. Use `terminal` with `mkdir -p "
|
||||
"~/.hermes/skills/<umbrella>/references/ && mv ... <umbrella>/"
|
||||
"references/<topic>.md` (or templates/ / scripts/).\n"
|
||||
"references/<topic>.md` (or templates/ / scripts/).\n\n"
|
||||
"Package integrity — not optional:\n"
|
||||
"Before demoting or archiving a skill, inspect it as a COMPLETE "
|
||||
"directory package, not just SKILL.md. A skill root may include "
|
||||
"`references/`, `templates/`, `scripts/`, and `assets/`; `skill_view` "
|
||||
"discovers those relative to the skill root. A reference markdown file "
|
||||
"inside another skill is NOT a new skill root and does not get its own "
|
||||
"linked-file discovery.\n"
|
||||
"If the source skill has support files OR SKILL.md contains relative "
|
||||
"links such as `references/...`, `templates/...`, `scripts/...`, or "
|
||||
"`assets/...`, DO NOT flatten only SKILL.md into "
|
||||
"`<umbrella>/references/<old>.md`. Choose one safe path instead:\n"
|
||||
" • keep it as a standalone skill, OR\n"
|
||||
" • fully merge it by re-homing every needed support file into the "
|
||||
"umbrella's canonical `references/`, `templates/`, `scripts/`, or "
|
||||
"`assets/` directories AND rewrite the destination instructions to "
|
||||
"the new paths, OR\n"
|
||||
" • archive the entire original skill package unchanged.\n"
|
||||
"Never leave archived/demoted instructions pointing at files that were "
|
||||
"left behind under the old skill directory.\n"
|
||||
"4. Also flag skills whose NAME is too narrow (contains a PR number, "
|
||||
"a feature codename, a specific error string, an 'audit' / "
|
||||
"'diagnosis' / 'salvage' session artifact). These almost always "
|
||||
|
||||
@@ -904,10 +904,6 @@ def get_cute_tool_message(
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "web_crawl":
|
||||
url = args.get("url", "")
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
return _wrap(f"┊ 🕸️ crawl {_trunc(domain, 35)} {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
|
||||
@@ -44,9 +44,10 @@ class FailoverReason(enum.Enum):
|
||||
payload_too_large = "payload_too_large" # 413 — compress payload
|
||||
image_too_large = "image_too_large" # Native image part exceeds provider's per-image limit — shrink and retry
|
||||
|
||||
# Model
|
||||
# Model / provider policy
|
||||
model_not_found = "model_not_found" # 404 or invalid model — fallback to different model
|
||||
provider_policy_blocked = "provider_policy_blocked" # Aggregator (e.g. OpenRouter) blocked the only endpoint due to account data/privacy policy
|
||||
content_policy_blocked = "content_policy_blocked" # Provider safety filter rejected this prompt — deterministic per-request, don't retry unchanged
|
||||
|
||||
# Request format
|
||||
format_error = "format_error" # 400 bad request — abort or strip + retry
|
||||
@@ -97,13 +98,20 @@ _BILLING_PATTERNS = [
|
||||
"insufficient_quota",
|
||||
"insufficient balance",
|
||||
"credit balance",
|
||||
"credits exhausted",
|
||||
"credits have been exhausted",
|
||||
"no usable credits",
|
||||
"top up your credits",
|
||||
"payment required",
|
||||
"billing hard limit",
|
||||
"exceeded your current quota",
|
||||
"account is deactivated",
|
||||
"plan does not include",
|
||||
"out of funds",
|
||||
"run out of funds",
|
||||
"balance_depleted",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
]
|
||||
|
||||
# Patterns that indicate rate limiting (transient, will resolve)
|
||||
@@ -282,6 +290,45 @@ _PROVIDER_POLICY_BLOCKED_PATTERNS = [
|
||||
"no endpoints found matching your data policy",
|
||||
]
|
||||
|
||||
# Provider content-policy / safety-filter blocks. Distinct from
|
||||
# ``provider_policy_blocked`` above (which is an OpenRouter *account*-level
|
||||
# data/privacy guardrail) — these are *per-prompt* safety decisions made by
|
||||
# the upstream model provider. They are deterministic for the unchanged
|
||||
# request, so retrying the same prompt three times just reproduces the same
|
||||
# block and burns paid attempts on a refusal. The recovery is to switch to a
|
||||
# configured fallback model/provider immediately, or surface the block to
|
||||
# the user with actionable guidance if no fallback exists.
|
||||
#
|
||||
# Patterns are intentionally narrow — each phrase is a verbatim string from
|
||||
# a specific provider's safety pipeline, not a generic word like "policy" or
|
||||
# "violation" that could collide with billing/auth/format errors:
|
||||
# • OpenAI Codex cybersecurity refusal (gpt-5.5, the case from #18028)
|
||||
# • OpenAI moderation refusal ("violates our usage policies", with
|
||||
# "usage policies" disambiguating from billing's "exceeded ... policy")
|
||||
# • Anthropic safety refusal ("prompt was flagged by ... safety system")
|
||||
# • OpenAI Responses content filter
|
||||
_CONTENT_POLICY_BLOCKED_PATTERNS = [
|
||||
# OpenAI Codex (#18028) — message may arrive without an HTTP status
|
||||
"flagged for possible cybersecurity risk",
|
||||
"trusted access for cyber",
|
||||
# OpenAI moderation — chat completions / responses
|
||||
"violates our usage policies",
|
||||
"violates openai's usage policies",
|
||||
"your request was flagged by",
|
||||
# Anthropic safety system
|
||||
"prompt was flagged by our safety",
|
||||
"responses cannot be generated due to safety",
|
||||
# Generic content-filter wording seen on Azure / OpenAI Responses.
|
||||
# ``content_filter`` (underscore) is the OpenAI-standard error/finish
|
||||
# token surfaced verbatim by their SDKs when a request is blocked.
|
||||
# ``responsibleaipolicyviolation`` is Azure OpenAI's error code.
|
||||
# Deliberately NOT matching the space variant ("content filter") — it
|
||||
# appears in benign config descriptions and tooltip text that providers
|
||||
# echo back; the underscore form is provider-specific enough.
|
||||
"content_filter",
|
||||
"responsibleaipolicyviolation",
|
||||
]
|
||||
|
||||
# Auth patterns (non-status-code signals)
|
||||
_AUTH_PATTERNS = [
|
||||
"invalid api key",
|
||||
@@ -485,6 +532,20 @@ def classify_api_error(
|
||||
|
||||
# ── 1. Provider-specific patterns (highest priority) ────────────
|
||||
|
||||
# Provider content-policy / safety-filter block. The provider has made a
|
||||
# deterministic refusal decision about THIS prompt — retrying unchanged
|
||||
# just reproduces the same refusal and burns paid attempts. Must run
|
||||
# before status-based classification so a 400 safety block isn't
|
||||
# downgraded to a generic ``format_error`` and a status-less block
|
||||
# (OpenAI Codex SDK can raise without one) isn't left in the retryable
|
||||
# ``unknown`` bucket. See issue #18028.
|
||||
if any(p in error_msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS):
|
||||
return _result(
|
||||
FailoverReason.content_policy_blocked,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Anthropic thinking block signature invalid (400).
|
||||
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
|
||||
# provider may be "openrouter" even though the error is Anthropic-specific.
|
||||
@@ -690,8 +751,13 @@ def _classify_by_status(
|
||||
)
|
||||
|
||||
if status_code == 403:
|
||||
# OpenRouter 403 "key limit exceeded" is actually billing
|
||||
if "key limit exceeded" in error_msg or "spending limit" in error_msg:
|
||||
# OpenRouter 403 "key limit exceeded" is actually billing. Other
|
||||
# providers also use 403 for account-plan or credit exhaustion.
|
||||
if (
|
||||
"key limit exceeded" in error_msg
|
||||
or "spending limit" in error_msg
|
||||
or any(p in error_msg for p in _BILLING_PATTERNS)
|
||||
):
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
@@ -708,6 +774,17 @@ def _classify_by_status(
|
||||
return _classify_402(error_msg, result_fn)
|
||||
|
||||
if status_code == 404:
|
||||
# Nous API currently surfaces HA/NAS credit depletion as a paid model
|
||||
# becoming unavailable on the Free Tier, returned as 404 rather than
|
||||
# 402. Treat that as entitlement/billing exhaustion, not a missing
|
||||
# model, so the retry loop can show credit/top-up guidance.
|
||||
if any(p in error_msg for p in _BILLING_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
should_rotate_credential=True,
|
||||
should_fallback=True,
|
||||
)
|
||||
# OpenRouter policy-block 404 — distinct from "model not found".
|
||||
# The model exists; the user's account privacy setting excludes the
|
||||
# only endpoint serving it. Falling back to another provider won't
|
||||
@@ -973,7 +1050,15 @@ def _classify_by_error_code(
|
||||
should_rotate_credential=True,
|
||||
)
|
||||
|
||||
if code_lower in {"insufficient_quota", "billing_not_active", "payment_required"}:
|
||||
if code_lower in {
|
||||
"insufficient_quota",
|
||||
"billing_not_active",
|
||||
"payment_required",
|
||||
"insufficient_credits",
|
||||
"no_usable_credits",
|
||||
"balance_depleted",
|
||||
"model_not_supported_on_free_tier",
|
||||
}:
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Best-effort early import for the OpenAI SDK's native streaming parser.
|
||||
|
||||
The OpenAI SDK imports ``jiter`` while constructing streaming chat-completion
|
||||
responses. On some Windows installs the native extension can be imported
|
||||
directly from the Hermes venv, but the first import fails when it happens later
|
||||
inside the threaded streaming request path. Loading it once during agent
|
||||
package import avoids that import-order failure while preserving the normal
|
||||
SDK error path for genuinely missing or broken installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
_JITER_PRELOADED = False
|
||||
_JITER_PRELOAD_ERROR: Exception | None = None
|
||||
|
||||
|
||||
def preload_jiter_native_extension() -> bool:
|
||||
"""Import jiter's native extension early if it is available."""
|
||||
|
||||
global _JITER_PRELOADED, _JITER_PRELOAD_ERROR
|
||||
|
||||
if _JITER_PRELOADED:
|
||||
return True
|
||||
|
||||
try:
|
||||
importlib.import_module("jiter.jiter")
|
||||
from jiter import from_json as _from_json # noqa: F401
|
||||
except Exception as exc:
|
||||
_JITER_PRELOAD_ERROR = exc
|
||||
return False
|
||||
|
||||
_JITER_PRELOADED = True
|
||||
_JITER_PRELOAD_ERROR = None
|
||||
return True
|
||||
|
||||
|
||||
preload_jiter_native_extension()
|
||||
+33
-2
@@ -368,11 +368,42 @@ class MemoryManager:
|
||||
|
||||
# -- Sync ----------------------------------------------------------------
|
||||
|
||||
def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
@staticmethod
|
||||
def _provider_sync_accepts_messages(provider: MemoryProvider) -> bool:
|
||||
"""Return whether sync_turn accepts a messages keyword."""
|
||||
try:
|
||||
signature = inspect.signature(provider.sync_turn)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
params = list(signature.parameters.values())
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params):
|
||||
return True
|
||||
return "messages" in signature.parameters
|
||||
|
||||
def sync_all(
|
||||
self,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
*,
|
||||
session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""Sync a completed turn to all providers."""
|
||||
for provider in self._providers:
|
||||
try:
|
||||
provider.sync_turn(user_content, assistant_content, session_id=session_id)
|
||||
if messages is not None and self._provider_sync_accepts_messages(provider):
|
||||
provider.sync_turn(
|
||||
user_content,
|
||||
assistant_content,
|
||||
session_id=session_id,
|
||||
messages=messages,
|
||||
)
|
||||
else:
|
||||
provider.sync_turn(
|
||||
user_content,
|
||||
assistant_content,
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Memory provider '%s' sync_turn failed: %s",
|
||||
|
||||
@@ -78,6 +78,7 @@ class MemoryProvider(ABC):
|
||||
- agent_workspace (str): Shared workspace name (e.g. "hermes").
|
||||
- parent_session_id (str): For subagents, the parent's session_id.
|
||||
- user_id (str): Platform user identifier (gateway sessions).
|
||||
- user_id_alt (str): Optional alternate stable platform user identifier.
|
||||
"""
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
@@ -111,11 +112,22 @@ class MemoryProvider(ABC):
|
||||
that do background prefetching should override this.
|
||||
"""
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
def sync_turn(
|
||||
self,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
*,
|
||||
session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""Persist a completed turn to the backend.
|
||||
|
||||
Called after each turn. Should be non-blocking — queue for
|
||||
background processing if the backend has latency.
|
||||
|
||||
``messages`` is the OpenAI-style conversation message list as of the
|
||||
completed turn, including any assistant tool calls and tool results.
|
||||
Providers that do not need raw turn context can ignore it.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
|
||||
+29
-3
@@ -141,6 +141,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
|
||||
# substring of "anthropic/claude-sonnet-4.6").
|
||||
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
|
||||
"claude-opus-4-8": 1000000,
|
||||
"claude-opus-4.8": 1000000,
|
||||
"claude-opus-4-7": 1000000,
|
||||
"claude-opus-4.7": 1000000,
|
||||
"claude-opus-4-6": 1000000,
|
||||
@@ -911,12 +913,33 @@ def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def get_context_length_from_provider_error(
|
||||
error_msg: str,
|
||||
current_context_length: int,
|
||||
) -> Optional[int]:
|
||||
"""Return a provider-reported lower context limit, if one is present.
|
||||
|
||||
Context-overflow recovery must not invent a new model window size. Some
|
||||
providers only say that the input exceeds the context window without
|
||||
reporting the actual maximum. In that case callers should keep the
|
||||
configured context length and try compression only, rather than stepping
|
||||
down through guessed probe tiers (1M → 256K → 128K → ...).
|
||||
"""
|
||||
parsed_limit = parse_context_limit_from_error(error_msg)
|
||||
if parsed_limit is None:
|
||||
return None
|
||||
if parsed_limit < current_context_length:
|
||||
return parsed_limit
|
||||
return None
|
||||
|
||||
|
||||
def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
"""Detect an "output cap too large" error and return how many output tokens are available.
|
||||
|
||||
Background — two distinct context errors exist:
|
||||
1. "Prompt too long" — the INPUT itself exceeds the context window.
|
||||
Fix: compress history and/or halve context_length.
|
||||
Fix: compress history, and only reduce context_length if the
|
||||
provider explicitly reports the actual lower limit.
|
||||
2. "max_tokens too large" — input is fine, but input + requested_output > window.
|
||||
Fix: reduce max_tokens (the output cap) for this call.
|
||||
Do NOT touch context_length — the window hasn't shrunk.
|
||||
@@ -1544,8 +1567,11 @@ def get_model_context_length(
|
||||
and base_url_host_matches(base_url, "amazonaws.com")
|
||||
):
|
||||
try:
|
||||
from agent.bedrock_adapter import get_bedrock_context_length
|
||||
return get_bedrock_context_length(model)
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock = registries.get_provider_namespace("bedrock")
|
||||
get_bedrock_context_length = _bedrock.get("get_bedrock_context_length")
|
||||
if get_bedrock_context_length is not None:
|
||||
return get_bedrock_context_length(model)
|
||||
except ImportError:
|
||||
pass # boto3 not installed — fall through to generic resolution
|
||||
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
"""Plugin capability registries.
|
||||
|
||||
Each plugin's ``register(ctx)`` function populates these registries via
|
||||
``ctx.register_<capability>()``. The core codebase then queries the
|
||||
registries instead of importing from plugin packages directly.
|
||||
|
||||
This is the **only** coupling point between the core and plugins: the core
|
||||
imports from ``agent.plugin_registries``, never from ``hermes_agent_*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Type,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProvider(Protocol):
|
||||
"""A plugin that can provide or check authentication credentials.
|
||||
|
||||
Registered via ``ctx.register_auth_provider(name, provider)``.
|
||||
Queried by ``hermes_cli/auth_commands.py``, ``doctor.py``, etc.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
"""Return True if the required credentials are present in env/config."""
|
||||
...
|
||||
|
||||
def check_env_vars(self) -> Dict[str, str | None]:
|
||||
"""Return a dict of env-var-name → current-value (or None if unset).
|
||||
|
||||
Used by ``hermes doctor`` to display credential status.
|
||||
"""
|
||||
...
|
||||
|
||||
def resolve_token(self, **kwargs: Any) -> Any:
|
||||
"""Resolve and return an auth token/credential for the provider.
|
||||
|
||||
The return type is provider-specific (string, tuple, object, etc.).
|
||||
"""
|
||||
...
|
||||
|
||||
def refresh_token(self, **kwargs: Any) -> Any:
|
||||
"""Refresh an existing token. Raises if refresh is not supported."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthProviderEntry:
|
||||
provider: AuthProvider
|
||||
"""The auth provider instance."""
|
||||
|
||||
cli_group: str = ""
|
||||
"""CLI argument group name (e.g. 'Anthropic', 'AWS / Bedrock')."""
|
||||
|
||||
setup_subcommands: bool = False
|
||||
"""Whether this provider adds CLI auth subcommands (login, logout, etc.)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class TransportBuilder(Protocol):
|
||||
"""A plugin that builds clients and converts messages for a model transport.
|
||||
|
||||
Registered via ``ctx.register_transport(name, builder)``.
|
||||
Queried by ``agent/transports/`` and ``agent/auxiliary_client.py``.
|
||||
"""
|
||||
|
||||
def build_client(self, **kwargs: Any) -> Any:
|
||||
"""Build and return a provider-specific API client."""
|
||||
...
|
||||
|
||||
def build_kwargs(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
"""Build the kwargs dict for a provider-specific API call."""
|
||||
...
|
||||
|
||||
def convert_messages(self, messages: Sequence[Any], **kwargs: Any) -> Any:
|
||||
"""Convert internal message format to provider-specific format."""
|
||||
...
|
||||
|
||||
def convert_tools(self, tools: Sequence[Any], **kwargs: Any) -> Any:
|
||||
"""Convert internal tool format to provider-specific format."""
|
||||
...
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs: Any) -> Any:
|
||||
"""Normalize a provider-specific response into the internal format."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class PlatformAdapterEntry:
|
||||
"""A registered platform adapter.
|
||||
|
||||
Registered via ``ctx.register_platform(name, entry)``.
|
||||
Queried by ``gateway/run.py`` and ``tools/send_message_tool.py``.
|
||||
"""
|
||||
name: str
|
||||
"""Platform identifier (e.g. 'telegram', 'slack')."""
|
||||
|
||||
adapter_class: Type
|
||||
"""The adapter class (e.g. TelegramAdapter)."""
|
||||
|
||||
check_requirements: Callable[[], bool]
|
||||
"""Check if the platform's dependencies are installed and configured."""
|
||||
|
||||
available_flag: str = ""
|
||||
"""Name of the module-level AVAILABLE boolean, if any."""
|
||||
|
||||
constants: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Platform-specific constants (e.g. FEISHU_DOMAIN, LARK_DOMAIN)."""
|
||||
|
||||
helper_functions: Dict[str, Callable] = field(default_factory=dict)
|
||||
"""Platform-specific helper functions (e.g. probe_bot, qr_register)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ToolProviderEntry:
|
||||
"""A registered tool provider.
|
||||
|
||||
Registered via ``ctx.register_tool_provider(name, entry)``.
|
||||
Queried by ``tools/`` modules.
|
||||
"""
|
||||
name: str
|
||||
"""Tool identifier (e.g. 'tts', 'stt', 'fal', 'daytona')."""
|
||||
|
||||
tool_functions: Dict[str, Callable] = field(default_factory=dict)
|
||||
"""Tool functions keyed by name (e.g. 'text_to_speech_tool', 'transcribe_audio')."""
|
||||
|
||||
check_fn: Optional[Callable] = None
|
||||
"""Check if the tool's dependencies are available."""
|
||||
|
||||
constants: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Tool-specific constants (e.g. MAX_FILE_SIZE)."""
|
||||
|
||||
config_functions: Dict[str, Callable] = field(default_factory=dict)
|
||||
"""Config/utility functions (e.g. _get_provider, _load_stt_config)."""
|
||||
|
||||
environment_classes: Dict[str, Type] = field(default_factory=dict)
|
||||
"""Environment classes for terminal backends (e.g. DaytonaEnvironment)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model metadata providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ModelMetadataEntry:
|
||||
"""A registered model metadata provider.
|
||||
|
||||
Registered via ``ctx.register_model_metadata(name, entry)``.
|
||||
Queried by ``agent/model_metadata.py`` and CLI model commands.
|
||||
"""
|
||||
name: str
|
||||
"""Provider identifier (e.g. 'anthropic', 'bedrock')."""
|
||||
|
||||
get_context_length: Optional[Callable[[str], int | None]] = None
|
||||
"""Return the context length for a model name, or None if unknown."""
|
||||
|
||||
list_models: Optional[Callable[[], List[str]]] = None
|
||||
"""Return a list of known model IDs for this provider."""
|
||||
|
||||
constants: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Provider-specific constants (e.g. _COMMON_BETAS, betas lists)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential pool entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CredentialPoolEntry:
|
||||
"""A registered credential pool provider.
|
||||
|
||||
Registered via ``ctx.register_credential_pool(name, entry)``.
|
||||
Queried by ``agent/credential_pool.py``.
|
||||
"""
|
||||
name: str
|
||||
"""Provider identifier (e.g. 'anthropic')."""
|
||||
|
||||
read_credentials: Optional[Callable] = None
|
||||
"""Read stored credentials."""
|
||||
|
||||
write_credentials: Optional[Callable] = None
|
||||
"""Write/store credentials."""
|
||||
|
||||
refresh_credentials: Optional[Callable] = None
|
||||
"""Refresh stored credentials."""
|
||||
|
||||
read_oauth: Optional[Callable] = None
|
||||
"""Read OAuth credentials."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider resolvers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class ProviderResolver(Protocol):
|
||||
"""A plugin that resolves an auxiliary client for a specific provider.
|
||||
|
||||
Registered via ``ctx.register_provider_resolver(provider_name, resolver)``.
|
||||
Queried by ``agent/auxiliary_client.py`` in ``resolve_provider_client()``.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
explicit_api_key: str | None = None,
|
||||
explicit_base_url: str | None = None,
|
||||
async_mode: bool = False,
|
||||
is_vision: bool = False,
|
||||
main_runtime: dict | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
"""Return ``(client, default_model)`` or ``(None, None)`` if unavailable."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential pool hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CredentialPoolHook:
|
||||
"""Provider-specific credential pool operations.
|
||||
|
||||
Registered via ``ctx.register_credential_pool_hook(provider_name, hook)``.
|
||||
Queried by ``agent/credential_pool.py``.
|
||||
"""
|
||||
|
||||
sync_from_credentials_file: Optional[Callable] = None
|
||||
"""Sync a pool entry from an external credentials file (e.g. ~/.claude/.credentials.json)."""
|
||||
|
||||
refresh_oauth: Optional[Callable] = None
|
||||
"""Refresh an OAuth token for a pool entry."""
|
||||
|
||||
should_include_in_pool: Optional[Callable] = None
|
||||
"""Return True if this provider's credentials should be included in the pool."""
|
||||
|
||||
needs_refresh: Optional[Callable] = None
|
||||
"""Return True if an OAuth entry needs a token refresh."""
|
||||
|
||||
source_priority: Optional[Callable] = None
|
||||
"""Return integer priority for a credential source (lower = preferred)."""
|
||||
|
||||
discover_credentials: Optional[Callable] = None
|
||||
"""Discover external credentials and upsert into the pool entries.
|
||||
|
||||
Signature: (entries: list, provider: str, is_suppressed: Callable) -> (changed: bool, active_sources: set)
|
||||
"""
|
||||
|
||||
env_var_order: Optional[list] = None
|
||||
"""Override env var scan order for this provider (e.g. ['ANTHROPIC_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'])."""
|
||||
|
||||
detect_auth_type: Optional[Callable] = None
|
||||
"""Given a token string, return the auth type for this provider.
|
||||
|
||||
Signature: (token: str) -> str (e.g. AUTH_TYPE_OAUTH or AUTH_TYPE_API_KEY)
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pricing providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Re-export PricingEntry from usage_pricing — that's the canonical definition
|
||||
# with Decimal fields. The registry stores these directly keyed by (provider, model).
|
||||
# Lazy import to avoid circular dependency (usage_pricing imports registries at runtime).
|
||||
def _get_pricing_entry_class():
|
||||
from agent.usage_pricing import PricingEntry
|
||||
return PricingEntry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProviderOverlayEntry:
|
||||
"""A provider overlay registered by a plugin.
|
||||
|
||||
Registered via ``ctx.register_provider_overlay(provider_name, entry)``.
|
||||
Queried by ``hermes_cli/providers.py``.
|
||||
|
||||
This mirrors the fields of ``HermesOverlay`` so that providers.py
|
||||
can merge plugin-registered overlays seamlessly.
|
||||
"""
|
||||
|
||||
provider_name: str
|
||||
"""Primary provider name (e.g. 'anthropic', 'bedrock')."""
|
||||
|
||||
transport: str = "openai_chat"
|
||||
"""Transport type: openai_chat | anthropic_messages | codex_responses | bedrock_converse"""
|
||||
|
||||
is_aggregator: bool = False
|
||||
"""Whether this provider aggregates multiple model providers."""
|
||||
|
||||
auth_type: str = "api_key"
|
||||
"""Auth type: api_key | oauth_device_code | oauth_external | aws_sdk | external_process"""
|
||||
|
||||
extra_env_vars: Tuple[str, ...] = ()
|
||||
"""Environment variable names that indicate this provider is configured."""
|
||||
|
||||
base_url_override: str = ""
|
||||
"""Override if models.dev URL is wrong/missing."""
|
||||
|
||||
base_url_env_var: str = ""
|
||||
"""Env var for user-custom base URL."""
|
||||
|
||||
display_name: str = ""
|
||||
"""Human-readable name for the provider (e.g. 'Anthropic', 'AWS Bedrock')."""
|
||||
|
||||
aliases: List[str] = field(default_factory=list)
|
||||
"""Alternative names that resolve to this provider."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The global registries (singleton)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PluginRegistries:
|
||||
"""Central store for all plugin-registered capabilities.
|
||||
|
||||
A single instance is created at import time and shared across the
|
||||
process. Plugins populate it during ``register()``; the core
|
||||
queries it at runtime.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.auth_providers: Dict[str, AuthProviderEntry] = {}
|
||||
self.transport_builders: Dict[str, TransportBuilder] = {}
|
||||
self._transports: Dict[str, type] = {}
|
||||
self.platform_adapters: Dict[str, PlatformAdapterEntry] = {}
|
||||
self.tool_providers: Dict[str, ToolProviderEntry] = {}
|
||||
self.model_metadata: Dict[str, ModelMetadataEntry] = {}
|
||||
self.credential_pools: Dict[str, CredentialPoolEntry] = {}
|
||||
self._provider_services: Dict[str, Dict[str, Any]] = {}
|
||||
self._provider_resolvers: Dict[str, Callable] = {}
|
||||
self._credential_pool_hooks: Dict[str, CredentialPoolHook] = {}
|
||||
self._pricing_providers: Dict[tuple, Any] = {}
|
||||
self._provider_overlays: Dict[str, ProviderOverlayEntry] = {}
|
||||
|
||||
# -- registration methods (called from PluginContext) --------------------
|
||||
|
||||
def register_auth_provider(
|
||||
self,
|
||||
name: str,
|
||||
provider: AuthProvider,
|
||||
*,
|
||||
cli_group: str = "",
|
||||
setup_subcommands: bool = False,
|
||||
) -> None:
|
||||
self.auth_providers[name] = AuthProviderEntry(
|
||||
provider=provider,
|
||||
cli_group=cli_group,
|
||||
setup_subcommands=setup_subcommands,
|
||||
)
|
||||
|
||||
def register_transport(self, name: str, builder: TransportBuilder) -> None:
|
||||
self.transport_builders[name] = builder
|
||||
|
||||
def register_platform(self, entry: PlatformAdapterEntry) -> None:
|
||||
self.platform_adapters[entry.name] = entry
|
||||
|
||||
def register_tool_provider(self, entry: ToolProviderEntry) -> None:
|
||||
self.tool_providers[entry.name] = entry
|
||||
|
||||
def register_model_metadata(self, entry: ModelMetadataEntry) -> None:
|
||||
self.model_metadata[entry.name] = entry
|
||||
|
||||
def register_credential_pool(self, entry: CredentialPoolEntry) -> None:
|
||||
self.credential_pools[entry.name] = entry
|
||||
|
||||
def register_provider_resolver(self, name: str, resolver: Callable) -> None:
|
||||
"""Register a provider resolver callable.
|
||||
|
||||
The resolver is called by ``resolve_provider_client()`` to create an
|
||||
auxiliary client for a specific provider. Signature::
|
||||
|
||||
def resolver(
|
||||
*,
|
||||
model: str | None,
|
||||
explicit_api_key: str | None,
|
||||
explicit_base_url: str | None,
|
||||
async_mode: bool,
|
||||
is_vision: bool,
|
||||
main_runtime: dict | None,
|
||||
api_mode: str | None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
...
|
||||
|
||||
Returns ``(client, default_model)`` or ``(None, None)``.
|
||||
"""
|
||||
self._provider_resolvers[name] = resolver
|
||||
|
||||
def register_credential_pool_hook(self, name: str, hook: CredentialPoolHook) -> None:
|
||||
"""Register a credential pool hook for provider-specific pool operations."""
|
||||
self._credential_pool_hooks[name] = hook
|
||||
|
||||
def register_pricing_provider(self, name: str, entries: List[tuple]) -> None:
|
||||
"""Register pricing entries for a provider.
|
||||
|
||||
Each entry is a (provider, model, PricingEntry) tuple so the
|
||||
lookup key matches the (provider, model) pattern used by
|
||||
_OFFICIAL_DOCS_PRICING.
|
||||
"""
|
||||
for prov, model, entry in entries:
|
||||
self._pricing_providers[(prov, model)] = entry
|
||||
|
||||
def register_provider_overlay(self, entry: ProviderOverlayEntry) -> None:
|
||||
"""Register a provider overlay entry from a plugin."""
|
||||
self._provider_overlays[entry.provider_name] = entry
|
||||
|
||||
# -- query helpers -------------------------------------------------------
|
||||
|
||||
def get_auth_provider(self, name: str) -> AuthProviderEntry | None:
|
||||
return self.auth_providers.get(name)
|
||||
|
||||
def get_transport(self, name: str) -> TransportBuilder | None:
|
||||
return self.transport_builders.get(name)
|
||||
|
||||
def get_platform(self, name: str) -> PlatformAdapterEntry | None:
|
||||
return self.platform_adapters.get(name)
|
||||
|
||||
def get_tool_provider(self, name: str) -> ToolProviderEntry | None:
|
||||
return self.tool_providers.get(name)
|
||||
|
||||
def get_model_metadata(self, name: str) -> ModelMetadataEntry | None:
|
||||
return self.model_metadata.get(name)
|
||||
|
||||
def get_credential_pool(self, name: str) -> CredentialPoolEntry | None:
|
||||
return self.credential_pools.get(name)
|
||||
|
||||
def get_provider_resolver(self, name: str) -> Callable | None:
|
||||
"""Return the registered resolver for a provider, or None."""
|
||||
return self._provider_resolvers.get(name)
|
||||
|
||||
def get_credential_pool_hook(self, name: str) -> CredentialPoolHook | None:
|
||||
"""Return the registered credential pool hook for a provider, or None."""
|
||||
return self._credential_pool_hooks.get(name)
|
||||
|
||||
def get_pricing_entry(self, provider: str, model: str) -> Any:
|
||||
"""Return a registered pricing entry for (provider, model), or None."""
|
||||
return self._pricing_providers.get((provider, model))
|
||||
|
||||
def all_pricing_entries(self) -> Dict[tuple, Any]:
|
||||
"""Return all registered pricing entries (keyed by (provider, model))."""
|
||||
return dict(self._pricing_providers)
|
||||
|
||||
def get_provider_overlay(self, name: str) -> ProviderOverlayEntry | None:
|
||||
"""Return a registered provider overlay, or None."""
|
||||
return self._provider_overlays.get(name)
|
||||
|
||||
def all_provider_overlays(self) -> Dict[str, ProviderOverlayEntry]:
|
||||
"""Return all registered provider overlays."""
|
||||
return dict(self._provider_overlays)
|
||||
|
||||
def all_auth_providers(self) -> List[AuthProviderEntry]:
|
||||
return list(self.auth_providers.values())
|
||||
|
||||
def all_platforms(self) -> List[PlatformAdapterEntry]:
|
||||
return list(self.platform_adapters.values())
|
||||
|
||||
def all_tool_providers(self) -> List[ToolProviderEntry]:
|
||||
return list(self.tool_providers.values())
|
||||
|
||||
# -- provider services (model-provider namespace) -----------------------
|
||||
|
||||
def register_provider_services(self, name: str, services: Dict[str, Any]) -> None:
|
||||
"""Register a namespace dict of provider-specific services.
|
||||
|
||||
This is the escape hatch for model-provider plugins that expose many
|
||||
symbols (anthropic has 50+). Each plugin registers its public surface
|
||||
as a flat dict of ``{symbol_name: callable_or_value}``. Core code
|
||||
looks up specific symbols instead of importing from the plugin
|
||||
package directly.
|
||||
|
||||
Each callable value is stored as a *lazy module-attribute reference*
|
||||
so that ``unittest.mock.patch("pkg.mod.fn")`` works correctly in
|
||||
tests — the registry re-reads ``mod.fn`` on every lookup instead of
|
||||
capturing the function object at register time.
|
||||
|
||||
Example::
|
||||
|
||||
registries.register_provider_services("anthropic", {
|
||||
"build_anthropic_client": build_anthropic_client,
|
||||
"resolve_anthropic_token": resolve_anthropic_token,
|
||||
"_is_oauth_token": _is_oauth_token,
|
||||
...
|
||||
})
|
||||
"""
|
||||
import sys
|
||||
|
||||
def _make_lazy(fn: Any) -> Any:
|
||||
"""Return a lazy wrapper that re-reads fn from its module each call.
|
||||
|
||||
This makes mock.patch() on the module attribute work transparently —
|
||||
the registry never caches the function object, just the reference path.
|
||||
"""
|
||||
if not callable(fn):
|
||||
return fn
|
||||
module = getattr(fn, "__module__", None)
|
||||
qualname = getattr(fn, "__qualname__", None)
|
||||
if not module or not qualname or "." in qualname:
|
||||
# non-simple attribute (lambda, nested fn, class method) — store directly
|
||||
return fn
|
||||
|
||||
class _LazyRef:
|
||||
__slots__ = ("_mod", "_attr", "_fallback")
|
||||
|
||||
def __init__(self, mod: str, attr: str, fallback: Any) -> None:
|
||||
self._mod = mod
|
||||
self._attr = attr
|
||||
self._fallback = fallback
|
||||
|
||||
def _resolve(self) -> Any:
|
||||
mod = sys.modules.get(self._mod)
|
||||
return getattr(mod, self._attr, self._fallback) if mod else self._fallback
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return self._resolve()(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
return getattr(self._resolve(), name)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<LazyRef {self._mod}.{self._attr}>"
|
||||
|
||||
# Allow isinstance checks and hasattr to pass through
|
||||
def __bool__(self) -> bool:
|
||||
return True
|
||||
|
||||
return _LazyRef(module, qualname, fn)
|
||||
|
||||
self._provider_services[name] = {k: _make_lazy(v) for k, v in services.items()}
|
||||
|
||||
def get_provider_service(self, provider: str, name: str) -> Any:
|
||||
"""Look up a single symbol from a provider's service namespace.
|
||||
|
||||
Returns ``None`` if the provider is not registered or the symbol
|
||||
doesn't exist.
|
||||
"""
|
||||
ns = self._provider_services.get(provider)
|
||||
if ns is None:
|
||||
return None
|
||||
return ns.get(name)
|
||||
|
||||
def get_provider_namespace(self, provider: str) -> Dict[str, Any]:
|
||||
"""Return the full service namespace dict for a provider (empty dict if unregistered)."""
|
||||
return self._provider_services.get(provider, {})
|
||||
|
||||
|
||||
# Module-level singleton — the one and only instance.
|
||||
registries = PluginRegistries()
|
||||
+8
-13
@@ -406,19 +406,14 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
if "eyJ" in text:
|
||||
text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text)
|
||||
|
||||
# URL userinfo (http(s)://user:pass@host) — redact for non-DB schemes.
|
||||
# DB schemes are handled above by _DB_CONNSTR_RE.
|
||||
if "://" in text:
|
||||
text = _redact_url_userinfo(text)
|
||||
|
||||
# URL query params containing opaque tokens (?access_token=…&code=…)
|
||||
if "?" in text:
|
||||
text = _redact_url_query_params(text)
|
||||
|
||||
# HTTP access logs can contain relative request targets with query params
|
||||
# and no URL scheme, e.g. `"POST /hook?password=... HTTP/1.1"`.
|
||||
if "?" in text and "=" in text and _has_http_method_substring(text):
|
||||
text = _redact_http_request_target_query_params(text)
|
||||
# NOTE: Web-URL redaction (query params + userinfo + HTTP access-log
|
||||
# request targets) is intentionally OFF. Many legitimate workflows pass
|
||||
# opaque tokens through query strings — magic-link checkouts, OAuth
|
||||
# callbacks the agent is meant to follow, pre-signed share URLs — and
|
||||
# blanket-redacting param values by name breaks those skills mid-flow.
|
||||
# Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still
|
||||
# caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords
|
||||
# are still caught by _DB_CONNSTR_RE.
|
||||
|
||||
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
|
||||
if "&" in text and "=" in text:
|
||||
|
||||
@@ -258,7 +258,7 @@ def emit_stream_drop(
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
agent._emit_status(
|
||||
agent._buffer_status(
|
||||
f"⚠️ {provider} stream {kind} ({type(error).__name__}){_suffix} "
|
||||
f"— reconnecting, retry {attempt}/{max_attempts}"
|
||||
)
|
||||
|
||||
@@ -47,9 +47,16 @@ def get_transport(api_mode: str):
|
||||
|
||||
|
||||
def _discover_transports() -> None:
|
||||
"""Import all transport modules to trigger auto-registration."""
|
||||
"""Import all transport modules to trigger auto-registration.
|
||||
|
||||
Also checks the plugin registry for transports registered by plugins
|
||||
(e.g. anthropic_messages from the anthropic plugin, bedrock_converse
|
||||
from the bedrock plugin). Plugin-registered transports take priority
|
||||
over core fallbacks when both exist.
|
||||
"""
|
||||
global _discovered
|
||||
_discovered = True
|
||||
# Core transport modules (registered automatically — no plugin needed)
|
||||
try:
|
||||
import agent.transports.anthropic # noqa: F401
|
||||
except ImportError:
|
||||
@@ -62,7 +69,10 @@ def _discover_transports() -> None:
|
||||
import agent.transports.chat_completions # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
# Plugin-registered transports (override core fallbacks)
|
||||
try:
|
||||
import agent.transports.bedrock # noqa: F401
|
||||
from agent.plugin_registries import registries
|
||||
for api_mode, transport_cls in registries._transports.items():
|
||||
_REGISTRY.setdefault(api_mode, transport_cls)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -1,41 +1,53 @@
|
||||
"""Anthropic Messages API transport.
|
||||
"""Anthropic Messages API transport — core module.
|
||||
|
||||
Delegates to the existing adapter functions in agent/anthropic_adapter.py.
|
||||
This transport owns format conversion and normalization — NOT client lifecycle.
|
||||
Owns format conversion and response normalization for the ``anthropic_messages``
|
||||
wire format. No SDK dependency; all wire-format logic lives in
|
||||
:mod:`agent.anthropic_format`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.anthropic_format import (
|
||||
build_anthropic_kwargs,
|
||||
convert_messages_to_anthropic,
|
||||
convert_tools_to_anthropic,
|
||||
_to_plain_data,
|
||||
)
|
||||
from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse
|
||||
from agent.transports.types import NormalizedResponse, ToolCall
|
||||
|
||||
|
||||
class AnthropicTransport(ProviderTransport):
|
||||
"""Transport for api_mode='anthropic_messages'.
|
||||
|
||||
Wraps the existing functions in anthropic_adapter.py behind the
|
||||
ProviderTransport ABC. Each method delegates — no logic is duplicated.
|
||||
Uses core functions directly from :mod:`agent.anthropic_format` — no
|
||||
plugin registry lookups needed. This means core tests, bedrock tests,
|
||||
and any other consumer of the anthropic wire format work without the
|
||||
anthropic plugin being registered.
|
||||
"""
|
||||
|
||||
_STOP_REASON_MAP = {
|
||||
"end_turn": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
"refusal": "content_filter",
|
||||
"model_context_window_exceeded": "length",
|
||||
}
|
||||
|
||||
@property
|
||||
def api_mode(self) -> str:
|
||||
return "anthropic_messages"
|
||||
|
||||
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
|
||||
"""Convert OpenAI messages to Anthropic (system, messages) tuple.
|
||||
|
||||
kwargs:
|
||||
base_url: Optional[str] — affects thinking signature handling.
|
||||
"""
|
||||
from agent.anthropic_adapter import convert_messages_to_anthropic
|
||||
|
||||
"""Convert OpenAI messages to Anthropic (system, messages) tuple."""
|
||||
base_url = kwargs.get("base_url")
|
||||
return convert_messages_to_anthropic(messages, base_url=base_url)
|
||||
return convert_messages_to_anthropic(messages, base_url=base_url,
|
||||
model=kwargs.get("model"))
|
||||
|
||||
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
|
||||
"""Convert OpenAI tool schemas to Anthropic input_schema format."""
|
||||
from agent.anthropic_adapter import convert_tools_to_anthropic
|
||||
|
||||
return convert_tools_to_anthropic(tools)
|
||||
|
||||
def build_kwargs(
|
||||
@@ -45,23 +57,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
**params,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build Anthropic messages.create() kwargs.
|
||||
|
||||
Calls convert_messages and convert_tools internally.
|
||||
|
||||
params (all optional):
|
||||
max_tokens: int
|
||||
reasoning_config: dict | None
|
||||
tool_choice: str | None
|
||||
is_oauth: bool
|
||||
preserve_dots: bool
|
||||
context_length: int | None
|
||||
base_url: str | None
|
||||
fast_mode: bool
|
||||
drop_context_1m_beta: bool
|
||||
"""
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
"""Build Anthropic messages.create() kwargs."""
|
||||
return build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@@ -78,15 +74,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
)
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
"""Normalize Anthropic response to NormalizedResponse.
|
||||
|
||||
Parses content blocks (text, thinking, tool_use), maps stop_reason
|
||||
to OpenAI finish_reason, and collects reasoning_details in provider_data.
|
||||
"""
|
||||
import json
|
||||
from agent.anthropic_adapter import _to_plain_data
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
"""Normalize Anthropic response to NormalizedResponse."""
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
_MCP_PREFIX = "mcp_"
|
||||
|
||||
@@ -107,12 +95,6 @@ class AnthropicTransport(ProviderTransport):
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||
stripped = name[len(_MCP_PREFIX):]
|
||||
# Only strip the mcp_ prefix for OAuth-injected tools
|
||||
# (where Hermes adds the prefix when sending to Anthropic
|
||||
# and must remove it on the way back). Native MCP server
|
||||
# tools (from mcp_servers: in config.yaml) are registered
|
||||
# in the tool registry under their FULL mcp_<server>_<tool>
|
||||
# name and must NOT be stripped. GH-25255.
|
||||
from tools.registry import registry as _tool_registry
|
||||
if (_tool_registry.get_entry(stripped)
|
||||
and not _tool_registry.get_entry(name)):
|
||||
@@ -141,13 +123,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
)
|
||||
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
An empty content list is legitimate when ``stop_reason == "end_turn"``
|
||||
— the model's canonical way of signalling "nothing more to add" after
|
||||
a tool turn that already delivered the user-facing text. Treating it
|
||||
as invalid falsely retries a completed response.
|
||||
"""
|
||||
"""Check Anthropic response structure is valid."""
|
||||
if response is None:
|
||||
return False
|
||||
content_blocks = getattr(response, "content", None)
|
||||
@@ -168,16 +144,6 @@ class AnthropicTransport(ProviderTransport):
|
||||
return {"cached_tokens": cached, "creation_tokens": written}
|
||||
return None
|
||||
|
||||
# Promote the adapter's canonical mapping to module level so it's shared
|
||||
_STOP_REASON_MAP = {
|
||||
"end_turn": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
"refusal": "content_filter",
|
||||
"model_context_window_exceeded": "length",
|
||||
}
|
||||
|
||||
def map_finish_reason(self, raw_reason: str) -> str:
|
||||
"""Map Anthropic stop_reason to OpenAI finish_reason."""
|
||||
return self._STOP_REASON_MAP.get(raw_reason, "stop")
|
||||
|
||||
@@ -128,6 +128,14 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||
|
||||
response_tools = _responses_tools(tools)
|
||||
# ``tools`` MUST be omitted entirely when there are no functions to
|
||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
||||
# without a None guard, so passing ``tools=None`` raises
|
||||
# ``TypeError: 'NoneType' object is not iterable`` before any HTTP
|
||||
# request is issued (openai==2.24.0). Reported for the
|
||||
# ``openai-codex`` / ``gpt-5.5`` combo on chatgpt.com/backend-api/codex
|
||||
# (#32892) when the agent runs without external tools registered.
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"instructions": instructions,
|
||||
@@ -137,10 +145,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
replay_encrypted_reasoning=replay_encrypted_reasoning,
|
||||
current_issuer_kind=issuer_kind,
|
||||
),
|
||||
"tools": response_tools,
|
||||
"store": False,
|
||||
}
|
||||
if response_tools:
|
||||
kwargs["tools"] = response_tools
|
||||
kwargs["tool_choice"] = "auto"
|
||||
kwargs["parallel_tool_calls"] = True
|
||||
|
||||
@@ -184,6 +192,17 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
if request_overrides:
|
||||
kwargs.update(request_overrides)
|
||||
|
||||
# xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not
|
||||
# supported: service_tier") — hit when ``/fast`` priority-processing
|
||||
# mode lingers from a prior model in the same session, or when a
|
||||
# user explicitly sets ``agent.service_tier`` in config.yaml. The
|
||||
# main-loop guard (``resolve_fast_mode_overrides`` only returns
|
||||
# ``service_tier`` for OpenAI fast-eligible models) doesn't cover
|
||||
# those leak paths, so strip defensively when targeting xAI. See
|
||||
# #28490 for the original report.
|
||||
if is_xai_responses:
|
||||
kwargs.pop("service_tier", None)
|
||||
|
||||
# Forward per-request timeout to the SDK so OpenAI/Anthropic clients
|
||||
# honor it. Without this, ``providers.<id>.request_timeout_seconds``
|
||||
# is silently dropped on the main agent Codex path while the
|
||||
|
||||
+96
-152
@@ -83,10 +83,40 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
|
||||
# Official docs snapshot entries. Models whose published pricing and cache
|
||||
# semantics are stable enough to encode exactly.
|
||||
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
|
||||
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
|
||||
# model ID with 2x premium (vs the 6x premium on older Opus generations).
|
||||
# Source: https://openrouter.ai/anthropic/claude-opus-4.8
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-8",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-8-fast",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("10.00"),
|
||||
output_cost_per_million=Decimal("50.00"),
|
||||
cache_read_cost_per_million=Decimal("1.00"),
|
||||
cache_write_cost_per_million=Decimal("12.50"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openrouter.ai/anthropic/claude-opus-4.8-fast",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.7 ─────────────────────────────────────────────
|
||||
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
|
||||
# tokens for the same text).
|
||||
# Source: https://platform.claude.com/docs/en/about-claude/pricing
|
||||
# NOTE: The anthropic plugin also registers these — plugin takes priority
|
||||
# at runtime, but these static entries ensure costs work without the plugin.
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-7",
|
||||
@@ -111,7 +141,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.6 ─────────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-6",
|
||||
@@ -160,7 +189,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.5 ─────────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-5",
|
||||
@@ -197,7 +225,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4 / 4.1 ─────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-20250514",
|
||||
@@ -222,7 +249,56 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# OpenAI
|
||||
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-haiku-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
cache_write_cost_per_million=Decimal("1.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-opus-20240229",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("15.00"),
|
||||
output_cost_per_million=Decimal("75.00"),
|
||||
cache_read_cost_per_million=Decimal("1.50"),
|
||||
cache_write_cost_per_million=Decimal("18.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-haiku-20240307",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.25"),
|
||||
output_cost_per_million=Decimal("1.25"),
|
||||
cache_read_cost_per_million=Decimal("0.03"),
|
||||
cache_write_cost_per_million=Decimal("0.30"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── OpenAI ────────────────────────────────────────────────────────────
|
||||
(
|
||||
"openai",
|
||||
"gpt-4o",
|
||||
@@ -300,55 +376,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://openai.com/api/pricing/",
|
||||
pricing_version="openai-pricing-2026-03-16",
|
||||
),
|
||||
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-haiku-20241022",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
cache_write_cost_per_million=Decimal("1.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-opus-20240229",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("15.00"),
|
||||
output_cost_per_million=Decimal("75.00"),
|
||||
cache_read_cost_per_million=Decimal("1.50"),
|
||||
cache_write_cost_per_million=Decimal("18.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-haiku-20240307",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.25"),
|
||||
output_cost_per_million=Decimal("1.25"),
|
||||
cache_read_cost_per_million=Decimal("0.03"),
|
||||
cache_write_cost_per_million=Decimal("0.30"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# DeepSeek
|
||||
(
|
||||
"deepseek",
|
||||
@@ -412,80 +439,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-03-16",
|
||||
),
|
||||
# AWS Bedrock — pricing per the Bedrock pricing page.
|
||||
# Bedrock charges the same per-token rates as the model provider but
|
||||
# through AWS billing. These are the on-demand prices (no commitment).
|
||||
# Source: https://aws.amazon.com/bedrock/pricing/
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-opus-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("15.00"),
|
||||
output_cost_per_million=Decimal("75.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-sonnet-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-haiku-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-pro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("3.20"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-lite",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.06"),
|
||||
output_cost_per_million=Decimal("0.24"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-micro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.035"),
|
||||
output_cost_per_million=Decimal("0.14"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
# MiniMax
|
||||
(
|
||||
"minimax",
|
||||
@@ -553,36 +506,27 @@ def resolve_billing_route(
|
||||
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
|
||||
|
||||
|
||||
def _normalize_anthropic_model_name(model: str) -> str:
|
||||
"""Normalize Anthropic model name variants to canonical form.
|
||||
|
||||
Handles:
|
||||
- Dot notation: claude-opus-4.7 → claude-opus-4-7
|
||||
- Short aliases: claude-opus-4.7 → claude-opus-4-7
|
||||
- Strips anthropic/ prefix if present
|
||||
"""
|
||||
name = model.lower().strip()
|
||||
if name.startswith("anthropic/"):
|
||||
name = name[len("anthropic/"):]
|
||||
# Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6)
|
||||
# But preserve the rest of the name structure
|
||||
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
||||
return name
|
||||
|
||||
|
||||
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
|
||||
model = route.model.lower()
|
||||
# Direct lookup first
|
||||
|
||||
# ── Plugin-registered pricing entries take priority ──
|
||||
from agent.plugin_registries import registries as _preg
|
||||
plugin_entry = _preg.get_pricing_entry(route.provider, model)
|
||||
if plugin_entry:
|
||||
return plugin_entry
|
||||
# Try provider-specific name normalization via registry
|
||||
_norm = _preg.get_provider_service(route.provider, "normalize_model_name")
|
||||
if _norm is not None:
|
||||
normalized = _norm(model)
|
||||
if normalized != model:
|
||||
plugin_entry = _preg.get_pricing_entry(route.provider, normalized)
|
||||
if plugin_entry:
|
||||
return plugin_entry
|
||||
|
||||
# Fall back to static dict
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
|
||||
if entry:
|
||||
return entry
|
||||
# Try normalized name for Anthropic (handles dot-notation like opus-4.7)
|
||||
if route.provider == "anthropic":
|
||||
normalized = _normalize_anthropic_model_name(model)
|
||||
if normalized != model:
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
|
||||
if entry:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@ from typing import Any, Dict, List
|
||||
|
||||
|
||||
class WebSearchProvider(abc.ABC):
|
||||
"""Abstract base class for a web search/extract/crawl backend.
|
||||
"""Abstract base class for a web search/extract backend.
|
||||
|
||||
Subclasses must implement :meth:`is_available` and at least one of
|
||||
:meth:`search` / :meth:`extract` / :meth:`crawl`. The
|
||||
:meth:`supports_search` / :meth:`supports_extract` / :meth:`supports_crawl`
|
||||
capability flags let the registry route each tool call to the right
|
||||
provider, and let multi-capability providers (Firecrawl, Tavily, Exa,
|
||||
…) advertise multiple capabilities from a single class.
|
||||
:meth:`search` / :meth:`extract`. The :meth:`supports_search` /
|
||||
:meth:`supports_extract` capability flags let the registry route each
|
||||
tool call to the right provider, and let multi-capability providers
|
||||
(Firecrawl, Tavily, Exa, …) advertise multiple capabilities from a
|
||||
single class.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -113,22 +113,6 @@ class WebSearchProvider(abc.ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def supports_crawl(self) -> bool:
|
||||
"""Return True if this provider implements :meth:`crawl`.
|
||||
|
||||
Crawl differs from extract in that the agent provides a *seed URL*
|
||||
and the provider walks linked pages on its own — useful for
|
||||
documentation sites where the agent doesn't know all relevant
|
||||
URLs upfront. Tavily is the only built-in backend that natively
|
||||
crawls today; Firecrawl provides a similar capability that we
|
||||
don't currently surface as a tool.
|
||||
|
||||
Providers that don't crawl should leave this as False; the
|
||||
dispatcher in :func:`tools.web_tools.web_crawl_tool` will fall
|
||||
back to its auxiliary-model summarization path.
|
||||
"""
|
||||
return False
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
|
||||
"""Execute a web search.
|
||||
|
||||
@@ -173,26 +157,6 @@ class WebSearchProvider(abc.ABC):
|
||||
f"{self.name} does not support extract (override supports_extract)"
|
||||
)
|
||||
|
||||
def crawl(self, url: str, **kwargs: Any) -> Any:
|
||||
"""Crawl a seed URL and return results.
|
||||
|
||||
Override when :meth:`supports_crawl` returns True. The default
|
||||
raises NotImplementedError; callers should gate on
|
||||
:meth:`supports_crawl` before calling.
|
||||
|
||||
Return shape: ``{"results": [{"url": str, "title": str,
|
||||
"content": str, ...}, ...]}`` matching what
|
||||
:func:`tools.web_tools.web_crawl_tool` post-processing expects.
|
||||
|
||||
Implementations MAY be ``async def``.
|
||||
|
||||
``kwargs`` may carry forward-compat fields (e.g. ``max_depth``,
|
||||
``include_domains``) — implementations should ignore unknown keys.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.name} does not support crawl (override supports_crawl)"
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
"""Return provider metadata for the ``hermes tools`` picker.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Active selection
|
||||
----------------
|
||||
The active provider is chosen by configuration with this precedence:
|
||||
|
||||
1. ``web.search_backend`` / ``web.extract_backend`` / ``web.crawl_backend``
|
||||
1. ``web.search_backend`` / ``web.extract_backend``
|
||||
(per-capability override).
|
||||
2. ``web.backend`` (shared fallback).
|
||||
3. If exactly one capability-eligible provider is registered AND available,
|
||||
@@ -24,10 +24,10 @@ The active provider is chosen by configuration with this precedence:
|
||||
5. Otherwise ``None`` — the tool surfaces a helpful error pointing at
|
||||
``hermes tools``.
|
||||
|
||||
The capability filter (``supports_search`` / ``supports_extract`` /
|
||||
``supports_crawl``) is applied at every step so a search-only provider
|
||||
(``brave-free``) configured as ``web.extract_backend`` correctly falls
|
||||
through to an extract-capable backend.
|
||||
The capability filter (``supports_search`` / ``supports_extract``) is
|
||||
applied at every step so a search-only provider (``brave-free``)
|
||||
configured as ``web.extract_backend`` correctly falls through to an
|
||||
extract-capable backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -131,7 +131,7 @@ _LEGACY_PREFERENCE = (
|
||||
|
||||
|
||||
def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]:
|
||||
"""Resolve the active provider for a capability ("search" | "extract" | "crawl").
|
||||
"""Resolve the active provider for a capability ("search" | "extract").
|
||||
|
||||
Resolution rules (in order):
|
||||
|
||||
@@ -168,8 +168,6 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc
|
||||
return bool(p.supports_search())
|
||||
if capability == "extract":
|
||||
return bool(p.supports_extract())
|
||||
if capability == "crawl":
|
||||
return bool(p.supports_crawl())
|
||||
return False
|
||||
|
||||
def _is_available_safe(p: WebSearchProvider) -> bool:
|
||||
@@ -241,21 +239,6 @@ def get_active_extract_provider() -> Optional[WebSearchProvider]:
|
||||
return _resolve(explicit, capability="extract")
|
||||
|
||||
|
||||
def get_active_crawl_provider() -> Optional[WebSearchProvider]:
|
||||
"""Resolve the currently-active web crawl provider.
|
||||
|
||||
Reads ``web.crawl_backend`` (preferred) or ``web.backend`` (shared
|
||||
fallback) from config.yaml; falls back per the module docstring.
|
||||
|
||||
Crawl is a niche capability — among built-in providers only Tavily and
|
||||
Firecrawl implement it. Callers should expect ``None`` and fall back to
|
||||
a different strategy (e.g. summarize-via-LLM) when neither is
|
||||
configured.
|
||||
"""
|
||||
explicit = _read_config_key("web", "crawl_backend") or _read_config_key("web", "backend")
|
||||
return _resolve(explicit, capability="crawl")
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the registry. **Test-only.**"""
|
||||
with _lock:
|
||||
|
||||
+18
-8
@@ -917,9 +917,13 @@ display:
|
||||
tool_progress: all
|
||||
|
||||
# Per-platform defaults can be quieter than the global setting. Telegram
|
||||
# defaults to final-answer-first on mobile: tool progress, interim assistant
|
||||
# updates, long-running "Still working..." heartbeats, and detailed busy acks
|
||||
# are off unless re-enabled under display.platforms.telegram.
|
||||
# tunes for mobile: tool_progress and busy_ack_detail default off (no
|
||||
# per-tool breadcrumb stream, no "iteration 21/60" debug detail in busy
|
||||
# acks or heartbeats), but interim_assistant_messages and
|
||||
# long_running_notifications STAY ON so the user has real signal between
|
||||
# turn start and final answer (mid-turn assistant commentary + a single
|
||||
# edit-in-place "⏳ Working — N min" heartbeat). Override under
|
||||
# display.platforms.telegram.
|
||||
|
||||
# Auto-cleanup of temporary progress bubbles after the final response lands.
|
||||
# On platforms that support message deletion (currently Telegram), this
|
||||
@@ -945,13 +949,19 @@ display:
|
||||
interim_assistant_messages: true
|
||||
|
||||
# Gateway-only long-running status heartbeats.
|
||||
# When false, the platform does not receive periodic "Still working..."
|
||||
# notifications even if agent.gateway_notify_interval is non-zero.
|
||||
# Telegram default: false. Other high-capability chat platforms default true.
|
||||
# When false, the platform does not receive periodic "⏳ Working — N min"
|
||||
# notifications even if agent.gateway_notify_interval is non-zero. The
|
||||
# heartbeat edits a single message in place (where the adapter supports
|
||||
# editing) instead of posting a new bubble each interval.
|
||||
# Default: true everywhere, including Telegram (silent agents are worse
|
||||
# than a single edit-in-place heartbeat).
|
||||
long_running_notifications: true
|
||||
|
||||
# Include detailed iteration/tool/status context in busy acknowledgments when
|
||||
# a new user message arrives while a run is active. Telegram default: false.
|
||||
# Include detailed iteration/tool/status context in busy acknowledgments
|
||||
# and long-running heartbeats. When true, busy acks show "iteration 21/60,
|
||||
# terminal, 10 min" and the heartbeat shows "⏳ Working — 12 min,
|
||||
# iteration 21/60, terminal". When false (Telegram default), both stay
|
||||
# terse: "Interrupting current task" and "⏳ Working — 12 min, terminal".
|
||||
busy_ack_detail: true
|
||||
|
||||
# What Enter does when Hermes is already busy (CLI and gateway platforms).
|
||||
|
||||
@@ -168,7 +168,7 @@ from hermes_cli.browser_connect import (
|
||||
try_launch_chrome_debug,
|
||||
)
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from utils import base_url_host_matches, is_truthy_value
|
||||
from utils import base_url_host_matches
|
||||
|
||||
_hermes_home = get_hermes_home()
|
||||
_project_env = Path(__file__).parent / '.env'
|
||||
@@ -3747,7 +3747,7 @@ class HermesCLI:
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
duration_label = snapshot["duration"]
|
||||
|
||||
yolo_active = bool(os.getenv("HERMES_YOLO_MODE"))
|
||||
yolo_active = self._is_session_yolo_active()
|
||||
if width < 52:
|
||||
text = f"⚕ {snapshot['model_short']} · {duration_label}"
|
||||
if yolo_active:
|
||||
@@ -3808,7 +3808,7 @@ class HermesCLI:
|
||||
# line and produce duplicated status bar rows over long sessions.
|
||||
width = self._get_tui_terminal_width()
|
||||
duration_label = snapshot["duration"]
|
||||
yolo_active = bool(os.getenv("HERMES_YOLO_MODE"))
|
||||
yolo_active = self._is_session_yolo_active()
|
||||
|
||||
if width < 52:
|
||||
frags = [
|
||||
@@ -6234,8 +6234,10 @@ class HermesCLI:
|
||||
|
||||
# ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer
|
||||
# provider). Never invoke it; just identify the auth surface.
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
if is_token_provider(self.api_key):
|
||||
from agent.plugin_registries import registries
|
||||
_azure_ns = registries.get_provider_namespace("azure")
|
||||
is_token_provider = _azure_ns.get("is_token_provider")
|
||||
if is_token_provider and is_token_provider(self.api_key):
|
||||
api_key_display = "Microsoft Entra ID"
|
||||
elif isinstance(self.api_key, str) and len(self.api_key) > 12:
|
||||
api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}"
|
||||
@@ -6907,6 +6909,7 @@ class HermesCLI:
|
||||
pass
|
||||
|
||||
# Switch to the new session
|
||||
self._transfer_session_yolo(self.session_id, new_session_id)
|
||||
self.session_id = new_session_id
|
||||
self.session_start = now
|
||||
self._pending_title = None
|
||||
@@ -7154,11 +7157,13 @@ class HermesCLI:
|
||||
|
||||
* ``sys.platform == "win32"`` — native Windows console (ConPTY /
|
||||
win32_input) does not support the modal reliably.
|
||||
* Called from a non-main thread — the prompt_toolkit event loop only
|
||||
runs on the main thread; key bindings can't fire from a daemon
|
||||
thread (same rationale as the ``_prompt_text_input`` thread guard
|
||||
in PR #23454).
|
||||
* ``self._app`` is not set — unit tests / non-interactive contexts.
|
||||
|
||||
On non-Windows platforms the modal itself is still safe from the
|
||||
``process_loop`` daemon thread as long as the main-thread event loop
|
||||
owns the prompt_toolkit buffer mutations. When we are off the main
|
||||
thread, schedule the modal snapshot / restore work on ``self._app.loop``
|
||||
via ``call_soon_threadsafe`` and keep the queue-based response path.
|
||||
"""
|
||||
import threading
|
||||
import time as _time
|
||||
@@ -7179,33 +7184,62 @@ class HermesCLI:
|
||||
if sys.platform == "win32":
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
|
||||
# Mirror the thread-aware guard from _prompt_text_input (PR #23454):
|
||||
# run_in_terminal and the modal queue both depend on the main-thread
|
||||
# event loop. From a daemon thread the modal key bindings never fire.
|
||||
if threading.current_thread() is not threading.main_thread():
|
||||
try:
|
||||
app_loop = self._app.loop
|
||||
except Exception:
|
||||
app_loop = None
|
||||
|
||||
in_main_thread = threading.current_thread() is threading.main_thread()
|
||||
if not in_main_thread and app_loop is None:
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
|
||||
response_queue = queue.Queue()
|
||||
self._capture_modal_input_snapshot()
|
||||
self._slash_confirm_state = {
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
self._slash_confirm_deadline = _time.monotonic() + timeout
|
||||
self._invalidate()
|
||||
|
||||
def _setup_modal() -> None:
|
||||
self._capture_modal_input_snapshot()
|
||||
self._slash_confirm_state = {
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
self._slash_confirm_deadline = _time.monotonic() + timeout
|
||||
self._invalidate()
|
||||
|
||||
def _teardown_modal() -> None:
|
||||
self._slash_confirm_state = None
|
||||
self._slash_confirm_deadline = 0
|
||||
self._restore_modal_input_snapshot()
|
||||
self._invalidate()
|
||||
|
||||
def _run_on_app_loop(fn) -> bool:
|
||||
if in_main_thread or app_loop is None:
|
||||
fn()
|
||||
return True
|
||||
ready = threading.Event()
|
||||
|
||||
def _wrapped() -> None:
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
ready.set()
|
||||
|
||||
try:
|
||||
app_loop.call_soon_threadsafe(_wrapped)
|
||||
except Exception:
|
||||
return False
|
||||
return ready.wait(timeout=5)
|
||||
|
||||
if not _run_on_app_loop(_setup_modal):
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
self._slash_confirm_state = None
|
||||
self._slash_confirm_deadline = 0
|
||||
self._restore_modal_input_snapshot()
|
||||
self._invalidate()
|
||||
_run_on_app_loop(_teardown_modal)
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._slash_confirm_deadline - _time.monotonic()
|
||||
@@ -7217,10 +7251,7 @@ class HermesCLI:
|
||||
self._invalidate()
|
||||
finally:
|
||||
if self._slash_confirm_state is not None:
|
||||
self._slash_confirm_state = None
|
||||
self._slash_confirm_deadline = 0
|
||||
self._restore_modal_input_snapshot()
|
||||
self._invalidate()
|
||||
_run_on_app_loop(_teardown_modal)
|
||||
return None
|
||||
|
||||
def _submit_slash_confirm_response(self, value: str | None) -> None:
|
||||
@@ -7558,8 +7589,19 @@ class HermesCLI:
|
||||
parts = cmd_original.split(None, 1) # split off '/model'
|
||||
raw_args = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
# Parse --provider and --global flags
|
||||
model_input, explicit_provider, persist_global = parse_model_flags(raw_args)
|
||||
# Parse --provider, --global, and --refresh flags
|
||||
model_input, explicit_provider, persist_global, force_refresh = parse_model_flags(raw_args)
|
||||
|
||||
# --refresh: wipe the on-disk picker cache before building the
|
||||
# provider list. Forces a live re-fetch of every authed provider's
|
||||
# /v1/models endpoint on this open.
|
||||
if force_refresh:
|
||||
try:
|
||||
from hermes_cli.models import clear_provider_models_cache
|
||||
clear_provider_models_cache()
|
||||
_cprint(" Cleared model picker cache. Refreshing...")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Single inventory context — replaces the inline config-slice the
|
||||
# dashboard / TUI used to duplicate. Overlay live session state
|
||||
@@ -7598,6 +7640,7 @@ class HermesCLI:
|
||||
_cprint("")
|
||||
_cprint(" /model <name> switch model")
|
||||
_cprint(" /model --provider <slug> switch provider")
|
||||
_cprint(" /model --refresh re-fetch live model lists")
|
||||
return
|
||||
|
||||
self._open_model_picker(
|
||||
@@ -9579,20 +9622,92 @@ class HermesCLI:
|
||||
}
|
||||
_cprint(labels.get(self.tool_progress_mode, ""))
|
||||
|
||||
def _toggle_yolo(self):
|
||||
"""Toggle YOLO mode — skip all dangerous command approval prompts."""
|
||||
import os
|
||||
from hermes_cli.colors import Colors as _Colors
|
||||
def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None:
|
||||
"""Move YOLO bypass state from an old session key to a new one.
|
||||
|
||||
current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
Called whenever ``self.session_id`` is reassigned mid-run — ``/branch``
|
||||
forks into a new session, and auto-compression rotates the agent's
|
||||
session id into a fresh continuation session. Without this transfer
|
||||
the user's ``/yolo ON`` toggle would silently revert on the very next
|
||||
turn (the same UX failure mode that motivated this entire fix), since
|
||||
``_session_yolo`` is keyed by session id.
|
||||
|
||||
Mirrors ``tui_gateway/server.py`` (~line 1297-1305) which performs the
|
||||
same transfer for the TUI's session-rename path. No-op when YOLO
|
||||
wasn't enabled or when the ids match.
|
||||
"""
|
||||
if not old_session_id or not new_session_id or old_session_id == new_session_id:
|
||||
return
|
||||
try:
|
||||
from tools.approval import (
|
||||
disable_session_yolo,
|
||||
enable_session_yolo,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
if is_session_yolo_enabled(old_session_id):
|
||||
enable_session_yolo(new_session_id)
|
||||
disable_session_yolo(old_session_id)
|
||||
|
||||
def _is_session_yolo_active(self) -> bool:
|
||||
"""Whether YOLO bypass is currently enabled for this CLI session.
|
||||
|
||||
Reads from ``tools.approval._session_yolo`` (the same set that
|
||||
``enable_session_yolo`` / ``disable_session_yolo`` write to) so the
|
||||
status bar reflects the actual bypass state instead of a stale env
|
||||
var. Also honors the process-start ``--yolo`` flag, which freezes
|
||||
``HERMES_YOLO_MODE`` into ``_YOLO_MODE_FROZEN`` before tool imports
|
||||
happen.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import (
|
||||
_YOLO_MODE_FROZEN,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
if _YOLO_MODE_FROZEN:
|
||||
return True
|
||||
# Use ``getattr`` so test fixtures that build a CLI via ``__new__``
|
||||
# (skipping ``__init__``) don't trip an AttributeError here; the
|
||||
# status-bar builders swallow exceptions silently but lose every
|
||||
# field after the failure.
|
||||
session_key = getattr(self, "session_id", None) or "default"
|
||||
return is_session_yolo_enabled(session_key)
|
||||
|
||||
def _toggle_yolo(self):
|
||||
"""Toggle YOLO mode — skip all dangerous command approval prompts.
|
||||
|
||||
Per-session toggle that mirrors the gateway and TUI ``/yolo`` handlers
|
||||
(see ``gateway/run.py:_handle_yolo_command`` and
|
||||
``tui_gateway/server.py`` key=="yolo"). We deliberately do NOT mutate
|
||||
``HERMES_YOLO_MODE`` here — that env var is read once at module import
|
||||
time into ``tools.approval._YOLO_MODE_FROZEN`` to keep prompt-injected
|
||||
skills from flipping the bypass mid-session, so setting it after CLI
|
||||
startup is a silent no-op. Routing through ``enable_session_yolo`` /
|
||||
``disable_session_yolo`` gives the same auditable, per-session bypass
|
||||
the other surfaces have. ``run_conversation`` binds
|
||||
``self.session_id`` as the active approval session key via
|
||||
``set_current_session_key`` so the bypass takes effect on the very
|
||||
next dangerous command in this run.
|
||||
"""
|
||||
from hermes_cli.colors import Colors as _Colors
|
||||
from tools.approval import (
|
||||
disable_session_yolo,
|
||||
enable_session_yolo,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
|
||||
session_key = self.session_id or "default"
|
||||
if is_session_yolo_enabled(session_key):
|
||||
disable_session_yolo(session_key)
|
||||
_cprint(
|
||||
f" ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}"
|
||||
" — dangerous commands will require approval."
|
||||
)
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
enable_session_yolo(session_key)
|
||||
_cprint(
|
||||
f" ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}"
|
||||
" — all commands auto-approved. Use with caution."
|
||||
@@ -10639,7 +10754,8 @@ class HermesCLI:
|
||||
if not reqs.get("stt_available", reqs.get("stt_key_set")):
|
||||
raise RuntimeError(
|
||||
"Voice mode requires an STT provider for transcription.\n"
|
||||
"Option 1: pip install faster-whisper (free, local)\n"
|
||||
"Option 1: uv pip install faster-whisper "
|
||||
"(free, local; `pip install faster-whisper` also works if pip is on PATH)\n"
|
||||
"Option 2: Set GROQ_API_KEY (free tier)\n"
|
||||
"Option 3: Set VOICE_TOOLS_OPENAI_KEY (paid)"
|
||||
)
|
||||
@@ -10852,7 +10968,14 @@ class HermesCLI:
|
||||
return
|
||||
self._voice_tts_done.clear()
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
from agent.plugin_registries import registries
|
||||
_tts_provider = registries.get_tool_provider("tts")
|
||||
if _tts_provider is None:
|
||||
raise ImportError("tts tool provider not registered")
|
||||
text_to_speech_tool = _tts_provider.tool_functions.get("text_to_speech_tool")
|
||||
check_tts_requirements = _tts_provider.check_fn
|
||||
if text_to_speech_tool is None:
|
||||
raise ImportError("text_to_speech_tool not found in tts provider")
|
||||
from tools.voice_mode import play_audio_file
|
||||
|
||||
# Strip markdown and non-speech content for cleaner TTS
|
||||
@@ -11035,8 +11158,10 @@ class HermesCLI:
|
||||
status = "enabled" if self._voice_tts else "disabled"
|
||||
|
||||
if self._voice_tts:
|
||||
from tools.tts_tool import check_tts_requirements
|
||||
if not check_tts_requirements():
|
||||
from agent.plugin_registries import registries
|
||||
_tts_provider = registries.get_tool_provider("tts")
|
||||
check_tts_requirements = _tts_provider.check_fn if _tts_provider else None
|
||||
if check_tts_requirements and not check_tts_requirements():
|
||||
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
|
||||
|
||||
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
|
||||
@@ -11658,13 +11783,17 @@ class HermesCLI:
|
||||
|
||||
if self._voice_tts:
|
||||
try:
|
||||
from tools.tts_tool import (
|
||||
_load_tts_config as _load_tts_cfg,
|
||||
_get_provider as _get_prov,
|
||||
_import_elevenlabs,
|
||||
_import_sounddevice,
|
||||
stream_tts_to_speaker,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_tts_provider = registries.get_tool_provider("tts")
|
||||
if _tts_provider is None:
|
||||
raise ImportError("tts tool provider not registered")
|
||||
_load_tts_cfg = _tts_provider.config_functions.get("_load_tts_config")
|
||||
_get_prov = _tts_provider.config_functions.get("_get_provider")
|
||||
_import_elevenlabs = _tts_provider.config_functions.get("_import_elevenlabs")
|
||||
_import_sounddevice = _tts_provider.config_functions.get("_import_sounddevice")
|
||||
stream_tts_to_speaker = _tts_provider.tool_functions.get("stream_tts_to_speaker")
|
||||
if not all([_load_tts_cfg, _get_prov, stream_tts_to_speaker]):
|
||||
raise ImportError("streaming TTS functions not found in tts provider")
|
||||
_tts_cfg = _load_tts_cfg()
|
||||
if _get_prov(_tts_cfg) == "elevenlabs":
|
||||
# Verify both ElevenLabs SDK and audio output are available
|
||||
@@ -11728,6 +11857,23 @@ class HermesCLI:
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
except Exception:
|
||||
pass
|
||||
# Bind this turn's approval session key into the contextvar so
|
||||
# ``tools.approval.is_current_session_yolo_enabled()`` resolves
|
||||
# against the same key that ``/yolo`` toggles under (see
|
||||
# ``_toggle_yolo`` → ``enable_session_yolo(self.session_id)``).
|
||||
# Mirrors ``tui_gateway/server.py`` and ``gateway/run.py`` which
|
||||
# bind the same contextvar before invoking the agent.
|
||||
try:
|
||||
from tools.approval import (
|
||||
reset_current_session_key,
|
||||
set_current_session_key,
|
||||
)
|
||||
_approval_session_token = set_current_session_key(
|
||||
self.session_id or "default"
|
||||
)
|
||||
except Exception:
|
||||
reset_current_session_key = None # type: ignore[assignment]
|
||||
_approval_session_token = None
|
||||
agent_message = _voice_prefix + message if _voice_prefix else message
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
_msn = getattr(self, '_pending_model_switch_note', None)
|
||||
@@ -11769,6 +11915,15 @@ class HermesCLI:
|
||||
set_secret_capture_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
# Release the per-turn approval session key. ``_session_yolo``
|
||||
# state itself is preserved across turns (so /yolo persists
|
||||
# for the whole CLI run); we just unbind the contextvar so a
|
||||
# reused thread doesn't see stale identity on its next run.
|
||||
if _approval_session_token is not None and reset_current_session_key is not None:
|
||||
try:
|
||||
reset_current_session_key(_approval_session_token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start agent in background thread (daemon so it cannot keep the
|
||||
# process alive when the user closes the terminal tab — SIGHUP
|
||||
@@ -11899,6 +12054,7 @@ class HermesCLI:
|
||||
and getattr(self.agent, "session_id", None)
|
||||
and self.agent.session_id != self.session_id
|
||||
):
|
||||
self._transfer_session_yolo(self.session_id, self.agent.session_id)
|
||||
self.session_id = self.agent.session_id
|
||||
self._pending_title = None
|
||||
|
||||
@@ -14939,6 +15095,39 @@ def main(
|
||||
time.sleep(_grace)
|
||||
except Exception:
|
||||
pass # never block signal handling
|
||||
# Kanban worker exit path (#28181): SIGTERM hits a dispatcher-spawned
|
||||
# worker that's likely in a non-daemon thread waiting on a child
|
||||
# subprocess in _wait_for_process. Raising KeyboardInterrupt only
|
||||
# unwinds the main thread; the worker thread keeps running, the
|
||||
# process gets reparented to init, and the dispatcher's _pid_alive
|
||||
# check returns True forever — task stuck in 'running' indefinitely.
|
||||
# Skip the controlled-unwind dance and call os._exit(0) so the kernel
|
||||
# reclaims the PID immediately and detect_crashed_workers can reclaim
|
||||
# the stale claim on the next tick. Flush logging + stdout/stderr
|
||||
# first so the final debug trace isn't lost; SIGALRM deadman guards
|
||||
# the flush against any rare blocking-I/O case (the reporter measured
|
||||
# flush in <1ms; the alarm is a failsafe, not the common path).
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
try:
|
||||
import signal as _sig_mod
|
||||
if hasattr(_sig_mod, "SIGALRM"):
|
||||
# Cancel any pre-existing alarm to avoid colliding with
|
||||
# caller-installed timers.
|
||||
_sig_mod.signal(_sig_mod.SIGALRM, lambda *_: os._exit(0))
|
||||
_sig_mod.alarm(2)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import logging as _lg
|
||||
_lg.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.flush()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
raise KeyboardInterrupt()
|
||||
try:
|
||||
import signal as _signal
|
||||
|
||||
@@ -29,10 +29,37 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
# Ensure project root is importable
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
PROJECT_ROOT = Path(__file__).parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# ── Plugin directory sys.path shadow fix ────────────────────────────────────
|
||||
# pytest adds ``plugins/model-providers/`` to sys.path because
|
||||
# ``plugins/model-providers/anthropic/__init__.py`` (a provider profile) exists.
|
||||
# This makes ``import anthropic`` shadow the real SDK package with the plugin
|
||||
# directory. We fix this via pytest_load_initial_conftests (runs before pytest
|
||||
# adds package roots) AND inline here as belt-and-suspenders.
|
||||
_bad_path = str(PROJECT_ROOT / "plugins" / "model-providers")
|
||||
while _bad_path in sys.path:
|
||||
sys.path.remove(_bad_path)
|
||||
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
|
||||
del sys.modules["anthropic"]
|
||||
|
||||
|
||||
def pytest_load_initial_conftests(early_config, parser, args):
|
||||
"""Remove plugin dirs from sys.path before pytest adds package roots.
|
||||
|
||||
This must run as early as possible — before hermes_agent_anthropic is
|
||||
imported — to prevent ``plugins/model-providers/anthropic/__init__.py``
|
||||
(a provider profile) from shadowing the real ``anthropic`` SDK.
|
||||
"""
|
||||
_bad = str(PROJECT_ROOT / "plugins" / "model-providers")
|
||||
while _bad in sys.path:
|
||||
sys.path.remove(_bad)
|
||||
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
|
||||
del sys.modules["anthropic"]
|
||||
|
||||
|
||||
|
||||
# ── Per-file process isolation ──────────────────────────────────────────────
|
||||
# Tests run via ``scripts/run_tests_parallel.py``, which spawns a fresh
|
||||
@@ -533,6 +560,43 @@ def pytest_configure(config): # noqa: D401 — pytest hook
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_finish(session) -> None: # noqa: D401 — pytest hook
|
||||
"""Warn when pytest is invoked directly instead of via the parallel runner.
|
||||
|
||||
The canonical runner (scripts/run_tests_parallel.py) spawns one
|
||||
pytest subprocess per file, giving each a fresh Python interpreter.
|
||||
This prevents cross-file module-level state leakage (especially the
|
||||
plugin-registry singleton) from causing ordering-dependent failures.
|
||||
|
||||
Running ``pytest tests/`` directly collapses all files into one
|
||||
process and WILL see spurious failures that don't exist in CI.
|
||||
The runner sets HERMES_PARALLEL_RUNNER=1 in each subprocess so we
|
||||
can detect the difference here.
|
||||
"""
|
||||
if os.environ.get("HERMES_PARALLEL_RUNNER"):
|
||||
return # launched by the runner — all good
|
||||
|
||||
n = len(session.items)
|
||||
if n < 50:
|
||||
return # single-file or tiny run — fine to use bare pytest
|
||||
|
||||
_YELLOW = "\033[33m"
|
||||
_BOLD = "\033[1m"
|
||||
_RESET = "\033[0m"
|
||||
print(
|
||||
f"\n{_BOLD}{_YELLOW}⚠ hermes-agent test suite warning{_RESET}\n"
|
||||
f" You're running {n} tests directly under pytest.\n"
|
||||
f" Cross-file module-state leakage (esp. the plugin registry\n"
|
||||
f" singleton) can cause ordering-dependent failures that don't\n"
|
||||
f" exist in CI.\n\n"
|
||||
f" Use the canonical runner instead:\n"
|
||||
f" {_BOLD}python scripts/run_tests_parallel.py{_RESET}\n\n"
|
||||
f" To suppress this warning (e.g. for a focused multi-file run):\n"
|
||||
f" {_BOLD}HERMES_PARALLEL_RUNNER=1 pytest ...{_RESET}\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _live_system_guard(request, monkeypatch):
|
||||
"""Block real os.kill / systemctl / gateway-pid scans during tests.
|
||||
@@ -838,3 +902,235 @@ def _live_system_guard(request, monkeypatch):
|
||||
pass
|
||||
|
||||
yield
|
||||
|
||||
# ── Anthropic registry seed (shared across all test subdirs) ────────────────
|
||||
# Defined in root conftest so tests/hermes_cli, tests/run_agent,
|
||||
# tests/tools, tests/gateway all get it. tests/agent has its own copy too.
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
|
||||
__all__ = ["mock_anthropic_provider"]
|
||||
|
||||
|
||||
def _mock_endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""Functional mock — detects Anthropic-wire endpoints by URL pattern.
|
||||
|
||||
Reproduces the real plugin's logic without importing it.
|
||||
"""
|
||||
if not base_url:
|
||||
return False
|
||||
normalized = base_url.lower().rstrip("/")
|
||||
if normalized.endswith("/anthropic"):
|
||||
return True
|
||||
# api.anthropic.com
|
||||
if "api.anthropic.com" in normalized:
|
||||
return True
|
||||
# kimi coding plan
|
||||
if "api.kimi.com" in normalized and "/coding" in normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _mock_is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
|
||||
"""Functional mock — detects Anthropic-compat endpoints.
|
||||
|
||||
Reproduces the real plugin's logic: named compat providers OR /anthropic URL suffix.
|
||||
"""
|
||||
_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
|
||||
if provider in _COMPAT_PROVIDERS:
|
||||
return True
|
||||
url_lower = (base_url or "").lower()
|
||||
return "/anthropic" in url_lower
|
||||
|
||||
def _mock_convert_openai_images_to_anthropic(messages: list) -> list:
|
||||
"""Functional mock — converts OpenAI image_url blocks to Anthropic image blocks."""
|
||||
converted = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
converted.append(msg)
|
||||
continue
|
||||
new_content = []
|
||||
changed = False
|
||||
for block in content:
|
||||
if block.get("type") == "image_url":
|
||||
image_url_val = (block.get("image_url") or {}).get("url", "")
|
||||
if image_url_val.startswith("data:"):
|
||||
header, _, b64data = image_url_val.partition(",")
|
||||
media_type = "image/png"
|
||||
if ":" in header and ";" in header:
|
||||
media_type = header.split(":", 1)[1].split(";", 1)[0]
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": b64data,
|
||||
},
|
||||
})
|
||||
else:
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": image_url_val,
|
||||
},
|
||||
})
|
||||
changed = True
|
||||
else:
|
||||
new_content.append(block)
|
||||
converted.append({**msg, "content": new_content} if changed else msg)
|
||||
return converted
|
||||
|
||||
def _mock_maybe_wrap_anthropic(client_obj, model, api_key, base_url, api_mode=None):
|
||||
"""Functional mock for maybe_wrap_anthropic — wraps when endpoint is Anthropic-wire.
|
||||
|
||||
Reproduces the real plugin's wrapping logic without importing it.
|
||||
Uses the real AnthropicAuxiliaryClient from core (no SDK dependency).
|
||||
"""
|
||||
# Already wrapped — don't double-wrap
|
||||
from agent.anthropic_aux import (AnthropicAuxiliaryClient,
|
||||
AsyncAnthropicAuxiliaryClient)
|
||||
if isinstance(client_obj, (AnthropicAuxiliaryClient, AsyncAnthropicAuxiliaryClient)):
|
||||
return client_obj
|
||||
|
||||
# Check for other specialized adapters we should never re-dispatch
|
||||
try:
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
if isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Explicit non-anthropic api_mode wins over URL heuristics
|
||||
if api_mode and api_mode != "anthropic_messages":
|
||||
return client_obj
|
||||
|
||||
should_wrap = (
|
||||
api_mode == "anthropic_messages"
|
||||
or _mock_endpoint_speaks_anthropic_messages(base_url)
|
||||
)
|
||||
if not should_wrap:
|
||||
return client_obj
|
||||
|
||||
# Use the registry's build_anthropic_client to construct a real(ish) client
|
||||
from agent.plugin_registries import registries
|
||||
build_fn = registries.get_provider_service("anthropic", "build_anthropic_client")
|
||||
if build_fn is None:
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
real_client = build_fn(api_key, base_url)
|
||||
except Exception:
|
||||
return client_obj
|
||||
|
||||
return AnthropicAuxiliaryClient(
|
||||
real_client, model, api_key, base_url, is_oauth=False,
|
||||
)
|
||||
|
||||
def _make_base_anthropic_namespace() -> dict:
|
||||
"""Build a minimal anthropic service namespace with safe mock stubs.
|
||||
|
||||
Wire-format code (build_anthropic_kwargs, convert_messages_to_anthropic,
|
||||
AnthropicAuxiliaryClient, etc.) has moved to core modules and is no
|
||||
longer looked up via the registry. Only SDK-dependent orchestration
|
||||
(maybe_wrap_anthropic, is_anthropic_compat_endpoint, client building,
|
||||
auth) still needs mock stubs here.
|
||||
"""
|
||||
mock_client = MagicMock(name="anthropic_client")
|
||||
mock_client.base_url = "https://api.anthropic.com/v1"
|
||||
mock_client.api_key = "sk-ant-mock"
|
||||
|
||||
def _resolve_token():
|
||||
"""Return token from env vars if set — mimics the real resolve_anthropic_token."""
|
||||
import os
|
||||
return (os.environ.get("ANTHROPIC_TOKEN")
|
||||
or os.environ.get("ANTHROPIC_API_KEY"))
|
||||
|
||||
return {
|
||||
# SDK-dependent client building
|
||||
"build_anthropic_client": MagicMock(return_value=mock_client),
|
||||
"build_anthropic_bedrock_client": MagicMock(return_value=mock_client),
|
||||
"resolve_anthropic_token": _resolve_token,
|
||||
"_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"),
|
||||
"is_claude_code_token_valid": MagicMock(return_value=False),
|
||||
"read_claude_code_credentials": MagicMock(return_value=None),
|
||||
"write_claude_code_credentials": MagicMock(),
|
||||
"refresh_oauth_token": MagicMock(return_value=None),
|
||||
"run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)),
|
||||
"_HERMES_OAUTH_FILE": MagicMock(),
|
||||
# Resolve / endpoint detection (still plugin-provided, still needs mocking)
|
||||
"maybe_wrap_anthropic": _mock_maybe_wrap_anthropic,
|
||||
"endpoint_speaks_anthropic_messages": _mock_endpoint_speaks_anthropic_messages,
|
||||
"is_anthropic_compat_endpoint": _mock_is_anthropic_compat_endpoint,
|
||||
"convert_openai_images_to_anthropic": _mock_convert_openai_images_to_anthropic,
|
||||
"ANTHROPIC_DEFAULT_BASE_URL": "https://api.anthropic.com",
|
||||
"_ANTHROPIC_COMPAT_PROVIDERS": frozenset(),
|
||||
"resolve_auxiliary_client": MagicMock(return_value=(mock_client, "claude-3-5-sonnet-20241022")),
|
||||
}
|
||||
|
||||
@contextmanager
|
||||
def mock_anthropic_provider(**overrides):
|
||||
"""Patch the anthropic registry namespace. Use in core tests instead of
|
||||
patching hermes_agent_anthropic.* directly.
|
||||
|
||||
Usage:
|
||||
with mock_anthropic_provider(build_anthropic_client=my_mock):
|
||||
result = resolve_provider_client(...)
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
base = _make_base_anthropic_namespace()
|
||||
base.update(overrides)
|
||||
with patch.dict(registries._provider_services, {"anthropic": base}):
|
||||
yield base
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _seed_anthropic_registry(request):
|
||||
"""Install mock anthropic namespace before each test, restore after.
|
||||
|
||||
Skips for plugin tests — they have their own conftest that registers the
|
||||
real plugin, and we must NOT block them with _guarded_register.
|
||||
|
||||
Uses patch.dict so it's guaranteed to restore even when plugin tests
|
||||
in other directories (which use the real plugin) run before us in the
|
||||
same process. Function-scoped (not session) so it re-seeds after each
|
||||
plugin test that overwrites the registry.
|
||||
|
||||
Also clears _provider_resolvers["anthropic"] so a real plugin registration
|
||||
that leaked from another test file doesn't affect core unit tests.
|
||||
|
||||
Also blocks _ensure_plugins_discovered() so that code paths that lazily
|
||||
trigger plugin loading (e.g. get_plugin_auxiliary_tasks via
|
||||
_resolve_task_provider_model) don't overwrite the mock namespace.
|
||||
"""
|
||||
# Skip for plugin tests — they manage the registry themselves
|
||||
node_path = str(request.fspath)
|
||||
if "/plugins/" in node_path:
|
||||
yield
|
||||
return
|
||||
from unittest.mock import patch
|
||||
from agent.plugin_registries import registries
|
||||
ns = _make_base_anthropic_namespace()
|
||||
# Guard registries.register_provider_services so that if discover_and_load()
|
||||
# fires during a test (e.g. via get_plugin_auxiliary_tasks in
|
||||
# _resolve_task_provider_model), it can't overwrite our mock anthropic
|
||||
# namespace. We only block "anthropic" — other providers / hooks proceed
|
||||
# normally so tests like test_context_engine.py still work.
|
||||
_orig_register = registries.register_provider_services
|
||||
|
||||
def _guarded_register(name, services):
|
||||
if name == "anthropic":
|
||||
return # mock namespace wins — don't let the real plugin clobber it
|
||||
return _orig_register(name, services)
|
||||
|
||||
_orig_resolver = registries._provider_resolvers.pop("anthropic", None)
|
||||
with patch.dict(registries._provider_services, {"anthropic": ns}), \
|
||||
patch.object(registries, "register_provider_services", _guarded_register):
|
||||
yield
|
||||
# Restore resolver (None means "not registered", which is correct for
|
||||
# core unit tests; plugin tests that need the real resolver load it themselves)
|
||||
if _orig_resolver is not None:
|
||||
registries._provider_resolvers["anthropic"] = _orig_resolver
|
||||
else:
|
||||
registries._provider_resolvers.pop("anthropic", None)
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/sh
|
||||
# shellcheck shell=sh
|
||||
# /opt/hermes/bin/hermes — `docker exec` privilege-drop shim.
|
||||
#
|
||||
# Background
|
||||
# ----------
|
||||
# The s6 image runs the supervised gateway/main process as the unprivileged
|
||||
# `hermes` user (UID 10000). When an operator runs `docker exec <c> hermes ...`
|
||||
# the default UID is root (0), and any file the command writes under
|
||||
# $HERMES_HOME — auth.json, .env, config.yaml — ends up root-owned and
|
||||
# unreadable to the supervised gateway. The most common manifestation: the
|
||||
# user runs `docker exec <c> hermes login`, this writes
|
||||
# /opt/data/auth.json as root:root mode 0600, and from then on the gateway
|
||||
# returns "Provider authentication failed: Hermes is not logged into Nous
|
||||
# Portal" on every incoming message — even though `docker exec <c> hermes
|
||||
# chat -q ping` (also running as root) succeeds because root happens to be
|
||||
# able to read its own root-owned file. See systematic-debugging skill
|
||||
# notes attached to this fix.
|
||||
#
|
||||
# Fix
|
||||
# ---
|
||||
# This shim sits at /opt/hermes/bin/hermes and is placed earliest on PATH.
|
||||
# When invoked as root, it drops to the hermes user (via s6-setuidgid)
|
||||
# before exec'ing the real venv binary, so anything that writes under
|
||||
# $HERMES_HOME is uid-aligned with the supervised processes. When invoked
|
||||
# as any non-root UID — including the supervised processes themselves,
|
||||
# `docker exec --user hermes`, kanban subagents, etc. — it short-circuits
|
||||
# straight to the venv binary with no privilege change. Net: one extra
|
||||
# fork on the docker-exec-as-root path, zero behavioral change on every
|
||||
# other path.
|
||||
#
|
||||
# Recursion safety: the shim exec's the venv binary by *absolute path*
|
||||
# (/opt/hermes/.venv/bin/hermes), so the second hop cannot re-enter this
|
||||
# shim regardless of PATH state. No sentinel env var needed.
|
||||
#
|
||||
# Opt-out: set HERMES_DOCKER_EXEC_AS_ROOT=1 (1/true/yes, case-insensitive)
|
||||
# to keep running as root. Reserved for diagnostic sessions where the
|
||||
# operator deliberately wants root semantics — e.g. inspecting root-only
|
||||
# state via the hermes CLI. Default is to drop.
|
||||
|
||||
set -e
|
||||
|
||||
REAL=/opt/hermes/.venv/bin/hermes
|
||||
|
||||
# Defensive: if the venv binary is missing (corrupted image, partial
|
||||
# install), fail loudly rather than silently masking it.
|
||||
if [ ! -x "$REAL" ]; then
|
||||
echo "hermes-shim: $REAL not found or not executable" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Already non-root? Just exec the real binary. This is the hot path for
|
||||
# supervised processes (uid 10000) and for `docker exec --user hermes`.
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
exec "$REAL" "$@"
|
||||
fi
|
||||
|
||||
# Root, with opt-out set? Honor it.
|
||||
case "${HERMES_DOCKER_EXEC_AS_ROOT:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes)
|
||||
exec "$REAL" "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Root, no opt-out. Drop to the hermes user.
|
||||
#
|
||||
# s6-setuidgid lives under /command/ which is NOT on `docker exec`'s PATH
|
||||
# (s6-overlay only puts /command/ on PATH for supervision-tree children).
|
||||
# Reference it by absolute path so the drop is robust against PATH
|
||||
# manipulation.
|
||||
S6_SUID=/command/s6-setuidgid
|
||||
if [ ! -x "$S6_SUID" ]; then
|
||||
# Non-s6 image (someone stripped s6-overlay, or a hand-built variant).
|
||||
# Fail loud rather than silently re-execing as root and leaking the
|
||||
# bug this shim exists to prevent.
|
||||
echo "hermes-shim: $S6_SUID not found; refusing to silently run as root." >&2
|
||||
echo "hermes-shim: re-run with --user hermes or set HERMES_DOCKER_EXEC_AS_ROOT=1." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
# Reset HOME to the hermes user's home before dropping privileges. Without
|
||||
# this, $HOME stays /root and any library that resolves paths off $HOME
|
||||
# (XDG caches, lockfiles, .config writes) will try to write to /root and
|
||||
# fail with EACCES. Mirrors main-wrapper.sh.
|
||||
export HOME=/opt/data
|
||||
|
||||
exec "$S6_SUID" hermes "$REAL" "$@"
|
||||
@@ -19,6 +19,10 @@ case "${HERMES_DASHBOARD:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# with-contenv repopulates HOME from /init as /root. Reset it before
|
||||
# dropping privileges so HOME-anchored state lands under /opt/data.
|
||||
export HOME=/opt/data
|
||||
|
||||
cd /opt/data
|
||||
# shellcheck disable=SC1091
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
@@ -40,7 +40,7 @@ _GLOBAL_DEFAULTS: dict[str, Any] = {
|
||||
"interim_assistant_messages": True,
|
||||
"long_running_notifications": True,
|
||||
"busy_ack_detail": True,
|
||||
# When true, delete tool-progress / "Still working..." / status bubbles
|
||||
# When true, delete tool-progress / "⏳ Working — N min" / status bubbles
|
||||
# after the final response lands on platforms that support message
|
||||
# deletion (e.g. Telegram). Off by default — progress is still shown
|
||||
# live, just cleaned up after success so the chat doesn't fill up with
|
||||
@@ -98,14 +98,16 @@ _TIER_MINIMAL = {
|
||||
|
||||
_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
# Tier 1 — full edit support, personal/team use
|
||||
# Telegram is usually a mobile inbox: default to final-answer-first and
|
||||
# avoid permanent operational breadcrumbs unless users opt back in with
|
||||
# display.platforms.telegram.tool_progress / long_running_notifications.
|
||||
# Telegram is usually a mobile inbox: keep tool_progress quiet and skip
|
||||
# the verbose busy-ack iteration counter, but DO surface real mid-turn
|
||||
# assistant commentary (interim_assistant_messages) and DO send periodic
|
||||
# heartbeats (long_running_notifications) so the user has signal between
|
||||
# turn start and final answer. Otherwise it looks like "typing..." for
|
||||
# 30 minutes with nothing happening. Opt in to verbose iteration detail
|
||||
# via display.platforms.telegram.busy_ack_detail / tool_progress.
|
||||
"telegram": {
|
||||
**_TIER_HIGH,
|
||||
"tool_progress": "off",
|
||||
"interim_assistant_messages": False,
|
||||
"long_running_notifications": False,
|
||||
"busy_ack_detail": False,
|
||||
},
|
||||
"discord": _TIER_HIGH,
|
||||
|
||||
@@ -24,7 +24,8 @@ Exposes an HTTP server with endpoints:
|
||||
|
||||
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
|
||||
AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent
|
||||
through this adapter by pointing at http://localhost:8642/v1.
|
||||
through this adapter by pointing at http://localhost:8642/v1 and
|
||||
authenticating with API_SERVER_KEY.
|
||||
|
||||
Requires:
|
||||
- aiohttp (already available in the gateway)
|
||||
@@ -844,11 +845,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
Validate Bearer token from Authorization header.
|
||||
|
||||
Returns None if auth is OK, or a 401 web.Response on failure.
|
||||
If no API key is configured, all requests are allowed (only when API
|
||||
server is local).
|
||||
connect() refuses to start the API server without API_SERVER_KEY, so
|
||||
the no-key branch only exists for tests or unsupported manual wiring.
|
||||
"""
|
||||
if not self._api_key:
|
||||
return None # No key configured — allow all (local-only use)
|
||||
return None
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
@@ -4099,11 +4100,13 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if hasattr(sweep_task, "add_done_callback"):
|
||||
sweep_task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
# Refuse to start network-accessible without authentication
|
||||
if is_network_accessible(self._host) and not self._api_key:
|
||||
# Refuse to start without authentication. The API server can
|
||||
# dispatch terminal-capable agent work, so every deployment needs
|
||||
# an explicit API_SERVER_KEY regardless of bind address.
|
||||
if not self._api_key:
|
||||
logger.error(
|
||||
"[%s] Refusing to start: binding to %s requires API_SERVER_KEY. "
|
||||
"Set API_SERVER_KEY or use the default 127.0.0.1.",
|
||||
"[%s] Refusing to start: API_SERVER_KEY is required for the API server, "
|
||||
"including loopback-only binds on %s.",
|
||||
self.name, self._host,
|
||||
)
|
||||
return False
|
||||
@@ -4141,14 +4144,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
await self._site.start()
|
||||
|
||||
self._mark_connected()
|
||||
if not self._api_key:
|
||||
logger.warning(
|
||||
"[%s] ⚠️ No API key configured (API_SERVER_KEY / platforms.api_server.key). "
|
||||
"All requests will be accepted without authentication. "
|
||||
"Set an API key for production deployments to prevent "
|
||||
"unauthorized access to sessions, responses, and cron jobs.",
|
||||
self.name,
|
||||
)
|
||||
logger.info(
|
||||
"[%s] API server listening on http://%s:%d (model: %s)",
|
||||
self.name, self._host, self._port, self._model_name,
|
||||
|
||||
@@ -829,6 +829,13 @@ _HERMES_HOME = get_hermes_home()
|
||||
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
|
||||
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
|
||||
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
|
||||
# Strict mode toggles the original allowlist+recency path-validation behavior.
|
||||
# Off by default — symmetric with inbound (we accept any document type the
|
||||
# user uploads), and with the denylist still blocking obvious credential /
|
||||
# system paths. Operators running public-facing gateways where prompt
|
||||
# injection from one user could exfiltrate the host's secrets to that same
|
||||
# user should set this to true.
|
||||
MEDIA_DELIVERY_STRICT_ENV = "HERMES_MEDIA_DELIVERY_STRICT"
|
||||
MEDIA_DELIVERY_SAFE_ROOTS = (
|
||||
IMAGE_CACHE_DIR,
|
||||
AUDIO_CACHE_DIR,
|
||||
@@ -918,6 +925,21 @@ def _media_delivery_recency_seconds() -> float:
|
||||
return float(_MEDIA_DELIVERY_TRUST_RECENT_DEFAULT_SECONDS)
|
||||
|
||||
|
||||
def _media_delivery_strict_mode() -> bool:
|
||||
"""Return True when path validation should require allowlist/recency match.
|
||||
|
||||
Off by default. In non-strict mode, ``validate_media_delivery_path``
|
||||
accepts any existing regular file that isn't under the credential /
|
||||
system-path denylist — restoring the pre-#29523 behavior for the
|
||||
single-user case. Strict mode preserves the original
|
||||
allowlist+recency-window logic for operators running public-facing
|
||||
gateways where prompt injection from one user shouldn't be able to
|
||||
exfiltrate the host's secrets to that same user.
|
||||
"""
|
||||
raw = os.environ.get(MEDIA_DELIVERY_STRICT_ENV, "0").strip().lower()
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _media_delivery_denied_paths() -> List[Path]:
|
||||
"""Return absolute denylist paths under which delivery is never allowed."""
|
||||
denied = [Path(p) for p in _MEDIA_DELIVERY_DENIED_PREFIXES]
|
||||
@@ -972,10 +994,22 @@ def _path_is_within(path: Path, root: Path) -> bool:
|
||||
def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||
"""Return a safe absolute file path for native media delivery, else None.
|
||||
|
||||
MEDIA tags and bare local paths in model output are untrusted text. Only
|
||||
existing regular files under Hermes-managed media caches, or roots the
|
||||
operator explicitly allowlists, may be uploaded as native attachments.
|
||||
Symlinks are resolved before the containment check.
|
||||
Default mode (single-user / private gateway): accept any existing regular
|
||||
file that isn't under the credential / system-path denylist
|
||||
(``_MEDIA_DELIVERY_DENIED_PREFIXES`` + ``~/.ssh``, ``~/.aws``, etc.).
|
||||
This matches the symmetry of inbound delivery — Telegram/Discord/Slack
|
||||
will hand the agent any file the user uploads, and the agent can hand
|
||||
back any file that isn't a credential.
|
||||
|
||||
Strict mode (opt-in via ``gateway.strict`` in ``config.yaml`` or
|
||||
``HERMES_MEDIA_DELIVERY_STRICT=1``): the file MUST live under a
|
||||
Hermes-managed cache, under an operator-allowlisted root
|
||||
(``HERMES_MEDIA_ALLOW_DIRS``), or be freshly produced inside the
|
||||
configured recency window. Suitable for public-facing bots where
|
||||
prompt injection from one user shouldn't be able to exfiltrate the
|
||||
host's secrets to that same user.
|
||||
|
||||
Symlinks are resolved before any containment / denylist check.
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
@@ -999,6 +1033,8 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
|
||||
# Cache / operator allowlist is always honored — these are unconditionally
|
||||
# trusted regardless of mode.
|
||||
for root in _media_delivery_allowed_roots():
|
||||
try:
|
||||
resolved_root = root.expanduser().resolve(strict=False)
|
||||
@@ -1007,9 +1043,18 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||
if _path_is_within(resolved, resolved_root):
|
||||
return str(resolved)
|
||||
|
||||
# Outside the cache/operator allowlist: fall back to recency-based trust
|
||||
# for files the agent has just produced (e.g. ``pandoc -o /tmp/report.pdf``
|
||||
# or ``write_file("/home/user/report.pdf", ...)``). System paths and
|
||||
# Non-strict mode (default): accept anything not on the denylist.
|
||||
# The denylist still blocks /etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
|
||||
# ~/.hermes/auth.json, etc. — so the obvious prompt-injection sites
|
||||
# (``MEDIA:/etc/passwd``, ``MEDIA:~/.ssh/id_rsa``) remain rejected.
|
||||
if not _media_delivery_strict_mode():
|
||||
if _path_under_denied_prefix(resolved):
|
||||
return None
|
||||
return str(resolved)
|
||||
|
||||
# Strict mode: fall back to recency-based trust for freshly-produced
|
||||
# files (e.g. ``pandoc -o /tmp/report.pdf`` or
|
||||
# ``write_file("/home/user/report.pdf", ...)``). System paths and
|
||||
# credential locations remain blocked even when "recent" — see
|
||||
# ``_MEDIA_DELIVERY_DENIED_PREFIXES`` for the denylist.
|
||||
window = _media_delivery_recency_seconds()
|
||||
@@ -3609,8 +3654,11 @@ class BasePlatformAdapter(ABC):
|
||||
and text_content
|
||||
and not media_files):
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
|
||||
if check_tts_requirements():
|
||||
from agent.plugin_registries import registries
|
||||
_tts = registries.get_tool_provider("tts")
|
||||
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
|
||||
check_tts_requirements = _tts.check_fn if _tts else None
|
||||
if check_tts_requirements and text_to_speech_tool and check_tts_requirements():
|
||||
import json as _json
|
||||
speech_text = self.prepare_tts_text(text_content)
|
||||
if not speech_text:
|
||||
|
||||
@@ -25,6 +25,7 @@ from gateway.platforms.base import (
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
is_network_accessible,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -132,12 +133,24 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
|
||||
self._notification_scheduler = scheduler
|
||||
|
||||
def _source_allowlist_required_but_missing(self) -> bool:
|
||||
return is_network_accessible(self._host) and not self._allowed_source_networks
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._client_state is None:
|
||||
logger.error(
|
||||
"[msgraph_webhook] Refusing to start without extra.client_state configured"
|
||||
)
|
||||
return False
|
||||
if self._source_allowlist_required_but_missing():
|
||||
logger.error(
|
||||
"[msgraph_webhook] Refusing to start: binding to %s requires "
|
||||
"extra.allowed_source_cidrs. Configure the Microsoft Graph "
|
||||
"source CIDRs or bind to loopback (127.0.0.1/::1) behind a "
|
||||
"tunnel or reverse proxy.",
|
||||
self._host,
|
||||
)
|
||||
return False
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get(self._health_path, self._handle_health)
|
||||
@@ -177,6 +190,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
return {"name": chat_id, "type": "webhook"}
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
if not self._source_ip_allowed(request):
|
||||
return web.Response(status=403)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
@@ -271,9 +286,12 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
def _source_ip_allowed(self, request: "web.Request") -> bool:
|
||||
"""Return True if the request's source IP is in the configured allowlist.
|
||||
|
||||
When ``allowed_source_cidrs`` is empty (the default), everything is
|
||||
allowed — preserves behavior for dev tunnels / localhost setups.
|
||||
Loopback-only binds may omit ``allowed_source_cidrs`` for local reverse
|
||||
proxies and dev tunnels. Network-accessible binds fail closed until an
|
||||
explicit CIDR allowlist is configured.
|
||||
"""
|
||||
if self._source_allowlist_required_but_missing():
|
||||
return False
|
||||
if not self._allowed_source_networks:
|
||||
return True
|
||||
peer = request.remote or ""
|
||||
|
||||
+325
-100
@@ -932,9 +932,14 @@ if _config_path.exists():
|
||||
_redact = _security_cfg.get("redact_secrets")
|
||||
if _redact is not None:
|
||||
os.environ["HERMES_REDACT_SECRETS"] = str(_redact).lower()
|
||||
# Gateway settings (media delivery allowlist + recency trust)
|
||||
# Gateway settings (media delivery allowlist + recency trust + strict mode)
|
||||
_gateway_cfg = _cfg.get("gateway", {})
|
||||
if isinstance(_gateway_cfg, dict):
|
||||
_strict = _gateway_cfg.get("strict")
|
||||
if _strict is not None:
|
||||
os.environ["HERMES_MEDIA_DELIVERY_STRICT"] = (
|
||||
"1" if _strict else "0"
|
||||
)
|
||||
_allow_dirs = _gateway_cfg.get("media_delivery_allow_dirs")
|
||||
if _allow_dirs:
|
||||
if isinstance(_allow_dirs, str):
|
||||
@@ -5424,7 +5429,13 @@ class GatewayRunner:
|
||||
HEALTH_WINDOW = 6
|
||||
bad_ticks = 0
|
||||
last_warn_at = 0
|
||||
disabled_corrupt_boards: dict[str, tuple[str, int | None, int | None]] = {}
|
||||
# Avoid hot-looping corrupt-looking board DBs, but do not suppress
|
||||
# same-fingerprint retries forever: transient WAL/open races can
|
||||
# surface as "database disk image is malformed" for one tick.
|
||||
CORRUPT_BOARD_RETRY_AFTER_SECONDS = 300
|
||||
disabled_corrupt_boards: dict[
|
||||
str, tuple[tuple[str, int | None, int | None], float]
|
||||
] = {}
|
||||
|
||||
def _board_db_fingerprint(slug: str) -> tuple[str, int | None, int | None]:
|
||||
path = _kb.kanban_db_path(slug)
|
||||
@@ -5439,6 +5450,9 @@ class GatewayRunner:
|
||||
return (resolved, stat.st_mtime_ns, stat.st_size)
|
||||
|
||||
def _is_corrupt_board_db_error(exc: Exception) -> bool:
|
||||
corrupt_guard_error = getattr(_kb, "KanbanDbCorruptError", None)
|
||||
if corrupt_guard_error is not None and isinstance(exc, corrupt_guard_error):
|
||||
return True
|
||||
if not isinstance(exc, sqlite3.DatabaseError):
|
||||
return False
|
||||
msg = str(exc).lower()
|
||||
@@ -5458,14 +5472,27 @@ class GatewayRunner:
|
||||
"""
|
||||
conn = None
|
||||
fingerprint = _board_db_fingerprint(slug)
|
||||
disabled_fingerprint = disabled_corrupt_boards.get(slug)
|
||||
if disabled_fingerprint == fingerprint:
|
||||
return None
|
||||
if disabled_fingerprint is not None:
|
||||
logger.info(
|
||||
"kanban dispatcher: board %s database changed; retrying dispatch",
|
||||
slug,
|
||||
)
|
||||
disabled_entry = disabled_corrupt_boards.get(slug)
|
||||
if disabled_entry is not None:
|
||||
disabled_fingerprint, disabled_at = disabled_entry
|
||||
age = time.monotonic() - disabled_at
|
||||
if (
|
||||
disabled_fingerprint == fingerprint
|
||||
and age < CORRUPT_BOARD_RETRY_AFTER_SECONDS
|
||||
):
|
||||
return None
|
||||
if disabled_fingerprint == fingerprint:
|
||||
logger.info(
|
||||
"kanban dispatcher: board %s database fingerprint unchanged "
|
||||
"after %.0fs quarantine; retrying dispatch",
|
||||
slug,
|
||||
age,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"kanban dispatcher: board %s database changed; retrying dispatch",
|
||||
slug,
|
||||
)
|
||||
disabled_corrupt_boards.pop(slug, None)
|
||||
try:
|
||||
conn = _kb.connect(board=slug)
|
||||
@@ -5485,20 +5512,32 @@ class GatewayRunner:
|
||||
)
|
||||
except sqlite3.DatabaseError as exc:
|
||||
if _is_corrupt_board_db_error(exc):
|
||||
disabled_corrupt_boards[slug] = fingerprint
|
||||
disabled_corrupt_boards[slug] = (fingerprint, time.monotonic())
|
||||
logger.error(
|
||||
"kanban dispatcher: board %s database %s is not a valid "
|
||||
"SQLite database; disabling dispatch for this board "
|
||||
"until the file changes or the gateway restarts. Move "
|
||||
"or restore the file, then run `hermes kanban init` if "
|
||||
"you need a fresh board.",
|
||||
"SQLite database; pausing dispatch for this board until "
|
||||
"the file changes, the gateway restarts, or the "
|
||||
"quarantine timer expires. Move or restore the file, "
|
||||
"then run `hermes kanban init` if you need a fresh board.",
|
||||
slug,
|
||||
fingerprint[0],
|
||||
)
|
||||
return None
|
||||
logger.exception("kanban dispatcher: tick failed on board %s", slug)
|
||||
return None
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
if _is_corrupt_board_db_error(exc):
|
||||
disabled_corrupt_boards[slug] = (fingerprint, time.monotonic())
|
||||
logger.error(
|
||||
"kanban dispatcher: board %s database %s is not a valid "
|
||||
"SQLite database; pausing dispatch for this board until "
|
||||
"the file changes, the gateway restarts, or the "
|
||||
"quarantine timer expires. Move or restore the file, "
|
||||
"then run `hermes kanban init` if you need a fresh board.",
|
||||
slug,
|
||||
fingerprint[0],
|
||||
)
|
||||
return None
|
||||
logger.exception("kanban dispatcher: tick failed on board %s", slug)
|
||||
return None
|
||||
finally:
|
||||
@@ -5657,6 +5696,19 @@ class GatewayRunner:
|
||||
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
|
||||
)
|
||||
while self._running:
|
||||
try:
|
||||
# Reap zombie children before per-board work so a board DB
|
||||
# failure cannot block cleanup of unrelated workers.
|
||||
pids = await asyncio.to_thread(_kb.reap_worker_zombies)
|
||||
if pids:
|
||||
logger.info(
|
||||
"kanban dispatcher: reaped %d zombie worker(s), pids=%s",
|
||||
len(pids),
|
||||
pids,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("kanban dispatcher: zombie reaper failed")
|
||||
|
||||
try:
|
||||
if auto_decompose_enabled:
|
||||
await asyncio.to_thread(_auto_decompose_tick)
|
||||
@@ -6211,6 +6263,29 @@ class GatewayRunner:
|
||||
# plugin adapters don't need a custom factory signature.
|
||||
if hasattr(adapter, "gateway_runner"):
|
||||
adapter.gateway_runner = self
|
||||
# ── Telegram: notification mode from config ──
|
||||
# Applied here (not in the adapter factory) because it
|
||||
# reads gateway-local config that only the gateway runner
|
||||
# has access to.
|
||||
if platform.value == "telegram":
|
||||
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
|
||||
if not _notify_mode:
|
||||
try:
|
||||
_gw_cfg = _load_gateway_config()
|
||||
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
|
||||
if _raw not in {None, ""}:
|
||||
_notify_mode = str(_raw).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
_notify_mode = _notify_mode or "important"
|
||||
if _notify_mode not in {"all", "important"}:
|
||||
logger.warning(
|
||||
"Unknown telegram notifications mode '%s', "
|
||||
"defaulting to 'important' (valid: all, important)",
|
||||
_notify_mode,
|
||||
)
|
||||
_notify_mode = "important"
|
||||
adapter._notifications_mode = _notify_mode
|
||||
return adapter
|
||||
# Registered but failed to instantiate — don't silently fall
|
||||
# through to built-ins (there are none for plugin platforms).
|
||||
@@ -6224,49 +6299,13 @@ class GatewayRunner:
|
||||
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
|
||||
# Fall through to built-in adapters below
|
||||
|
||||
if platform == Platform.TELEGRAM:
|
||||
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
|
||||
if not check_telegram_requirements():
|
||||
logger.warning("Telegram: python-telegram-bot not installed")
|
||||
return None
|
||||
adapter = TelegramAdapter(config)
|
||||
# Apply Telegram notification mode from config. Controls whether
|
||||
# intermediate messages (tool progress, streaming, status) trigger
|
||||
# push notifications. Supports ENV override for quick testing.
|
||||
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
|
||||
if not _notify_mode:
|
||||
try:
|
||||
_gw_cfg = _load_gateway_config()
|
||||
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
|
||||
if _raw not in {None, ""}:
|
||||
_notify_mode = str(_raw).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
_notify_mode = _notify_mode or "important"
|
||||
if _notify_mode not in {"all", "important"}:
|
||||
logger.warning(
|
||||
"Unknown telegram notifications mode '%s', "
|
||||
"defaulting to 'important' (valid: all, important)",
|
||||
_notify_mode,
|
||||
)
|
||||
_notify_mode = "important"
|
||||
adapter._notifications_mode = _notify_mode
|
||||
return adapter
|
||||
|
||||
elif platform == Platform.WHATSAPP:
|
||||
if platform == Platform.WHATSAPP:
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
|
||||
if not check_whatsapp_requirements():
|
||||
logger.warning("WhatsApp: Node.js not installed or bridge not configured")
|
||||
return None
|
||||
return WhatsAppAdapter(config)
|
||||
|
||||
elif platform == Platform.SLACK:
|
||||
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
|
||||
if not check_slack_requirements():
|
||||
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
|
||||
return None
|
||||
return SlackAdapter(config)
|
||||
|
||||
elif platform == Platform.SIGNAL:
|
||||
from gateway.platforms.signal import SignalAdapter, check_signal_requirements
|
||||
if not check_signal_requirements():
|
||||
@@ -6295,20 +6334,6 @@ class GatewayRunner:
|
||||
return None
|
||||
return SmsAdapter(config)
|
||||
|
||||
elif platform == Platform.DINGTALK:
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
|
||||
if not check_dingtalk_requirements():
|
||||
logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set")
|
||||
return None
|
||||
return DingTalkAdapter(config)
|
||||
|
||||
elif platform == Platform.FEISHU:
|
||||
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
|
||||
if not check_feishu_requirements():
|
||||
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
|
||||
return None
|
||||
return FeishuAdapter(config)
|
||||
|
||||
elif platform == Platform.WECOM_CALLBACK:
|
||||
from gateway.platforms.wecom_callback import (
|
||||
WecomCallbackAdapter,
|
||||
@@ -6333,13 +6358,6 @@ class GatewayRunner:
|
||||
return None
|
||||
return WeixinAdapter(config)
|
||||
|
||||
elif platform == Platform.MATRIX:
|
||||
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
|
||||
if not check_matrix_requirements():
|
||||
logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'")
|
||||
return None
|
||||
return MatrixAdapter(config)
|
||||
|
||||
elif platform == Platform.API_SERVER:
|
||||
from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
|
||||
if not check_api_server_requirements():
|
||||
@@ -7970,7 +7988,8 @@ class GatewayRunner:
|
||||
"🎤 I received your voice message but can't transcribe it — "
|
||||
"no speech-to-text provider is configured.\n\n"
|
||||
"To enable voice: install faster-whisper "
|
||||
"(`pip install faster-whisper` in the Hermes venv) "
|
||||
"(`uv pip install faster-whisper` in the Hermes venv; "
|
||||
"`pip install faster-whisper` also works if pip is on PATH) "
|
||||
"and set `stt.enabled: true` in config.yaml, "
|
||||
"then /restart the gateway."
|
||||
)
|
||||
@@ -10193,8 +10212,16 @@ class GatewayRunner:
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
|
||||
# Parse --provider and --global flags
|
||||
model_input, explicit_provider, persist_global = parse_model_flags(raw_args)
|
||||
# Parse --provider, --global, and --refresh flags
|
||||
model_input, explicit_provider, persist_global, force_refresh = parse_model_flags(raw_args)
|
||||
|
||||
# --refresh: bust the disk cache so the picker shows live data.
|
||||
if force_refresh:
|
||||
try:
|
||||
from hermes_cli.models import clear_provider_models_cache
|
||||
clear_provider_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read current model/provider from config
|
||||
current_model = ""
|
||||
@@ -11401,7 +11428,12 @@ class GatewayRunner:
|
||||
audio_path = None
|
||||
actual_path = None
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts
|
||||
from agent.plugin_registries import registries
|
||||
_tts_entry = registries.get_tool_provider("tts")
|
||||
if _tts_entry is None:
|
||||
return
|
||||
text_to_speech_tool = _tts_entry.tool_functions["text_to_speech_tool"]
|
||||
_strip_markdown_for_tts = _tts_entry.tool_functions["_strip_markdown_for_tts"]
|
||||
|
||||
tts_text = _strip_markdown_for_tts(text[:4000])
|
||||
if not tts_text:
|
||||
@@ -11768,6 +11800,7 @@ class GatewayRunner:
|
||||
session_id=task_id,
|
||||
platform=platform_key,
|
||||
user_id=source.user_id,
|
||||
user_id_alt=source.user_id_alt,
|
||||
user_name=source.user_name,
|
||||
chat_id=source.chat_id,
|
||||
chat_name=source.chat_name,
|
||||
@@ -14688,9 +14721,32 @@ class GatewayRunner:
|
||||
return f"{prefix}\n\n{user_text}"
|
||||
return prefix
|
||||
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
|
||||
from agent.plugin_registries import registries
|
||||
_stt_entry = registries.get_tool_provider("stt")
|
||||
enriched_parts = []
|
||||
if _stt_entry is None or "transcribe_audio" not in _stt_entry.tool_functions:
|
||||
# No STT plugin registered — treat each audio path the same way
|
||||
# as a "No STT provider" transcription failure.
|
||||
for path in audio_paths:
|
||||
abs_path = os.path.abspath(path)
|
||||
duration_str = await _probe_audio_duration(abs_path)
|
||||
if duration_str:
|
||||
enriched_parts.append(
|
||||
f"[The user sent a voice message: {abs_path} (duration: {duration_str})]"
|
||||
)
|
||||
else:
|
||||
enriched_parts.append(f"[The user sent a voice message: {abs_path}]")
|
||||
if not enriched_parts:
|
||||
return user_text
|
||||
prefix = "\n\n".join(enriched_parts)
|
||||
_placeholder = "(The user sent a message with no text content)"
|
||||
if user_text and user_text.strip() == _placeholder:
|
||||
return prefix
|
||||
if user_text:
|
||||
return f"{prefix}\n\n{user_text}"
|
||||
return prefix
|
||||
transcribe_audio = _stt_entry.tool_functions["transcribe_audio"]
|
||||
|
||||
for path in audio_paths:
|
||||
try:
|
||||
logger.debug("Transcribing user voice: %s", path)
|
||||
@@ -15070,6 +15126,29 @@ class GatewayRunner:
|
||||
out["tools.registry_generation"] = getattr(registry, "_generation", None)
|
||||
except Exception:
|
||||
out["tools.registry_generation"] = None
|
||||
|
||||
# Honcho identity-mapping keys live in honcho.json, not user_config.
|
||||
# HonchoSessionManager freezes the resolved peer_name / ai_peer /
|
||||
# pin / aliases / prefix at construction; without busting here,
|
||||
# mid-flight honcho.json edits go unread until the next unrelated
|
||||
# cache eviction.
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
hcfg = HonchoClientConfig.from_global_config()
|
||||
out["honcho.peer_name"] = hcfg.peer_name
|
||||
out["honcho.ai_peer"] = hcfg.ai_peer
|
||||
out["honcho.pin_peer_name"] = bool(hcfg.pin_peer_name)
|
||||
out["honcho.runtime_peer_prefix"] = hcfg.runtime_peer_prefix or ""
|
||||
aliases = hcfg.user_peer_aliases or {}
|
||||
out["honcho.user_peer_aliases"] = sorted(aliases.items()) if isinstance(aliases, dict) else []
|
||||
except Exception:
|
||||
out["honcho.peer_name"] = None
|
||||
out["honcho.ai_peer"] = None
|
||||
out["honcho.pin_peer_name"] = None
|
||||
out["honcho.runtime_peer_prefix"] = None
|
||||
out["honcho.user_peer_aliases"] = None
|
||||
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
@@ -15079,6 +15158,8 @@ class GatewayRunner:
|
||||
enabled_toolsets: list,
|
||||
ephemeral_prompt: str,
|
||||
cache_keys: dict | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id_alt: str | None = None,
|
||||
) -> str:
|
||||
"""Compute a stable string key from agent config values.
|
||||
|
||||
@@ -15092,6 +15173,20 @@ class GatewayRunner:
|
||||
the output of ``_extract_cache_busting_config(user_config)`` so
|
||||
edits to model.context_length / compression.* in config.yaml are
|
||||
picked up on the next gateway message without a manual restart.
|
||||
|
||||
``user_id`` and ``user_id_alt`` are the runtime user identities
|
||||
carried by the current message's gateway source. They participate
|
||||
in the cache key because the Honcho memory provider freezes them
|
||||
into ``HonchoSessionManager`` at first-message init (see
|
||||
``plugins/memory/honcho/__init__.py::_do_session_init``). Without
|
||||
them in the signature, a shared-thread session_key (one in which
|
||||
``build_session_key`` intentionally omits the participant ID,
|
||||
e.g. ``thread_sessions_per_user=False``) would reuse the cached
|
||||
AIAgent across distinct users, causing the second user's messages
|
||||
to be attributed to the first user's resolved Honcho peer. This
|
||||
broke #27371's per-user-peer contract in multi-user gateways.
|
||||
Per-user agent rebuilds in shared threads trade prompt-cache
|
||||
warmth for correct memory attribution.
|
||||
"""
|
||||
import hashlib, json as _j
|
||||
|
||||
@@ -15116,6 +15211,8 @@ class GatewayRunner:
|
||||
# cached agent and doesn't affect system prompt or tools.
|
||||
ephemeral_prompt or "",
|
||||
_cache_keys_sorted,
|
||||
str(user_id or ""),
|
||||
str(user_id_alt or ""),
|
||||
],
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
@@ -15914,7 +16011,7 @@ class GatewayRunner:
|
||||
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
|
||||
# that implements ``delete_message``). When enabled via
|
||||
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
|
||||
# from the tool-progress / "Still working..." / status-callback bubbles
|
||||
# from the tool-progress / "⏳ Working — N min" / status-callback bubbles
|
||||
# are collected here and deleted after the final response lands.
|
||||
# Failed runs skip cleanup so the bubbles remain as breadcrumbs.
|
||||
_cleanup_progress = bool(
|
||||
@@ -16657,6 +16754,8 @@ class GatewayRunner:
|
||||
enabled_toolsets,
|
||||
combined_ephemeral,
|
||||
cache_keys=self._extract_cache_busting_config(user_config),
|
||||
user_id=getattr(source, "user_id", None),
|
||||
user_id_alt=getattr(source, "user_id_alt", None),
|
||||
)
|
||||
agent = None
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
@@ -16700,6 +16799,7 @@ class GatewayRunner:
|
||||
session_id=session_id,
|
||||
platform=platform_key,
|
||||
user_id=source.user_id,
|
||||
user_id_alt=source.user_id_alt,
|
||||
user_name=source.user_name,
|
||||
chat_id=source.chat_id,
|
||||
chat_name=source.chat_name,
|
||||
@@ -17455,35 +17555,69 @@ class GatewayRunner:
|
||||
_notify_adapter = self.adapters.get(source.platform)
|
||||
if not _notify_adapter:
|
||||
return
|
||||
# Track the heartbeat message id so we can edit-in-place on
|
||||
# platforms that support it (Telegram, Discord, Slack, etc.)
|
||||
# instead of spamming a new "Still working" bubble every
|
||||
# interval. Falls back to send-new when edit fails or isn't
|
||||
# supported by the adapter.
|
||||
_heartbeat_msg_id: Optional[str] = None
|
||||
while True:
|
||||
await asyncio.sleep(_NOTIFY_INTERVAL)
|
||||
_elapsed_mins = int((time.time() - _notify_start) // 60)
|
||||
# Include agent activity context if available.
|
||||
# Include agent activity context if available. Default
|
||||
# heartbeat is terse: elapsed + current tool. Verbose
|
||||
# iteration counter is gated on busy_ack_detail so users
|
||||
# who want it can opt in per platform.
|
||||
_agent_ref = agent_holder[0]
|
||||
_status_detail = ""
|
||||
_want_iteration_detail = bool(
|
||||
resolve_display_setting(
|
||||
user_config,
|
||||
platform_key,
|
||||
"busy_ack_detail",
|
||||
True,
|
||||
)
|
||||
)
|
||||
if _agent_ref and hasattr(_agent_ref, "get_activity_summary"):
|
||||
try:
|
||||
_a = _agent_ref.get_activity_summary()
|
||||
_parts = [f"iteration {_a['api_call_count']}/{_a['max_iterations']}"]
|
||||
if _a.get("current_tool"):
|
||||
_parts.append(f"running: {_a['current_tool']}")
|
||||
else:
|
||||
_parts.append(_a.get("last_activity_desc", ""))
|
||||
_status_detail = " — " + ", ".join(_parts)
|
||||
_parts = []
|
||||
if _want_iteration_detail:
|
||||
_parts.append(
|
||||
f"iteration {_a['api_call_count']}/{_a['max_iterations']}"
|
||||
)
|
||||
_action = _a.get("current_tool") or _a.get("last_activity_desc")
|
||||
if _action:
|
||||
_parts.append(str(_action))
|
||||
if _parts:
|
||||
_status_detail = " — " + ", ".join(_parts)
|
||||
except Exception:
|
||||
pass
|
||||
_heartbeat_text = f"⏳ Working — {_elapsed_mins} min{_status_detail}"
|
||||
try:
|
||||
_notify_res = await _notify_adapter.send(
|
||||
source.chat_id,
|
||||
f"⏳ Still working... ({_elapsed_mins} min elapsed{_status_detail})",
|
||||
metadata=_status_thread_metadata,
|
||||
)
|
||||
if (
|
||||
_cleanup_progress
|
||||
and getattr(_notify_res, "success", False)
|
||||
and getattr(_notify_res, "message_id", None)
|
||||
):
|
||||
_cleanup_msg_ids.append(str(_notify_res.message_id))
|
||||
_notify_res = None
|
||||
if _heartbeat_msg_id:
|
||||
try:
|
||||
_notify_res = await _notify_adapter.edit_message(
|
||||
source.chat_id,
|
||||
_heartbeat_msg_id,
|
||||
_heartbeat_text,
|
||||
)
|
||||
except Exception as _ee:
|
||||
logger.debug("Heartbeat edit failed: %s", _ee)
|
||||
_notify_res = None
|
||||
if not (_notify_res and getattr(_notify_res, "success", False)):
|
||||
_notify_res = await _notify_adapter.send(
|
||||
source.chat_id,
|
||||
_heartbeat_text,
|
||||
metadata=_status_thread_metadata,
|
||||
)
|
||||
if getattr(_notify_res, "success", False) and getattr(
|
||||
_notify_res, "message_id", None
|
||||
):
|
||||
_heartbeat_msg_id = str(_notify_res.message_id)
|
||||
if _cleanup_progress:
|
||||
_cleanup_msg_ids.append(_heartbeat_msg_id)
|
||||
except Exception as _ne:
|
||||
logger.debug("Long-running notification error: %s", _ne)
|
||||
|
||||
@@ -18066,6 +18200,72 @@ class GatewayRunner:
|
||||
return response
|
||||
|
||||
|
||||
def _run_planned_stop_watcher(
|
||||
stop_event: threading.Event,
|
||||
runner,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
shutdown_handler,
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
) -> None:
|
||||
"""Poll for the planned-stop marker and trigger graceful shutdown.
|
||||
|
||||
On Windows, ``asyncio.add_signal_handler`` raises NotImplementedError
|
||||
for SIGTERM/SIGINT, so the standard signal-driven shutdown path
|
||||
never runs when ``hermes gateway stop`` signals the gateway. The
|
||||
consequence is that the drain loop is skipped — in-flight agent
|
||||
sessions are killed mid-turn and ``resume_pending`` is never set,
|
||||
so the next gateway boot has no idea those sessions need to be
|
||||
auto-resumed (issue #33778, v0.13.0 session-resume feature broken
|
||||
on native Windows).
|
||||
|
||||
This watcher runs on every platform (cheap, defensive) and bridges
|
||||
the gap on Windows by translating a filesystem marker into the
|
||||
same shutdown-handler invocation a real SIGTERM would have produced
|
||||
on POSIX. The CLI's ``hermes_cli.gateway_windows.stop()`` writes
|
||||
the marker via ``write_planned_stop_marker(pid)`` and then waits
|
||||
for the gateway PID to exit; this watcher is what makes that
|
||||
exit happen cleanly.
|
||||
|
||||
On POSIX this is a no-op safety net — the signal handler always
|
||||
races us to consuming the marker file because it fires synchronously
|
||||
from the kernel's signal delivery.
|
||||
|
||||
Args:
|
||||
stop_event: cleared by start_gateway() during normal shutdown
|
||||
to tell the watcher to exit.
|
||||
runner: the GatewayRunner instance; we check ``_running`` and
|
||||
``_draining`` to avoid triggering shutdown if the gateway
|
||||
is already in one of those states.
|
||||
loop: the asyncio event loop the shutdown handler must run on.
|
||||
shutdown_handler: same callable that's wired to SIGTERM —
|
||||
tolerates a ``None`` signal argument (planned stop case)
|
||||
and consumes the marker via
|
||||
``consume_planned_stop_marker_for_self()``.
|
||||
poll_interval: seconds between marker checks. 0.5s gives a
|
||||
responsive shutdown without burning CPU.
|
||||
"""
|
||||
from gateway.status import _get_planned_stop_marker_path
|
||||
marker_path = _get_planned_stop_marker_path()
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
if (
|
||||
marker_path.exists()
|
||||
and not getattr(runner, "_draining", False)
|
||||
and getattr(runner, "_running", False)
|
||||
):
|
||||
# Drive the same path as a real signal handler.
|
||||
# Pass signal=None — the handler tolerates that and consumes
|
||||
# the marker via consume_planned_stop_marker_for_self,
|
||||
# which also validates target_pid + start_time match us.
|
||||
loop.call_soon_threadsafe(shutdown_handler, None)
|
||||
# Done — the handler will set _draining; we exit on next tick.
|
||||
break
|
||||
except Exception as _e:
|
||||
logger.debug("Planned-stop watcher tick error: %s", _e)
|
||||
stop_event.wait(poll_interval)
|
||||
|
||||
|
||||
def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60):
|
||||
"""
|
||||
Background thread that ticks the cron scheduler at a regular interval.
|
||||
@@ -18470,7 +18670,28 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
pass
|
||||
else:
|
||||
logger.info("Skipping signal handlers (not running in main thread).")
|
||||
|
||||
|
||||
# Windows fallback: asyncio.add_signal_handler raises NotImplementedError
|
||||
# on Windows, so `hermes gateway stop`'s SIGTERM (which Python maps to
|
||||
# TerminateProcess on Windows) never invokes shutdown_signal_handler.
|
||||
# That means the drain loop never runs, mark_resume_pending never fires,
|
||||
# and sessions are silently lost across restarts (issue #33778).
|
||||
#
|
||||
# The fix is a marker-polling thread: `hermes gateway stop` writes the
|
||||
# planned-stop marker BEFORE killing, and this thread notices it and
|
||||
# drives the same shutdown path the signal handler would have. Runs
|
||||
# on every platform (cheap, defensive) so non-signal-bearing
|
||||
# environments (Windows native, sandboxed CI runners that mask
|
||||
# SIGTERM) still get a clean drain.
|
||||
_planned_stop_watcher_stop = threading.Event()
|
||||
_planned_stop_watcher_thread = threading.Thread(
|
||||
target=_run_planned_stop_watcher,
|
||||
args=(_planned_stop_watcher_stop, runner, loop, shutdown_signal_handler),
|
||||
daemon=True,
|
||||
name="planned-stop-watcher",
|
||||
)
|
||||
_planned_stop_watcher_thread.start()
|
||||
|
||||
# Claim the PID file BEFORE bringing up any platform adapters.
|
||||
# This closes the --replace race window: two concurrent `gateway run
|
||||
# --replace` invocations both pass the termination-wait above, but
|
||||
@@ -18548,6 +18769,10 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
cron_stop.set()
|
||||
cron_thread.join(timeout=5)
|
||||
|
||||
# Stop the planned-stop watcher (daemon=True so this is belt-and-suspenders).
|
||||
_planned_stop_watcher_stop.set()
|
||||
_planned_stop_watcher_thread.join(timeout=2)
|
||||
|
||||
# Close MCP server connections
|
||||
try:
|
||||
from tools.mcp_tool import shutdown_mcp_servers
|
||||
|
||||
@@ -552,11 +552,6 @@ class GatewayStreamConsumer:
|
||||
self._last_edit_time = time.monotonic()
|
||||
|
||||
if got_done:
|
||||
# Record that the final content reached the user even
|
||||
# if the cosmetic final edit below fails.
|
||||
if current_update_visible and self._accumulated:
|
||||
self._final_content_delivered = True
|
||||
|
||||
# Final edit without cursor. If progressive editing failed
|
||||
# mid-stream, send a single continuation/fallback message
|
||||
# here instead of letting the base gateway path send the
|
||||
@@ -573,6 +568,7 @@ class GatewayStreamConsumer:
|
||||
# final edit — but only for adapters that don't
|
||||
# need an explicit finalize signal.
|
||||
self._final_response_sent = True
|
||||
self._final_content_delivered = True
|
||||
elif self._message_id:
|
||||
# Either the mid-stream edit didn't run (no
|
||||
# visible update this tick) OR the adapter needs
|
||||
@@ -580,8 +576,12 @@ class GatewayStreamConsumer:
|
||||
self._final_response_sent = await self._send_or_edit(
|
||||
self._accumulated, finalize=True,
|
||||
)
|
||||
if self._final_response_sent:
|
||||
self._final_content_delivered = True
|
||||
elif not self._already_sent:
|
||||
self._final_response_sent = await self._send_or_edit(self._accumulated)
|
||||
if self._final_response_sent:
|
||||
self._final_content_delivered = True
|
||||
return
|
||||
|
||||
if commentary_text is not None:
|
||||
@@ -641,6 +641,7 @@ class GatewayStreamConsumer:
|
||||
# "Let me search…") had been delivered, not the real answer.
|
||||
if _best_effort_ok and not self._final_response_sent:
|
||||
self._final_response_sent = True
|
||||
self._final_content_delivered = True
|
||||
except Exception as e:
|
||||
logger.error("Stream consumer error: %s", e)
|
||||
|
||||
@@ -778,6 +779,7 @@ class GatewayStreamConsumer:
|
||||
pass
|
||||
self._already_sent = True
|
||||
self._final_response_sent = True
|
||||
self._final_content_delivered = True
|
||||
return
|
||||
|
||||
raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
|
||||
@@ -814,11 +816,13 @@ class GatewayStreamConsumer:
|
||||
|
||||
if not result or not result.success:
|
||||
if sent_any_chunk:
|
||||
# Some continuation text already reached the user. Suppress
|
||||
# the base gateway final-send path so we don't resend the
|
||||
# full response and create another duplicate.
|
||||
# Some continuation text already reached the user, but not
|
||||
# the full response. Do NOT set _final_response_sent — the
|
||||
# base gateway final-send path should still deliver the
|
||||
# complete response so the user gets the full answer.
|
||||
# Suppress only _already_sent to avoid a duplicate send
|
||||
# of the same partial content.
|
||||
self._already_sent = True
|
||||
self._final_response_sent = True
|
||||
self._message_id = last_message_id
|
||||
self._last_sent_text = last_successful_chunk
|
||||
self._fallback_prefix = ""
|
||||
@@ -856,6 +860,7 @@ class GatewayStreamConsumer:
|
||||
self._message_id = last_message_id
|
||||
self._already_sent = True
|
||||
self._final_response_sent = True
|
||||
self._final_content_delivered = True
|
||||
self._last_sent_text = chunks[-1]
|
||||
self._fallback_prefix = ""
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.14.0"
|
||||
__release_date__ = "2026.5.16"
|
||||
__version__ = "0.15.0"
|
||||
__release_date__ = "2026.5.28"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
+227
-64
@@ -802,16 +802,18 @@ def format_auth_error(error: Exception) -> str:
|
||||
return f"{error} Run `hermes model` to re-authenticate."
|
||||
|
||||
if error.code == "subscription_required":
|
||||
return (
|
||||
"No active paid subscription found on Nous Portal. "
|
||||
"Please purchase/activate a subscription, then retry."
|
||||
)
|
||||
if error.provider == "nous":
|
||||
return _format_nous_entitlement_auth_error(error)
|
||||
return "No active paid subscription found. Please purchase/activate a subscription, then retry."
|
||||
|
||||
if error.code == "insufficient_credits":
|
||||
return (
|
||||
"Subscription credits are exhausted. "
|
||||
"Top up/renew credits in Nous Portal, then retry."
|
||||
)
|
||||
if error.provider == "nous":
|
||||
return _format_nous_entitlement_auth_error(error)
|
||||
return "Subscription credits are exhausted. Top up/renew credits, then retry."
|
||||
|
||||
if error.code in {"subscription_expired", "no_usable_credits", "account_missing"}:
|
||||
if error.provider == "nous":
|
||||
return _format_nous_entitlement_auth_error(error)
|
||||
|
||||
if error.code == "temporarily_unavailable":
|
||||
return f"{error} Please retry in a few seconds."
|
||||
@@ -819,6 +821,25 @@ def format_auth_error(error: Exception) -> str:
|
||||
return str(error)
|
||||
|
||||
|
||||
def _format_nous_entitlement_auth_error(error: AuthError) -> str:
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
message = format_nous_portal_entitlement_message(
|
||||
account_info,
|
||||
capability="Nous model access",
|
||||
)
|
||||
if message:
|
||||
return message
|
||||
except Exception:
|
||||
pass
|
||||
return f"{error} Check credits or billing in Nous Portal, then retry."
|
||||
|
||||
|
||||
def _token_fingerprint(token: Any) -> Optional[str]:
|
||||
"""Return a short hash fingerprint for telemetry without leaking token bytes."""
|
||||
if not isinstance(token, str):
|
||||
@@ -1125,11 +1146,32 @@ def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
||||
|
||||
|
||||
def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a provider's persisted state.
|
||||
|
||||
In profile mode, falls back to the global-root ``auth.json`` when the
|
||||
profile has no entry for ``provider_id``. This mirrors the per-provider
|
||||
shadowing already used by ``read_credential_pool``: workers spawned in a
|
||||
profile can see providers (e.g. ``nous``) that were only authenticated at
|
||||
global scope. Once the user runs ``hermes auth login <provider>`` inside
|
||||
the profile, the profile state fully shadows the global state on the next
|
||||
read. See issue #18594 follow-up.
|
||||
"""
|
||||
providers = auth_store.get("providers")
|
||||
if not isinstance(providers, dict):
|
||||
return None
|
||||
state = providers.get(provider_id)
|
||||
return dict(state) if isinstance(state, dict) else None
|
||||
if isinstance(providers, dict):
|
||||
state = providers.get(provider_id)
|
||||
if isinstance(state, dict):
|
||||
return dict(state)
|
||||
|
||||
# Read-only fallback to the global-root auth store (profile mode only;
|
||||
# returns empty dict in classic mode so this is a no-op).
|
||||
global_store = _load_global_auth_store()
|
||||
if global_store:
|
||||
global_providers = global_store.get("providers")
|
||||
if isinstance(global_providers, dict):
|
||||
global_state = global_providers.get(provider_id)
|
||||
if isinstance(global_state, dict):
|
||||
return dict(global_state)
|
||||
return None
|
||||
|
||||
|
||||
def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None:
|
||||
@@ -1283,23 +1325,18 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool:
|
||||
def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return persisted auth state for a provider, or None.
|
||||
|
||||
In profile mode, falls back to the global-root ``auth.json`` when the
|
||||
profile has no state for this provider. Profile state always wins when
|
||||
present. Writes (``_save_auth_store`` / ``persist_*_credentials``) are
|
||||
unchanged — they still target the profile only. This mirrors
|
||||
In profile mode, ``_load_provider_state`` already falls back to the
|
||||
global-root ``auth.json`` per-provider when the profile has no entry —
|
||||
so this is now a thin convenience wrapper. Profile state always wins
|
||||
when present. Writes (``_save_auth_store`` / ``persist_*_credentials``)
|
||||
are unchanged — they still target the profile only. This mirrors
|
||||
``read_credential_pool``'s per-provider shadowing semantics so that
|
||||
``_seed_from_singletons`` can reseed a profile's credential pool from
|
||||
global-scope provider state (e.g. a globally-authenticated Anthropic
|
||||
OAuth or Nous device-code session). See issue #18594 follow-up.
|
||||
"""
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, provider_id)
|
||||
if state is not None:
|
||||
return state
|
||||
global_store = _load_global_auth_store()
|
||||
if not global_store:
|
||||
return None
|
||||
return _load_provider_state(global_store, provider_id)
|
||||
return _load_provider_state(auth_store, provider_id)
|
||||
|
||||
|
||||
def get_active_provider() -> Optional[str]:
|
||||
@@ -1560,8 +1597,10 @@ def resolve_provider(
|
||||
# AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).
|
||||
# This runs after API-key providers so explicit keys always win.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
if has_aws_credentials():
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock_ns = registries.get_provider_namespace("bedrock")
|
||||
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
|
||||
if has_aws_credentials and has_aws_credentials():
|
||||
return "bedrock"
|
||||
except ImportError:
|
||||
pass # boto3 not installed — skip Bedrock auto-detection
|
||||
@@ -3144,6 +3183,9 @@ def _prompt_manual_callback_paste(redirect_uri: str) -> dict:
|
||||
print("not on your laptop) — that is expected. Copy the FULL URL")
|
||||
print("from your browser's address bar of that failed page and paste")
|
||||
print("it below. A bare '?code=...&state=...' fragment also works.")
|
||||
print("If the consent page shows the authorization code in-page")
|
||||
print("(xAI's current behavior) rather than redirecting, paste the")
|
||||
print("bare code value on its own.")
|
||||
print("───────────────────────────────────────────────────────────────")
|
||||
try:
|
||||
raw = input("Callback URL: ")
|
||||
@@ -3275,16 +3317,38 @@ def _sync_codex_pool_entries(
|
||||
tokens: Dict[str, str],
|
||||
last_refresh: Optional[str],
|
||||
) -> None:
|
||||
"""Mirror a fresh Codex re-auth into the credential_pool singleton entries.
|
||||
"""Mirror a fresh Codex re-auth into the credential_pool OAuth entries.
|
||||
|
||||
The runtime selects credentials from ``credential_pool.openai-codex``, not
|
||||
from ``providers.openai-codex.tokens``. A re-auth invalidates the prior
|
||||
OAuth pair server-side, but the pool's ``device_code`` entry keeps holding
|
||||
the now-consumed refresh token plus any stale error markers — so the next
|
||||
request spends a dead token and gets a 401 ``token_invalidated``. Update
|
||||
the singleton-seeded entries in lockstep with the provider tokens and clear
|
||||
the error state so the fresh credentials take effect immediately. Manual
|
||||
(``manual:*``) entries are independent credentials and are left untouched.
|
||||
OAuth pair server-side, but pool entries keep holding the now-consumed
|
||||
refresh token plus any stale error markers — so the next request spends a
|
||||
dead token and gets a 401 ``token_invalidated``.
|
||||
|
||||
What gets refreshed:
|
||||
|
||||
* ``device_code`` — the singleton-seeded entry written by the device-code
|
||||
OAuth flow when the user logged in via ``hermes setup`` / the model
|
||||
picker. Always synced with the fresh tokens.
|
||||
* ``manual:device_code`` — entries created by ``hermes auth add openai-codex``
|
||||
that use the same device-code OAuth mechanism. An interactive re-auth
|
||||
proves the user owns the ChatGPT account, so it is safe (and expected)
|
||||
to refresh these entries too. Without this, a user who once ran the
|
||||
``hermes auth add`` workaround for #33000 would silently leave that
|
||||
manual entry stale on every subsequent re-auth, recreating the issue
|
||||
reported in #33538.
|
||||
|
||||
What does NOT get refreshed:
|
||||
|
||||
* ``manual:api_key`` and any other non-device-code manual sources — those
|
||||
are independent credentials (an explicit API key, a different ChatGPT
|
||||
account, etc.) and must not be overwritten by a single re-auth.
|
||||
|
||||
Error markers (``last_status``, ``last_error_*``) are also cleared on
|
||||
every device-code-backed entry — even those whose tokens we did not
|
||||
rewrite — so that an interactive re-auth gives every relevant pool entry
|
||||
a fresh selection chance instead of leaving them marked unhealthy from a
|
||||
pre-re-auth 401.
|
||||
"""
|
||||
access_token = tokens.get("access_token")
|
||||
if not access_token:
|
||||
@@ -3296,8 +3360,15 @@ def _sync_codex_pool_entries(
|
||||
entries = pool.get("openai-codex")
|
||||
if not isinstance(entries, list):
|
||||
return
|
||||
# Sources whose tokens should be rewritten by a fresh Codex device-code
|
||||
# OAuth re-auth. ``manual:api_key`` and unknown sources are intentionally
|
||||
# excluded — they represent independent credentials.
|
||||
REFRESHABLE_SOURCES = {"device_code", "manual:device_code"}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or entry.get("source") != "device_code":
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
source = entry.get("source")
|
||||
if source not in REFRESHABLE_SOURCES:
|
||||
continue
|
||||
entry["access_token"] = access_token
|
||||
if refresh_token:
|
||||
@@ -5611,6 +5682,8 @@ def _empty_nous_auth_status() -> Dict[str, Any]:
|
||||
"access_expires_at": None,
|
||||
"agent_key_expires_at": None,
|
||||
"has_refresh_token": False,
|
||||
"inference_credential_present": False,
|
||||
"credential_source": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -5639,24 +5712,36 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
|
||||
return (agent_exp, access_exp, -priority)
|
||||
|
||||
entry = max(entries, key=_entry_sort_key)
|
||||
access_token = (
|
||||
getattr(entry, "access_token", None)
|
||||
or getattr(entry, "runtime_api_key", "")
|
||||
)
|
||||
if not access_token:
|
||||
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
|
||||
if not runtime_key:
|
||||
return _empty_nous_auth_status()
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
|
||||
refresh_token = getattr(entry, "refresh_token", None)
|
||||
is_portal_oauth = bool(access_token) and (
|
||||
auth_type.startswith("oauth") or bool(refresh_token)
|
||||
)
|
||||
label = getattr(entry, "label", "unknown")
|
||||
portal_status_url = None
|
||||
if is_portal_oauth:
|
||||
portal_status_url = (
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or DEFAULT_NOUS_PORTAL_URL
|
||||
)
|
||||
|
||||
return {
|
||||
"logged_in": True,
|
||||
"portal_base_url": getattr(entry, "portal_base_url", None)
|
||||
or getattr(entry, "base_url", None),
|
||||
"logged_in": is_portal_oauth,
|
||||
"portal_base_url": portal_status_url,
|
||||
"inference_base_url": getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None),
|
||||
"access_token": access_token,
|
||||
"access_token": access_token if is_portal_oauth else None,
|
||||
"access_expires_at": getattr(entry, "expires_at", None),
|
||||
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
|
||||
"has_refresh_token": bool(getattr(entry, "refresh_token", None)),
|
||||
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
"has_refresh_token": bool(refresh_token),
|
||||
"inference_credential_present": True,
|
||||
"credential_source": f"pool:{label}",
|
||||
"source": f"pool:{label}",
|
||||
}
|
||||
except Exception:
|
||||
return _empty_nous_auth_status()
|
||||
@@ -5739,6 +5824,10 @@ def _compute_nous_auth_status() -> Dict[str, Any]:
|
||||
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
||||
"has_refresh_token": bool(state.get("refresh_token")),
|
||||
"access_token": state.get("access_token"),
|
||||
"inference_credential_present": bool(
|
||||
state.get("access_token") or state.get("agent_key")
|
||||
),
|
||||
"credential_source": "auth_store",
|
||||
"source": "auth_store",
|
||||
}
|
||||
try:
|
||||
@@ -5756,6 +5845,8 @@ def _compute_nous_auth_status() -> Dict[str, Any]:
|
||||
or refreshed_state.get("agent_key_expires_at")
|
||||
or base_status.get("agent_key_expires_at"),
|
||||
"has_refresh_token": bool(refreshed_state.get("refresh_token")),
|
||||
"inference_credential_present": True,
|
||||
"credential_source": "auth_store",
|
||||
"source": f"runtime:{creds.get('source', 'portal')}",
|
||||
"key_id": creds.get("key_id"),
|
||||
}
|
||||
@@ -5955,8 +6046,13 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
# AWS SDK providers (Bedrock) — check via boto3 credential chain
|
||||
if pconfig and pconfig.auth_type == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
return {"logged_in": has_aws_credentials(), "provider": target}
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock_ns = registries.get_provider_namespace("bedrock")
|
||||
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
|
||||
if has_aws_credentials:
|
||||
return {"logged_in": has_aws_credentials(), "provider": target}
|
||||
else:
|
||||
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
|
||||
except ImportError:
|
||||
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
|
||||
return {"logged_in": False}
|
||||
@@ -5995,11 +6091,13 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:
|
||||
|
||||
if auth_mode == "entra_id":
|
||||
try:
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_azure_ns = registries.get_provider_namespace("azure")
|
||||
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
|
||||
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
|
||||
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
|
||||
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed]):
|
||||
raise ImportError("azure provider services not fully registered")
|
||||
installed = has_azure_identity_installed()
|
||||
entra_cfg = {}
|
||||
if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict):
|
||||
@@ -6267,6 +6365,7 @@ def _prompt_model_selection(
|
||||
pricing: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
unavailable_models: Optional[List[str]] = None,
|
||||
portal_url: str = "",
|
||||
unavailable_message: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
|
||||
|
||||
@@ -6358,18 +6457,22 @@ def _prompt_model_selection(
|
||||
choices.append(" Enter custom model name")
|
||||
choices.append(" Skip (keep current)")
|
||||
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
unavailable_footer = unavailable_message.strip()
|
||||
if not unavailable_footer and _unavailable:
|
||||
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
|
||||
|
||||
# Print the unavailable block BEFORE the menu via regular print().
|
||||
# simple_term_menu pads title lines to terminal width (causes wrapping),
|
||||
# so we keep the title minimal and use stdout for the static block.
|
||||
# clear_screen=False means our printed output stays visible above.
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
if _unavailable:
|
||||
print(menu_title)
|
||||
print()
|
||||
for mid in _unavailable:
|
||||
print(f"{_DIM} {_label(mid)}{_RESET}")
|
||||
print()
|
||||
print(f"{_DIM} ── Upgrade at {_upgrade_url} for paid models ──{_RESET}")
|
||||
print(f"{_DIM} ── {unavailable_footer} ──{_RESET}")
|
||||
print()
|
||||
effective_title = "Available free models:"
|
||||
else:
|
||||
@@ -6411,8 +6514,11 @@ def _prompt_model_selection(
|
||||
|
||||
if _unavailable:
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
unavailable_footer = unavailable_message.strip() or (
|
||||
f"Unavailable models (requires paid tier — upgrade at {_upgrade_url})"
|
||||
)
|
||||
print()
|
||||
print(f" {_DIM}── Unavailable models (requires paid tier — upgrade at {_upgrade_url}) ──{_RESET}")
|
||||
print(f" {_DIM}── {unavailable_footer} ──{_RESET}")
|
||||
for mid in _unavailable:
|
||||
print(f" {'':>{num_width}} {_DIM}{_label(mid)}{_RESET}")
|
||||
print()
|
||||
@@ -6761,6 +6867,12 @@ def _xai_oauth_loopback_login(
|
||||
remote VM). The same PKCE verifier, ``state``, and ``nonce`` are
|
||||
used for both paths so the upstream-side OAuth flow is identical.
|
||||
"""
|
||||
def _stdin_supports_manual_paste() -> bool:
|
||||
try:
|
||||
return bool(getattr(sys.stdin, "isatty", lambda: False)())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
discovery = _xai_oauth_discovery(timeout_seconds)
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
@@ -6824,12 +6936,28 @@ def _xai_oauth_loopback_login(
|
||||
else:
|
||||
print("Could not open the browser automatically; use the URL above.")
|
||||
|
||||
callback = _xai_wait_for_callback(
|
||||
server,
|
||||
thread,
|
||||
callback_result,
|
||||
timeout_seconds=max(30.0, timeout_seconds * 9),
|
||||
)
|
||||
try:
|
||||
callback = _xai_wait_for_callback(
|
||||
server,
|
||||
thread,
|
||||
callback_result,
|
||||
timeout_seconds=max(30.0, timeout_seconds * 9),
|
||||
)
|
||||
except AuthError as exc:
|
||||
if (
|
||||
getattr(exc, "code", "") != "xai_callback_timeout"
|
||||
or not _stdin_supports_manual_paste()
|
||||
):
|
||||
raise
|
||||
print()
|
||||
print("xAI loopback callback timed out.")
|
||||
print("If your browser reached a failed 127.0.0.1 callback page,")
|
||||
print("paste that FULL callback URL below to continue this login.")
|
||||
print("You can also re-run with `--manual-paste` to skip the")
|
||||
print("loopback listener from the start.")
|
||||
callback = _prompt_manual_callback_paste(redirect_uri)
|
||||
if callback.get("code") is None and callback.get("error") is None:
|
||||
raise exc
|
||||
except Exception:
|
||||
try:
|
||||
server.shutdown()
|
||||
@@ -6849,7 +6977,21 @@ def _xai_oauth_loopback_login(
|
||||
provider="xai-oauth",
|
||||
code="xai_authorization_failed",
|
||||
)
|
||||
if callback.get("state") != state:
|
||||
callback_state = callback.get("state")
|
||||
# Manual-paste bare-code path: when a user pastes only the opaque
|
||||
# authorization code (no ``code=``/``state=`` query parameters),
|
||||
# ``_parse_pasted_callback`` returns ``state=None``. xAI's consent
|
||||
# page renders the code in-page rather than redirecting through the
|
||||
# 127.0.0.1 callback, so on many remote setups (Cloud Shell, headless
|
||||
# VPS, container consoles) the bare code is the only thing the user
|
||||
# can obtain. PKCE (code_verifier) still binds the exchange to this
|
||||
# client, so the local state-equality check is redundant on the
|
||||
# bare-code path — we substitute the locally generated state to keep
|
||||
# the rest of the validation chain (and the token exchange) unchanged.
|
||||
# See #26923 (AccursedGalaxy comment, 2026-05-20).
|
||||
if callback_state is None and manual_paste:
|
||||
callback_state = state
|
||||
if callback_state != state:
|
||||
raise AuthError(
|
||||
"xAI authorization failed: state mismatch.",
|
||||
provider="xai-oauth",
|
||||
@@ -7610,8 +7752,9 @@ def _nous_device_code_login(
|
||||
portal_url = auth_state.get(
|
||||
"portal_base_url", DEFAULT_NOUS_PORTAL_URL
|
||||
).rstrip("/")
|
||||
message = format_auth_error(exc)
|
||||
print()
|
||||
print("Your Nous Portal account does not have an active subscription.")
|
||||
print(message)
|
||||
print(f" Subscribe here: {portal_url}/billing")
|
||||
print()
|
||||
print("After subscribing, run `hermes model` again to finish setup.")
|
||||
@@ -7721,11 +7864,30 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
|
||||
print()
|
||||
unavailable_models: list = []
|
||||
unavailable_message = ""
|
||||
if model_ids:
|
||||
pricing = get_pricing_for_provider("nous")
|
||||
free_tier = check_nous_free_tier()
|
||||
# Force fresh account data for model selection so recent credit
|
||||
# purchases are reflected immediately.
|
||||
free_tier = check_nous_free_tier(force_fresh=True)
|
||||
_portal_for_recs = auth_state.get("portal_base_url", "")
|
||||
if free_tier:
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
|
||||
_account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
unavailable_message = (
|
||||
format_nous_portal_entitlement_message(
|
||||
_account_info,
|
||||
capability="paid Nous models",
|
||||
)
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
unavailable_message = ""
|
||||
# The Portal's freeRecommendedModels endpoint is the
|
||||
# source of truth for what's free *right now*. Augment
|
||||
# the curated list with anything new the Portal flags
|
||||
@@ -7752,11 +7914,12 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
model_ids, pricing=pricing,
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_portal,
|
||||
unavailable_message=unavailable_message,
|
||||
)
|
||||
elif unavailable_models:
|
||||
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
print("No free models currently available.")
|
||||
print(f"Upgrade at {_url} to access paid models.")
|
||||
print(unavailable_message or f"Upgrade at {_url} to access paid models.")
|
||||
else:
|
||||
print("No curated models available for Nous Portal.")
|
||||
except Exception as exc:
|
||||
|
||||
+18
-11
@@ -221,9 +221,12 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "anthropic":
|
||||
from agent import anthropic_adapter as anthropic_mod
|
||||
|
||||
creds = anthropic_mod.run_hermes_oauth_login_pure()
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
run_hermes_oauth_login_pure = _anthropic_ns.get("run_hermes_oauth_login_pure")
|
||||
if not run_hermes_oauth_login_pure:
|
||||
raise SystemExit("Anthropic plugin not loaded — cannot run OAuth login.")
|
||||
creds = run_hermes_oauth_login_pure()
|
||||
if not creds:
|
||||
raise SystemExit("Anthropic OAuth login did not return credentials.")
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
@@ -549,8 +552,12 @@ def _interactive_auth() -> None:
|
||||
|
||||
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
if has_aws_credentials():
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock = registries.get_provider_namespace("bedrock")
|
||||
has_aws_credentials = _bedrock.get("has_aws_credentials")
|
||||
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
|
||||
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
|
||||
if has_aws_credentials and has_aws_credentials():
|
||||
auth_source = resolve_aws_auth_env_var() or "unknown"
|
||||
region = resolve_bedrock_region()
|
||||
print(f"bedrock (AWS SDK credential chain):")
|
||||
@@ -577,12 +584,12 @@ def _interactive_auth() -> None:
|
||||
_cfg_provider = str(_model_cfg.get("provider") or "").strip().lower()
|
||||
_cfg_auth_mode = str(_model_cfg.get("auth_mode") or "").strip().lower()
|
||||
if _cfg_provider == "azure-foundry" and _cfg_auth_mode == "entra_id":
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
describe_active_credential,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_azure = registries.get_provider_namespace("azure")
|
||||
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
|
||||
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
|
||||
describe_active_credential = _azure.get("describe_active_credential")
|
||||
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
|
||||
_base_url = str(_model_cfg.get("base_url") or "").strip()
|
||||
_entra = _model_cfg.get("entra") or {}
|
||||
if not isinstance(_entra, dict):
|
||||
|
||||
@@ -512,6 +512,7 @@ def _quick_snapshot_root(hermes_home: Optional[Path] = None) -> Path:
|
||||
def create_quick_snapshot(
|
||||
label: Optional[str] = None,
|
||||
hermes_home: Optional[Path] = None,
|
||||
keep: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
"""Create a quick state snapshot of critical files.
|
||||
|
||||
@@ -585,8 +586,10 @@ def create_quick_snapshot(
|
||||
with open(snap_dir / "manifest.json", "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
|
||||
# Auto-prune
|
||||
_prune_quick_snapshots(root, keep=_QUICK_DEFAULT_KEEP)
|
||||
# Auto-prune. Defaults preserve historical manual /snapshot behavior; callers
|
||||
# with known high-churn safety snapshots (for example pre-update) can pass a
|
||||
# smaller keep value so large state.db copies do not accumulate indefinitely.
|
||||
_prune_quick_snapshots(root, keep=_QUICK_DEFAULT_KEEP if keep is None else keep)
|
||||
|
||||
logger.info("State snapshot created: %s (%d files)", snap_id, len(manifest))
|
||||
return snap_id
|
||||
|
||||
+29
-1
@@ -300,14 +300,42 @@ def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:
|
||||
|
||||
|
||||
def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
|
||||
"""Return upstream/local git hashes for the startup banner."""
|
||||
"""Return upstream/local git hashes for the startup banner.
|
||||
|
||||
For source installs and dev images this runs ``git rev-parse`` against
|
||||
the active checkout. When no checkout is available — the canonical case
|
||||
is the published Docker image, which excludes ``.git`` from the build
|
||||
context — we fall back to the baked-in build SHA (see
|
||||
``hermes_cli/build_info.py``) and return it as a frozen
|
||||
``upstream == local`` state with ``ahead=0``. A built image is by
|
||||
definition pinned to one commit, so "ahead" is always zero and the
|
||||
banner correctly shows ``· upstream <sha>`` with no carried-commits
|
||||
annotation.
|
||||
"""
|
||||
repo_dir = repo_dir or _resolve_repo_dir()
|
||||
if repo_dir is None:
|
||||
# No git checkout — try the baked build SHA (Docker image path).
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return {"upstream": baked, "local": baked, "ahead": 0}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
upstream = _git_short_hash(repo_dir, "origin/main")
|
||||
local = _git_short_hash(repo_dir, "HEAD")
|
||||
if not upstream or not local:
|
||||
# Live-git lookup failed (e.g. shallow clone without origin/main).
|
||||
# Fall back to the baked build SHA if available.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return {"upstream": baked, "local": baked, "ahead": 0}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
ahead = 0
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Baked-in build metadata for Hermes Agent.
|
||||
|
||||
Source installs report their git revision live via ``git rev-parse`` (see
|
||||
``hermes_cli/dump.py`` and ``hermes_cli/banner.py``). That doesn't work inside
|
||||
the published Docker image because ``.dockerignore`` excludes ``.git``, so
|
||||
those callsites fall back to ``"(unknown)"`` / drop the banner suffix entirely.
|
||||
|
||||
To make ``hermes dump`` and the startup banner identify the exact commit the
|
||||
image was built from, the Docker build writes the build-time ``$HERMES_GIT_SHA``
|
||||
arg into ``<project_root>/.hermes_build_sha``. This module is the single
|
||||
read-side helper consumed by both callsites — keeping the lookup in one place
|
||||
so the file path and missing-file behaviour stay consistent.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- Returns ``None`` when the file is absent. Source installs and dev images
|
||||
built without the ``HERMES_GIT_SHA`` build-arg fall through to live-git
|
||||
resolution in the caller, so non-Docker installs are unaffected.
|
||||
- Returns ``None`` on any IO / decoding error. The build-sha is a nice-to-have
|
||||
for support triage; nothing in the CLI is allowed to crash because of it.
|
||||
- Truncates to ``short`` characters (default 8) to match the format used by
|
||||
``git rev-parse --short=8`` throughout the codebase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Path is resolved relative to this module so it works regardless of cwd —
|
||||
# matches the pattern used by ``banner._resolve_repo_dir``.
|
||||
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
|
||||
|
||||
|
||||
def get_build_sha(short: int = 8) -> Optional[str]:
|
||||
"""Return the baked-in build SHA, truncated to ``short`` chars, or None.
|
||||
|
||||
Reads ``<project_root>/.hermes_build_sha`` if present. The file is
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg and contains
|
||||
the full 40-character commit hash on a single line.
|
||||
"""
|
||||
try:
|
||||
if not _BUILD_SHA_FILE.is_file():
|
||||
return None
|
||||
sha = _BUILD_SHA_FILE.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not sha:
|
||||
return None
|
||||
return sha[:short] if short and short > 0 else sha
|
||||
@@ -29,21 +29,29 @@ DEFAULT_CODEX_MODELS: List[str] = [
|
||||
# curated fallback so Pro users still see Spark in `/model` when live
|
||||
# discovery is unavailable (offline first run, transient API failure).
|
||||
"gpt-5.3-codex-spark",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
# NOTE: gpt-5.2-codex / gpt-5.1-codex-max / gpt-5.1-codex-mini were
|
||||
# previously listed here but the chatgpt.com Codex backend returns
|
||||
# HTTP 400 "The '<model>' model is not supported when using Codex with
|
||||
# a ChatGPT account." for all three on every ChatGPT Pro account we've
|
||||
# tested (verified live 2026-05-27). Keeping them in the fallback list
|
||||
# leaked dead slugs into /model when live discovery was unavailable
|
||||
# (transient API failure, first-run before refresh) and surfaced HTTP 400
|
||||
# crashes on selection. The Codex CLI public catalog still references
|
||||
# these slugs, which is why they survived previously — but those entries
|
||||
# describe the public OpenAI API, not the OAuth-backed Codex backend
|
||||
# Hermes uses. Removed here. If OpenAI re-enables them on Codex backend,
|
||||
# live discovery will pick them up automatically via _fetch_models_from_api.
|
||||
]
|
||||
|
||||
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
||||
("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex")),
|
||||
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.4-mini", ("gpt-5.3-codex",)),
|
||||
("gpt-5.4", ("gpt-5.3-codex",)),
|
||||
# Surface Spark whenever any compatible Codex template is present so
|
||||
# accounts hitting the live endpoint with an older lineup still see
|
||||
# Spark in the picker. Backend gates real availability by ChatGPT Pro
|
||||
# entitlement; Hermes does not.
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex",)),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model for this session", "Configuration",
|
||||
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
|
||||
aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"),
|
||||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||||
"Configuration", aliases=("codex_runtime",),
|
||||
args_hint="[auto|codex_app_server]"),
|
||||
|
||||
+84
-7
@@ -345,6 +345,58 @@ def recommended_update_command() -> str:
|
||||
return recommended_update_command_for_method(method)
|
||||
|
||||
|
||||
# Long-form text for ``hermes update`` / ``--check`` when running inside the
|
||||
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
|
||||
# hermes_cli/main.py; lives here so the wording stays consistent and we
|
||||
# don't grow two slightly-different copies.
|
||||
#
|
||||
# Why this matters:
|
||||
# - The published image excludes ``.git`` (see .dockerignore), so the
|
||||
# git-based update path can never succeed inside the container.
|
||||
# - The pre-existing fallback message ("✗ Not a git repository. Please
|
||||
# reinstall: curl ... install.sh") is actively misleading inside Docker
|
||||
# — that script installs a *new* host-side Hermes, it doesn't update
|
||||
# the running container.
|
||||
# - The right action is ``docker pull`` + restart the container; this
|
||||
# helper spells that out, with notes on tag pinning and config
|
||||
# persistence so users don't get blindsided.
|
||||
_DOCKER_UPDATE_MESSAGE = """\
|
||||
✗ ``hermes update`` doesn't apply inside the Docker container.
|
||||
|
||||
Hermes Agent runs as a published image (nousresearch/hermes-agent), not a
|
||||
git checkout — the container has no working tree to pull into. Update by
|
||||
pulling a fresh image and restarting your container instead:
|
||||
|
||||
docker pull nousresearch/hermes-agent:latest
|
||||
# then restart whatever started the container, e.g.:
|
||||
docker compose up -d --force-recreate hermes-agent
|
||||
# or, for ad-hoc runs, exit the current container and `docker run` again
|
||||
|
||||
Verify the new version after restart:
|
||||
docker run --rm nousresearch/hermes-agent:latest --version
|
||||
|
||||
Notes:
|
||||
• If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag
|
||||
won't move your container — pull the newer tag you actually want, or
|
||||
switch to ``:latest`` / ``:main`` for rolling updates. See available
|
||||
tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags
|
||||
• Your config and session history live under ``$HERMES_HOME`` (``/opt/data``
|
||||
in the container, typically bind-mounted from the host) and persist
|
||||
across image upgrades — re-pulling doesn't lose any state.
|
||||
• Running a fork? Build your own image with this repo's ``Dockerfile``
|
||||
and replace the ``docker pull`` step with your build/push pipeline."""
|
||||
|
||||
|
||||
def format_docker_update_message() -> str:
|
||||
"""Return the user-facing message for ``hermes update`` inside Docker.
|
||||
|
||||
Centralised so ``cmd_update`` (the apply path) and ``_cmd_update_check``
|
||||
(the dry-run path) share the same wording. See ``_DOCKER_UPDATE_MESSAGE``
|
||||
above for the full rationale.
|
||||
"""
|
||||
return _DOCKER_UPDATE_MESSAGE
|
||||
|
||||
|
||||
def format_managed_message(action: str = "modify this Hermes installation") -> str:
|
||||
"""Build a user-facing error for managed installs."""
|
||||
managed_system = get_managed_system() or "a package manager"
|
||||
@@ -1754,6 +1806,21 @@ DEFAULT_CONFIG = {
|
||||
# Gateway settings — control how messaging platforms (Telegram, Discord,
|
||||
# Slack, etc.) deliver agent-produced files as native attachments.
|
||||
"gateway": {
|
||||
# When false (default), any file path the agent emits is delivered
|
||||
# as a native attachment as long as it isn't under the credential /
|
||||
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
|
||||
# auth.json, etc.). This matches the symmetry of inbound delivery
|
||||
# — we accept any document type the user uploads, and the agent
|
||||
# can hand back any file that isn't a credential.
|
||||
#
|
||||
# When true, fall back to the older allowlist+recency-window
|
||||
# behavior: files must live under the Hermes cache, under
|
||||
# ``media_delivery_allow_dirs``, or be freshly produced inside the
|
||||
# ``trust_recent_files_seconds`` window. Recommended for
|
||||
# public-facing gateways where prompt injection from one user
|
||||
# shouldn't be able to exfiltrate the host's secrets to that same
|
||||
# user. Bridged to HERMES_MEDIA_DELIVERY_STRICT.
|
||||
"strict": False,
|
||||
# Extra directories from which model-emitted bare file paths may be
|
||||
# uploaded as native gateway attachments. Files inside the Hermes
|
||||
# cache (~/.hermes/cache/{documents,images,audio,video,screenshots})
|
||||
@@ -1761,7 +1828,7 @@ DEFAULT_CONFIG = {
|
||||
# (project dirs, scratch dirs, mounted shares). Accepts a list of
|
||||
# absolute paths or a single os.pathsep-separated string. Bridged
|
||||
# to HERMES_MEDIA_ALLOW_DIRS at gateway startup. Tilde paths are
|
||||
# expanded.
|
||||
# expanded. Honored in both default and strict mode.
|
||||
"media_delivery_allow_dirs": [],
|
||||
# When true, files whose mtime is within ``trust_recent_files_seconds``
|
||||
# of "now" are trusted for native delivery even outside the cache /
|
||||
@@ -1769,10 +1836,12 @@ DEFAULT_CONFIG = {
|
||||
# PDFs the agent writes into a working directory. System paths
|
||||
# (/etc, /proc, ~/.ssh, ~/.aws, etc.) remain blocked regardless.
|
||||
# Disable to fall back to pure-allowlist mode. Bridged to
|
||||
# HERMES_MEDIA_TRUST_RECENT_FILES.
|
||||
# HERMES_MEDIA_TRUST_RECENT_FILES. Only consulted when ``strict``
|
||||
# is true; in default mode the denylist alone gates delivery.
|
||||
"trust_recent_files": True,
|
||||
# Recency window in seconds. 600 (10 min) comfortably covers a
|
||||
# multi-tool agent turn. Bridged to HERMES_MEDIA_TRUST_RECENT_SECONDS.
|
||||
# Only consulted when ``strict`` is true.
|
||||
"trust_recent_files_seconds": 600,
|
||||
},
|
||||
|
||||
@@ -2453,10 +2522,10 @@ OPTIONAL_ENV_VARS = {
|
||||
"advanced": True,
|
||||
},
|
||||
"TAVILY_API_KEY": {
|
||||
"description": "Tavily API key for AI-native web search, extract, and crawl",
|
||||
"description": "Tavily API key for AI-native web search and extract",
|
||||
"prompt": "Tavily API key",
|
||||
"url": "https://app.tavily.com/home",
|
||||
"tools": ["web_search", "web_extract", "web_crawl"],
|
||||
"tools": ["web_search", "web_extract"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
@@ -2532,6 +2601,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"KREA_API_KEY": {
|
||||
"description": "Krea API key for Krea 2 image generation (Medium + Large)",
|
||||
"prompt": "Krea API key",
|
||||
"url": "https://www.krea.ai/settings/api-tokens",
|
||||
"tools": ["image_generate"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"VOICE_TOOLS_OPENAI_KEY": {
|
||||
"description": "OpenAI API key for voice transcription (Whisper) and OpenAI TTS",
|
||||
"prompt": "OpenAI API Key (for Whisper STT + TTS)",
|
||||
@@ -2932,8 +3009,8 @@ OPTIONAL_ENV_VARS = {
|
||||
"advanced": True,
|
||||
},
|
||||
"API_SERVER_KEY": {
|
||||
"description": "Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.",
|
||||
"prompt": "API server auth key (required for network access)",
|
||||
"description": "Bearer token for API server authentication. Required whenever the API server is enabled; server refuses to start without it.",
|
||||
"prompt": "API server auth key",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
@@ -2948,7 +3025,7 @@ OPTIONAL_ENV_VARS = {
|
||||
"advanced": True,
|
||||
},
|
||||
"API_SERVER_HOST": {
|
||||
"description": "Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.",
|
||||
"description": "Host/bind address for the API server (default: 127.0.0.1). API_SERVER_KEY is still required even on loopback binds.",
|
||||
"prompt": "API server host",
|
||||
"url": None,
|
||||
"password": False,
|
||||
|
||||
+24
-38
@@ -28,7 +28,6 @@ from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from utils import base_url_host_matches
|
||||
|
||||
|
||||
_PROVIDER_ENV_HINTS = (
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
@@ -54,14 +53,11 @@ _PROVIDER_ENV_HINTS = (
|
||||
"TOKENHUB_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
from hermes_constants import is_termux as _is_termux
|
||||
|
||||
|
||||
def _python_install_cmd() -> str:
|
||||
return "python -m pip install" if _is_termux() else "uv pip install"
|
||||
|
||||
|
||||
def _system_package_install_cmd(pkg: str) -> str:
|
||||
if _is_termux():
|
||||
return f"pkg install {pkg}"
|
||||
@@ -69,7 +65,6 @@ def _system_package_install_cmd(pkg: str) -> str:
|
||||
return f"brew install {pkg}"
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def _safe_which(cmd: str) -> str | None:
|
||||
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
|
||||
try:
|
||||
@@ -77,7 +72,6 @@ def _safe_which(cmd: str) -> str | None:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps: list[str] = []
|
||||
step = 1
|
||||
@@ -88,7 +82,6 @@ def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps.append(f"{step + 1}) agent-browser install")
|
||||
return steps
|
||||
|
||||
|
||||
def _termux_install_all_fallback_notes() -> list[str]:
|
||||
return [
|
||||
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
|
||||
@@ -97,12 +90,10 @@ def _termux_install_all_fallback_notes() -> list[str]:
|
||||
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
|
||||
]
|
||||
|
||||
|
||||
def _has_provider_env_config(content: str) -> bool:
|
||||
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
|
||||
return any(key in content for key in _PROVIDER_ENV_HINTS)
|
||||
|
||||
|
||||
def _honcho_is_configured_for_doctor() -> bool:
|
||||
"""Return True when Honcho is configured, even if this process has no active session."""
|
||||
try:
|
||||
@@ -113,7 +104,6 @@ def _honcho_is_configured_for_doctor() -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_kanban_worker_env_gate(item: dict) -> bool:
|
||||
"""Return True when Kanban is unavailable only because this is not a worker process."""
|
||||
if item.get("name") != "kanban":
|
||||
@@ -124,14 +114,12 @@ def _is_kanban_worker_env_gate(item: dict) -> bool:
|
||||
tools = item.get("tools") or []
|
||||
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
|
||||
|
||||
|
||||
def _doctor_tool_availability_detail(toolset: str) -> str:
|
||||
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
|
||||
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
|
||||
"""Adjust runtime-gated tool availability for doctor diagnostics."""
|
||||
updated_available = list(available)
|
||||
@@ -149,7 +137,6 @@ def _apply_doctor_tool_availability_overrides(available: list[str], unavailable:
|
||||
updated_unavailable.append(item)
|
||||
return updated_available, updated_unavailable
|
||||
|
||||
|
||||
def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:
|
||||
"""Return True when a direct API-key probe failure is non-blocking.
|
||||
|
||||
@@ -179,7 +166,6 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def check_ok(text: str, detail: str = ""):
|
||||
print(f" {color('✓', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
|
||||
|
||||
@@ -192,19 +178,16 @@ def check_fail(text: str, detail: str = ""):
|
||||
def check_info(text: str):
|
||||
print(f" {color('→', Colors.CYAN)} {text}")
|
||||
|
||||
|
||||
def _section(title: str) -> None:
|
||||
"""Print a doctor section banner: blank line + bold cyan ◆ title."""
|
||||
print()
|
||||
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
|
||||
"""Emit a check_fail and append the corresponding fix instruction."""
|
||||
check_fail(text, detail)
|
||||
issues.append(fix)
|
||||
|
||||
|
||||
def _check_s6_supervision(issues: list[str]) -> None:
|
||||
"""Inside a container under our s6 /init, surface what s6 sees.
|
||||
|
||||
@@ -252,7 +235,6 @@ def _check_s6_supervision(issues: list[str]) -> None:
|
||||
+ (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "")
|
||||
)
|
||||
|
||||
|
||||
def _check_gateway_service_linger(issues: list[str]) -> None:
|
||||
"""Warn when a systemd user gateway service will stop after logout.
|
||||
|
||||
@@ -296,10 +278,8 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
|
||||
else:
|
||||
check_warn("Could not verify systemd linger", f"({linger_detail})")
|
||||
|
||||
|
||||
_APIKEY_PROVIDERS_CACHE: list | None = None
|
||||
|
||||
|
||||
def _build_apikey_providers_list() -> list:
|
||||
"""Build the API-key provider health-check list once and cache it.
|
||||
|
||||
@@ -391,7 +371,6 @@ def _build_apikey_providers_list() -> list:
|
||||
pass
|
||||
return _static
|
||||
|
||||
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
@@ -1475,12 +1454,15 @@ def run_doctor(args):
|
||||
return _ConnectivityResult("Anthropic API", [], [])
|
||||
try:
|
||||
import httpx
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
_COMMON_BETAS,
|
||||
_OAUTH_ONLY_BETAS,
|
||||
_CONTEXT_1M_BETA,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
|
||||
# _COMMON_BETAS and _CONTEXT_1M_BETA are now in core
|
||||
from agent.anthropic_format import _COMMON_BETAS, _CONTEXT_1M_BETA
|
||||
_OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS")
|
||||
|
||||
if not all([_is_oauth_token, _OAUTH_ONLY_BETAS]):
|
||||
raise ImportError("anthropic provider services not fully registered")
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
is_oauth = _is_oauth_token(key)
|
||||
if is_oauth:
|
||||
@@ -1624,11 +1606,13 @@ def run_doctor(args):
|
||||
|
||||
def _probe_bedrock() -> _ConnectivityResult:
|
||||
try:
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock_ns = registries.get_provider_namespace("bedrock")
|
||||
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
|
||||
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
|
||||
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
|
||||
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region]):
|
||||
raise ImportError("bedrock provider services not fully registered")
|
||||
except ImportError:
|
||||
return _ConnectivityResult("AWS Bedrock", [], [])
|
||||
if not has_aws_credentials():
|
||||
@@ -1699,12 +1683,14 @@ def run_doctor(args):
|
||||
return _ConnectivityResult("Azure Foundry (Entra ID)", [], [])
|
||||
|
||||
try:
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
describe_active_credential,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_azure_ns = registries.get_provider_namespace("azure")
|
||||
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
|
||||
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
|
||||
describe_active_credential = _azure_ns.get("describe_active_credential")
|
||||
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
|
||||
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, describe_active_credential, has_azure_identity_installed]):
|
||||
raise ImportError("azure provider services not fully registered")
|
||||
except Exception as exc:
|
||||
return _ConnectivityResult(
|
||||
"Azure Foundry (Entra ID)",
|
||||
|
||||
+24
-2
@@ -20,7 +20,15 @@ from agent.skill_utils import is_excluded_skill_path
|
||||
|
||||
|
||||
def _get_git_commit(project_root: Path) -> str:
|
||||
"""Return short git commit hash, or '(unknown)'."""
|
||||
"""Return short git commit hash, or '(unknown)'.
|
||||
|
||||
Source installs and dev images resolve this live via ``git rev-parse``.
|
||||
The published Docker image excludes ``.git`` from the build context, so
|
||||
that lookup always fails — we fall back to the baked-in build SHA written
|
||||
to ``<project_root>/.hermes_build_sha`` by the Dockerfile's
|
||||
``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``).
|
||||
The output format is identical regardless of source.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short=8", "HEAD"],
|
||||
@@ -28,9 +36,23 @@ def _get_git_commit(project_root: Path) -> str:
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
value = result.stdout.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to the build-time baked SHA (populated in published Docker
|
||||
# images, absent otherwise). Defers the import so the dump module
|
||||
# stays cheap on non-dump code paths.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return baked
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "(unknown)"
|
||||
|
||||
|
||||
|
||||
+82
-3
@@ -4370,7 +4370,9 @@ def _setup_feishu():
|
||||
if method_idx == 0:
|
||||
# ── QR scan-to-create ──
|
||||
try:
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from agent.plugin_registries import registries
|
||||
_feishu_entry = registries.get_platform("feishu")
|
||||
qr_register = _feishu_entry.helper_functions.get("qr_register") if _feishu_entry else None
|
||||
except Exception as exc:
|
||||
print_error(f" Feishu / Lark onboard import failed: {exc}")
|
||||
qr_register = None
|
||||
@@ -4411,8 +4413,13 @@ def _setup_feishu():
|
||||
# Try to probe the bot with manual credentials
|
||||
bot_name = None
|
||||
try:
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
bot_info = probe_bot(app_id, app_secret, domain)
|
||||
from agent.plugin_registries import registries
|
||||
_feishu_entry = registries.get_platform("feishu")
|
||||
probe_bot = _feishu_entry.helper_functions.get("probe_bot") if _feishu_entry else None
|
||||
if probe_bot:
|
||||
bot_info = probe_bot(app_id, app_secret, domain)
|
||||
else:
|
||||
bot_info = None
|
||||
if bot_info:
|
||||
bot_name = bot_info.get("bot_name")
|
||||
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")
|
||||
@@ -5150,11 +5157,83 @@ def gateway_command(args):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _maybe_redirect_run_to_s6_supervision(args) -> bool:
|
||||
"""Inside an s6 container, redirect bare ``gateway run`` to the
|
||||
supervised path.
|
||||
|
||||
Background. Before the s6 image landed, ``docker run <image> gateway
|
||||
run`` was the standard way to start a containerized gateway: the
|
||||
gateway was the container's main process, tini reaped zombies, and
|
||||
container exit code == gateway exit code. With s6-overlay as PID 1,
|
||||
we'd much rather have the gateway run as a supervised s6 longrun
|
||||
(auto-restart on crash, dashboard supervised alongside, multiple
|
||||
profile gateways under the same /init). This redirect upgrades the
|
||||
old invocation transparently — the user gets the new behavior
|
||||
without changing their docker run command.
|
||||
|
||||
Three gates make this a no-op outside the intended scope:
|
||||
|
||||
1. ``_dispatch_via_service_manager_if_s6`` returns False unless
|
||||
we're in a container with s6 as PID 1. Host runs of
|
||||
``hermes gateway run`` are unaffected.
|
||||
2. ``HERMES_S6_SUPERVISED_CHILD`` is exported by
|
||||
``S6ServiceManager._render_run_script`` for the supervised
|
||||
process itself — i.e. when s6-supervise execs ``hermes gateway
|
||||
run --replace`` as a longrun, this guard short-circuits the
|
||||
redirect so the supervised gateway actually runs in
|
||||
foreground (otherwise we'd recurse: run → start → run → start
|
||||
→ ...).
|
||||
3. ``--no-supervise`` (or ``HERMES_GATEWAY_NO_SUPERVISE=1``) opts
|
||||
out for users who genuinely want pre-s6 semantics — CI smoke
|
||||
tests, debugging the foreground startup path, etc.
|
||||
|
||||
Returns True iff dispatched (caller should ``return``).
|
||||
"""
|
||||
no_supervise = getattr(args, "no_supervise", False) or \
|
||||
os.environ.get("HERMES_GATEWAY_NO_SUPERVISE", "").lower() in ("1", "true", "yes")
|
||||
if no_supervise:
|
||||
return False
|
||||
if os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
|
||||
# We ARE the supervised child s6-supervise is running. Fall
|
||||
# through to the foreground code path so the gateway actually
|
||||
# starts.
|
||||
return False
|
||||
if not _dispatch_via_service_manager_if_s6("start"):
|
||||
return False
|
||||
# Loud breadcrumb: explain the upgrade and how to opt out. Print to
|
||||
# stderr so it doesn't pollute stdout-parsing scripts. The
|
||||
# supervised gateway's own logs are routed by s6-log to both
|
||||
# `docker logs` and ${HERMES_HOME}/logs/gateways/<profile>/current,
|
||||
# so the user sees a clear sequence: this banner first, then the
|
||||
# gateway's own stdout/stderr from the supervisor.
|
||||
print(
|
||||
"→ gateway is now running under s6 supervision (auto-restart on crash,\n"
|
||||
" dashboard supervised alongside if HERMES_DASHBOARD is set).\n"
|
||||
" This is the recommended setup for the s6 container image — the\n"
|
||||
" gateway will keep running even if it crashes.\n"
|
||||
" Use `--no-supervise` (or HERMES_GATEWAY_NO_SUPERVISE=1) to opt out\n"
|
||||
" and get the pre-s6 foreground behavior instead.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
# Block until the container is signalled. The supervised gateway's
|
||||
# lifetime is independent of this process — s6-supervise restarts
|
||||
# it on crash, and we don't want the container to exit when the
|
||||
# gateway flaps. `sleep infinity` matches the static main-hermes
|
||||
# service's pattern (see docker/s6-rc.d/main-hermes/run): the CMD
|
||||
# process is a no-op heartbeat that keeps /init alive until
|
||||
# `docker stop` sends SIGTERM, at which point /init runs stage 3
|
||||
# shutdown (which tears down the supervised gateway cleanly).
|
||||
os.execvp("sleep", ["sleep", "infinity"])
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
subcmd = getattr(args, 'gateway_command', None)
|
||||
|
||||
# Default to run if no subcommand
|
||||
if subcmd is None or subcmd == "run":
|
||||
if _maybe_redirect_run_to_s6_supervision(args):
|
||||
return # unreachable; execvp doesn't return
|
||||
verbose = getattr(args, 'verbose', 0)
|
||||
quiet = getattr(args, 'quiet', False)
|
||||
replace = getattr(args, 'replace', False)
|
||||
|
||||
@@ -1014,12 +1014,70 @@ def start() -> None:
|
||||
_report_gateway_start(f"direct spawn (PID {pid})")
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
"""Stop the gateway. Tries /End on the scheduled task, then kills any stragglers."""
|
||||
_assert_windows()
|
||||
from hermes_cli.gateway import kill_gateway_processes
|
||||
def _drain_gateway_pid(pid: int, drain_timeout: float) -> bool:
|
||||
"""Write the planned-stop marker and wait for the gateway PID to exit.
|
||||
|
||||
stopped_any = False
|
||||
Windows cannot deliver POSIX signals to a Python asyncio loop
|
||||
(``loop.add_signal_handler`` raises NotImplementedError), so writing
|
||||
the marker is the ONLY way to ask a running gateway to drain
|
||||
in-flight agents and persist ``resume_pending`` before exit. The
|
||||
gateway's planned-stop watcher thread (gateway/run.py) polls for
|
||||
the marker and drives the same shutdown path the SIGTERM handler
|
||||
would have on POSIX.
|
||||
|
||||
Returns True if the PID exited within the timeout, False if it
|
||||
didn't (caller should escalate to schtasks /End + taskkill).
|
||||
"""
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
from gateway.status import write_planned_stop_marker, _pid_exists
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
try:
|
||||
write_planned_stop_marker(pid)
|
||||
except Exception:
|
||||
# Best-effort: if the marker can't be written, we have no choice
|
||||
# but to fall through to a hard kill. Caller decides escalation.
|
||||
pass
|
||||
|
||||
deadline = time.monotonic() + max(drain_timeout, 1.0)
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_exists(pid):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
"""Stop the gateway.
|
||||
|
||||
Writes the planned-stop marker first so the gateway can drain
|
||||
in-flight agents and persist ``resume_pending`` before exit (the
|
||||
gateway's marker-watcher thread picks this up — Windows asyncio
|
||||
can't deliver SIGTERM to the loop, so the marker is our only IPC).
|
||||
Then escalates: ``schtasks /End`` (kills the scheduled-task tree)
|
||||
+ ``kill_gateway_processes(force=True)`` for any strays.
|
||||
"""
|
||||
_assert_windows()
|
||||
from hermes_cli.gateway import kill_gateway_processes, _get_restart_drain_timeout
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
# Phase 1: ask the running gateway (if any) to drain itself by writing
|
||||
# the planned-stop marker, then wait briefly for it to exit cleanly.
|
||||
# On clean exit, sessions land with resume_pending=True and the next
|
||||
# boot will auto-resume them.
|
||||
pid = get_running_pid()
|
||||
drained = False
|
||||
if pid is not None:
|
||||
try:
|
||||
drain_timeout = float(_get_restart_drain_timeout() or 30.0)
|
||||
except Exception:
|
||||
drain_timeout = 30.0
|
||||
drained = _drain_gateway_pid(pid, drain_timeout)
|
||||
|
||||
stopped_any = drained
|
||||
if is_task_registered():
|
||||
code, _out, err = _exec_schtasks(["/End", "/TN", get_task_name()])
|
||||
# schtasks returns nonzero when the task isn't currently running — don't treat that as an error.
|
||||
@@ -1028,12 +1086,19 @@ def stop() -> None:
|
||||
elif "not running" not in (err or "").lower():
|
||||
print(f"⚠ schtasks /End returned code {code}: {err.strip()}")
|
||||
|
||||
killed = kill_gateway_processes(all_profiles=False)
|
||||
# Phase 3: hard-kill any strays. When drain succeeded this is a no-op;
|
||||
# when drain timed out this is the escalation that ensures the PID
|
||||
# actually exits. Use force=True on Windows so taskkill /T /F walks
|
||||
# the descendant tree (browser helpers, etc.).
|
||||
killed = kill_gateway_processes(all_profiles=False, force=not drained)
|
||||
if killed:
|
||||
stopped_any = True
|
||||
print(f"✓ Killed {killed} gateway process(es)")
|
||||
if stopped_any:
|
||||
print("✓ Gateway stopped")
|
||||
if drained:
|
||||
print("✓ Gateway stopped (drained cleanly)")
|
||||
else:
|
||||
print("✓ Gateway stopped")
|
||||
else:
|
||||
print("✗ No gateway was running")
|
||||
|
||||
|
||||
+35
-35
@@ -1021,7 +1021,7 @@ def _board_task_counts(slug: str) -> dict[str, int]:
|
||||
path = kb.kanban_db_path(board=slug)
|
||||
if not path.exists():
|
||||
return {}
|
||||
with kb.connect(board=slug) as conn:
|
||||
with kb.connect_closing(board=slug) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
|
||||
).fetchall()
|
||||
@@ -1264,7 +1264,7 @@ def _cmd_init(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.heartbeat_worker(
|
||||
conn,
|
||||
args.task_id,
|
||||
@@ -1279,7 +1279,7 @@ def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_assignees(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
data = kb.known_assignees(conn)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
@@ -1320,7 +1320,7 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task_id = kb.create_task(
|
||||
conn,
|
||||
title=args.title,
|
||||
@@ -1369,7 +1369,7 @@ def _cmd_swarm(args: argparse.Namespace) -> int:
|
||||
if not workers:
|
||||
print("kanban swarm: at least one --worker is required", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
created = ks.create_swarm(
|
||||
conn,
|
||||
goal=args.goal,
|
||||
@@ -1395,7 +1395,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
|
||||
assignee = args.assignee
|
||||
if args.mine and not assignee:
|
||||
assignee = _profile_author()
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
# Cheap "mini-dispatch": recompute ready so list output reflects
|
||||
# dependencies that may have cleared since the last dispatcher tick.
|
||||
kb.recompute_ready(conn)
|
||||
@@ -1444,7 +1444,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, args.task_id)
|
||||
if not task:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
@@ -1610,7 +1610,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
|
||||
def _cmd_assign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.assign_task(conn, args.task_id, profile)
|
||||
if not ok:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
@@ -1620,7 +1620,7 @@ def _cmd_assign(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.reclaim_task(
|
||||
conn, args.task_id,
|
||||
reason=getattr(args, "reason", None),
|
||||
@@ -1637,7 +1637,7 @@ def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
|
||||
def _cmd_reassign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.reassign_task(
|
||||
conn, args.task_id, profile,
|
||||
reclaim_first=bool(getattr(args, "reclaim", False)),
|
||||
@@ -1667,7 +1667,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
||||
|
||||
diag_config = kd.config_from_runtime_config(load_config())
|
||||
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
# Either one-task mode or fleet mode.
|
||||
if getattr(args, "task", None):
|
||||
task = kb.get_task(conn, args.task)
|
||||
@@ -1790,14 +1790,14 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_link(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
kb.link_tasks(conn, args.parent_id, args.child_id)
|
||||
print(f"Linked {args.parent_id} -> {args.child_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_unlink(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.unlink_tasks(conn, args.parent_id, args.child_id)
|
||||
if not ok:
|
||||
print(f"No such link: {args.parent_id} -> {args.child_id}", file=sys.stderr)
|
||||
@@ -1807,7 +1807,7 @@ def _cmd_unlink(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_claim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl)
|
||||
if task is None:
|
||||
# Report why
|
||||
@@ -1838,7 +1838,7 @@ def _cmd_comment(args: argparse.Namespace) -> int:
|
||||
suffix = f"\n\n[trimmed to {args.max_len} chars by --max-len]"
|
||||
body = body[: max(0, args.max_len - len(suffix))].rstrip() + suffix
|
||||
author = args.author or _profile_author()
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
kb.add_comment(conn, args.task_id, author, body)
|
||||
print(f"Comment added to {args.task_id}")
|
||||
return 0
|
||||
@@ -1885,7 +1885,7 @@ def _cmd_complete(args: argparse.Namespace) -> int:
|
||||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if not kb.complete_task(
|
||||
conn, tid,
|
||||
@@ -1912,7 +1912,7 @@ def _cmd_edit(args: argparse.Namespace) -> int:
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if not kb.edit_completed_task_result(
|
||||
conn,
|
||||
args.task_id,
|
||||
@@ -1934,7 +1934,7 @@ def _cmd_block(args: argparse.Namespace) -> int:
|
||||
author = _profile_author()
|
||||
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"BLOCKED: {reason}")
|
||||
@@ -1956,7 +1956,7 @@ def _cmd_schedule(args: argparse.Namespace) -> int:
|
||||
author = _profile_author()
|
||||
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"SCHEDULED: {reason}")
|
||||
@@ -1979,7 +1979,7 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
|
||||
print("at least one task_id is required", file=sys.stderr)
|
||||
return 1
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if not kb.unblock_task(conn, tid):
|
||||
failed.append(tid)
|
||||
@@ -2003,7 +2003,7 @@ def _cmd_promote(args: argparse.Namespace) -> int:
|
||||
seen.add(tid)
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
ok, err = kb.promote_task(
|
||||
conn,
|
||||
@@ -2050,7 +2050,7 @@ def _cmd_archive(args: argparse.Namespace) -> int:
|
||||
print("at least one task_id is required", file=sys.stderr)
|
||||
return 1
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if purge_ids:
|
||||
for tid in purge_ids:
|
||||
if not kb.delete_archived_task(conn, tid):
|
||||
@@ -2073,7 +2073,7 @@ def _cmd_tail(args: argparse.Namespace) -> int:
|
||||
print(f"Tailing events for {args.task_id}. Ctrl-C to stop.")
|
||||
try:
|
||||
while True:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
events = kb.list_events(conn, args.task_id)
|
||||
for e in events:
|
||||
if e.id > last_id:
|
||||
@@ -2087,7 +2087,7 @@ def _cmd_tail(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
res = kb.dispatch_once(
|
||||
conn,
|
||||
dry_run=args.dry_run,
|
||||
@@ -2257,7 +2257,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
||||
from the dispatcher's perspective, not stuck.
|
||||
"""
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
return kb.has_spawnable_ready(conn)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -2288,7 +2288,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
||||
cursor = 0
|
||||
print("Watching kanban events. Ctrl-C to stop.", flush=True)
|
||||
# Seed cursor at the latest id so we don't replay history.
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
|
||||
).fetchone()
|
||||
@@ -2296,7 +2296,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
||||
|
||||
try:
|
||||
while True:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, "
|
||||
" t.assignee, t.tenant "
|
||||
@@ -2329,7 +2329,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_stats(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
stats = kb.board_stats(conn)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
||||
@@ -2349,7 +2349,7 @@ def _cmd_stats(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if kb.get_task(conn, args.task_id) is None:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -2366,7 +2366,7 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_notify_list(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
subs = kb.list_notify_subs(conn, args.task_id)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(subs, indent=2, ensure_ascii=False))
|
||||
@@ -2383,7 +2383,7 @@ def _cmd_notify_list(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_notify_unsubscribe(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.remove_notify_sub(
|
||||
conn, task_id=args.task_id,
|
||||
platform=args.platform, chat_id=args.chat_id,
|
||||
@@ -2417,7 +2417,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
runs = kb.list_runs(conn, args.task_id, **rsk)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps([
|
||||
@@ -2456,7 +2456,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_context(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
text = kb.build_worker_context(conn, args.task_id)
|
||||
print(text)
|
||||
return 0
|
||||
@@ -2622,7 +2622,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
|
||||
import shutil
|
||||
scratch_root = kb.workspaces_root()
|
||||
removed_ws = 0
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'"
|
||||
).fetchall()
|
||||
@@ -2645,7 +2645,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
|
||||
|
||||
event_days = getattr(args, "event_retention_days", 30)
|
||||
log_days = getattr(args, "log_retention_days", 30)
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
removed_events = kb.gc_events(
|
||||
conn, older_than_seconds=event_days * 24 * 3600,
|
||||
)
|
||||
|
||||
+310
-93
@@ -71,6 +71,7 @@ new locking.
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -134,6 +135,34 @@ def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int:
|
||||
return DEFAULT_CLAIM_TTL_SECONDS
|
||||
|
||||
|
||||
# Grace period after a task transitions to ``running`` during which
|
||||
# ``detect_crashed_workers`` skips the ``_pid_alive`` check. Covers the
|
||||
# fork() → /proc-visibility window where liveness can transiently report
|
||||
# False for a freshly-spawned worker. The 15-minute claim TTL still
|
||||
# catches genuinely-crashed workers; this only suppresses false positives
|
||||
# during the launch window.
|
||||
DEFAULT_CRASH_GRACE_SECONDS = 30
|
||||
|
||||
|
||||
def _resolve_crash_grace_seconds() -> int:
|
||||
"""Return the crash-detection grace period in seconds.
|
||||
|
||||
Reads ``HERMES_KANBAN_CRASH_GRACE_SECONDS`` from the environment;
|
||||
falls back to ``DEFAULT_CRASH_GRACE_SECONDS`` when absent, empty,
|
||||
non-integer, or negative. A value of 0 restores immediate-reclaim
|
||||
behaviour (useful for tests).
|
||||
"""
|
||||
raw = os.environ.get("HERMES_KANBAN_CRASH_GRACE_SECONDS", "").strip()
|
||||
if raw:
|
||||
try:
|
||||
parsed = int(raw)
|
||||
except ValueError:
|
||||
parsed = -1
|
||||
if parsed >= 0:
|
||||
return parsed
|
||||
return DEFAULT_CRASH_GRACE_SECONDS
|
||||
|
||||
|
||||
# Worker-context caps so build_worker_context() stays bounded on
|
||||
# pathological boards (retry-heavy tasks, comment storms, giant
|
||||
# summaries). Values chosen to fit a typical 100k-char LLM prompt with
|
||||
@@ -954,6 +983,89 @@ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_
|
||||
_INITIALIZED_PATHS: set[str] = set()
|
||||
_INIT_LOCK = threading.RLock()
|
||||
_SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
DEFAULT_BUSY_TIMEOUT_MS = 120_000
|
||||
|
||||
|
||||
def _resolve_busy_timeout_ms() -> int:
|
||||
"""Return the SQLite busy timeout for Kanban connections.
|
||||
|
||||
Kanban is the shared cross-profile dispatch bus, so worker stampedes are
|
||||
expected. A long busy timeout lets SQLite serialize writers via WAL rather
|
||||
than surfacing transient ``database is locked`` failures during bursts.
|
||||
"""
|
||||
raw = os.environ.get("HERMES_KANBAN_BUSY_TIMEOUT_MS", "").strip()
|
||||
if raw:
|
||||
try:
|
||||
parsed = int(raw)
|
||||
except ValueError:
|
||||
parsed = 0
|
||||
if parsed > 0:
|
||||
return parsed
|
||||
return DEFAULT_BUSY_TIMEOUT_MS
|
||||
|
||||
|
||||
def _sqlite_connect(path: Path) -> sqlite3.Connection:
|
||||
"""Open a Kanban SQLite connection with consistent lock waiting."""
|
||||
busy_timeout_ms = _resolve_busy_timeout_ms()
|
||||
conn = sqlite3.connect(
|
||||
str(path),
|
||||
isolation_level=None,
|
||||
timeout=busy_timeout_ms / 1000.0,
|
||||
)
|
||||
# ``sqlite3.connect(timeout=...)`` normally maps to busy_timeout, but set
|
||||
# the PRAGMA explicitly so it is observable and survives future wrapper
|
||||
# changes. Parameter binding is not supported for PRAGMA assignments.
|
||||
conn.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
|
||||
return conn
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _cross_process_init_lock(path: Path):
|
||||
"""Serialize first-connect WAL/schema/integrity setup across processes.
|
||||
|
||||
``_INIT_LOCK`` only protects threads inside one Python process. During a
|
||||
dispatcher burst, many worker processes can all hit a fresh/legacy board at
|
||||
once and each process has an empty ``_INITIALIZED_PATHS`` cache. This file
|
||||
lock keeps header validation, integrity probing, WAL activation, and
|
||||
additive migrations single-file/single-writer across the whole host while
|
||||
leaving normal post-init DB usage concurrent under SQLite WAL.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = path.with_name(path.name + ".init.lock")
|
||||
handle = lock_path.open("a+b")
|
||||
try:
|
||||
if _IS_WINDOWS:
|
||||
import msvcrt
|
||||
|
||||
# Lock a single byte in the sidecar file. ``msvcrt.locking`` starts
|
||||
# at the current file position, so seek explicitly before both
|
||||
# lock and unlock. The file is opened in append/read binary mode so
|
||||
# it always exists but the byte-range lock is the synchronization
|
||||
# primitive; no payload needs to be written.
|
||||
handle.seek(0)
|
||||
locking = getattr(msvcrt, "locking")
|
||||
lock_mode = getattr(msvcrt, "LK_LOCK")
|
||||
locking(handle.fileno(), lock_mode, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
if _IS_WINDOWS:
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0)
|
||||
locking = getattr(msvcrt, "locking")
|
||||
unlock_mode = getattr(msvcrt, "LK_UNLCK")
|
||||
locking(handle.fileno(), unlock_mode, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
def _looks_like_tls_record_at(data: bytes, offset: int) -> bool:
|
||||
@@ -1027,14 +1139,21 @@ class KanbanDbCorruptError(RuntimeError):
|
||||
|
||||
|
||||
def _backup_corrupt_db(path: Path) -> Optional[Path]:
|
||||
"""Copy a corrupt DB (and its WAL/SHM sidecars) to a timestamped backup.
|
||||
"""Copy a corrupt DB (and its WAL/SHM sidecars) to a content-addressed backup.
|
||||
|
||||
The backup filename is deterministic in the main DB's sha256, so repeated
|
||||
quarantines of the same corrupt bytes (gateway restarts, dispatcher retries,
|
||||
multi-profile fleets all hitting the same shared DB) reuse one backup
|
||||
instead of amplifying disk usage by N. If the corrupt bytes actually
|
||||
change between attempts — e.g. a partial repair or further damage — the
|
||||
fingerprint changes and a separate backup is preserved.
|
||||
|
||||
Returns the backup path of the main DB file, or ``None`` if the copy
|
||||
itself failed (the caller still raises loudly in that case).
|
||||
|
||||
Writes are confined to the original DB's parent directory. The
|
||||
backup basename is derived purely from ``path.name``, never from
|
||||
caller-supplied directory segments — no traversal is possible.
|
||||
Writes are confined to the original DB's parent directory. The backup
|
||||
basename is derived purely from ``path.name`` and a content hash, never
|
||||
from caller-supplied directory segments — no traversal is possible.
|
||||
"""
|
||||
# Resolve once and pin the parent so subsequent path operations cannot
|
||||
# escape it. ``Path.resolve()`` collapses any ``..`` segments and
|
||||
@@ -1042,32 +1161,31 @@ def _backup_corrupt_db(path: Path) -> Optional[Path]:
|
||||
resolved = path.resolve()
|
||||
parent = resolved.parent
|
||||
base_name = resolved.name # basename only
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
candidate = parent / f"{base_name}.corrupt.{stamp}.bak"
|
||||
# Defensive: candidate must still be inside parent after construction.
|
||||
# f-string interpolation of ``base_name`` cannot escape ``parent``
|
||||
# because ``base_name`` is itself a resolved basename, but assert it
|
||||
# anyway so static analyzers can see the containment guarantee.
|
||||
if candidate.parent != parent:
|
||||
return None
|
||||
counter = 0
|
||||
while candidate.exists():
|
||||
counter += 1
|
||||
candidate = parent / f"{base_name}.corrupt.{stamp}.{counter}.bak"
|
||||
if candidate.parent != parent:
|
||||
return None
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
shutil.copy2(resolved, candidate)
|
||||
with resolved.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError:
|
||||
return None
|
||||
token = digest.hexdigest()[:16]
|
||||
candidate = parent / f"{base_name}.corrupt.{token}.bak"
|
||||
# Defensive: candidate must still be inside parent after construction.
|
||||
if candidate.parent != parent:
|
||||
return None
|
||||
if not candidate.exists():
|
||||
try:
|
||||
shutil.copy2(resolved, candidate)
|
||||
except OSError:
|
||||
return None
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = parent / (base_name + suffix)
|
||||
if sidecar.parent != parent or not sidecar.exists():
|
||||
continue
|
||||
sidecar_backup = parent / (candidate.name + suffix)
|
||||
if sidecar_backup.parent != parent or sidecar_backup.exists():
|
||||
continue
|
||||
try:
|
||||
sidecar_backup = parent / (candidate.name + suffix)
|
||||
if sidecar_backup.parent != parent:
|
||||
continue
|
||||
shutil.copy2(sidecar, sidecar_backup)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1114,7 +1232,7 @@ def _guard_existing_db_is_healthy(path: Path) -> None:
|
||||
return
|
||||
reason: Optional[str] = None
|
||||
try:
|
||||
probe = sqlite3.connect(str(resolved), timeout=5, isolation_level=None)
|
||||
probe = _sqlite_connect(resolved)
|
||||
try:
|
||||
row = probe.execute("PRAGMA integrity_check").fetchone()
|
||||
finally:
|
||||
@@ -1160,45 +1278,90 @@ def connect(
|
||||
else:
|
||||
path = kanban_db_path(board=board)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Cheap byte-level check first — catches the #29507 TLS-overwrite shape
|
||||
# and other invalid-header cases without opening a sqlite connection.
|
||||
_validate_sqlite_header(path)
|
||||
# Full integrity probe — catches corruption past the header (malformed
|
||||
# pages, broken internal metadata). Cached per-path after first success
|
||||
# via _INITIALIZED_PATHS so it only runs once per process per path.
|
||||
_guard_existing_db_is_healthy(path)
|
||||
resolved = str(path.resolve())
|
||||
conn = sqlite3.connect(str(path), isolation_level=None, timeout=30)
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
with _INIT_LOCK:
|
||||
# WAL activation can take an exclusive lock while SQLite creates the
|
||||
# sidecar files for a fresh database. Keep it in the same process-local
|
||||
# critical section as schema initialization so concurrent gateway
|
||||
# startup threads do not race before _INITIALIZED_PATHS is populated.
|
||||
# WAL doesn't work on network filesystems (NFS/SMB/FUSE). Shared helper
|
||||
# falls back to DELETE with one WARNING so kanban stays usable there.
|
||||
# See hermes_state._WAL_INCOMPAT_MARKERS for detection logic.
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
needs_init = resolved not in _INITIALIZED_PATHS
|
||||
if needs_init:
|
||||
# Idempotent: runs CREATE TABLE IF NOT EXISTS + the additive
|
||||
# migrations. Cached so subsequent connect() calls in the same
|
||||
# process are cheap. The lock prevents same-process dispatcher
|
||||
# threads from racing through the additive ALTER TABLE pass with
|
||||
# stale PRAGMA snapshots during gateway startup.
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
_migrate_add_optional_columns(conn)
|
||||
_INITIALIZED_PATHS.add(resolved)
|
||||
except Exception:
|
||||
conn.close()
|
||||
raise
|
||||
with _cross_process_init_lock(path):
|
||||
# Cheap byte-level check first — catches the #29507 TLS-overwrite shape
|
||||
# and other invalid-header cases without opening a sqlite connection.
|
||||
_validate_sqlite_header(path)
|
||||
# Full integrity probe — catches corruption past the header (malformed
|
||||
# pages, broken internal metadata). Cached per-path after first success
|
||||
# via _INITIALIZED_PATHS so it only runs once per process per path.
|
||||
_guard_existing_db_is_healthy(path)
|
||||
resolved = str(path.resolve())
|
||||
conn = _sqlite_connect(path)
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
with _INIT_LOCK:
|
||||
# WAL activation can take an exclusive lock while SQLite creates the
|
||||
# sidecar files for a fresh database. Keep it in the same process-local
|
||||
# critical section as schema initialization so concurrent gateway
|
||||
# startup threads do not race before _INITIALIZED_PATHS is populated.
|
||||
# WAL doesn't work on network filesystems (NFS/SMB/FUSE). Shared helper
|
||||
# falls back to DELETE with one WARNING so kanban stays usable there.
|
||||
# See hermes_state._WAL_INCOMPAT_MARKERS for detection logic.
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})")
|
||||
# FULL (was NORMAL): fsync before each checkpoint to narrow the
|
||||
# crash window that can leave a b-tree page header torn.
|
||||
conn.execute("PRAGMA synchronous=FULL")
|
||||
conn.execute("PRAGMA wal_autocheckpoint=100")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
# Zero freed pages so a later torn write cannot expose stale
|
||||
# cell content; persisted in the DB header for new DBs.
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
# Surface corrupt cells as read errors instead of silent
|
||||
# wrong-data returns.
|
||||
conn.execute("PRAGMA cell_size_check=ON")
|
||||
needs_init = resolved not in _INITIALIZED_PATHS
|
||||
if needs_init:
|
||||
# Idempotent: runs CREATE TABLE IF NOT EXISTS + the additive
|
||||
# migrations. Cached so subsequent connect() calls in the same
|
||||
# process are cheap. The lock prevents same-process dispatcher
|
||||
# threads from racing through the additive ALTER TABLE pass with
|
||||
# stale PRAGMA snapshots during gateway startup.
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
_migrate_add_optional_columns(conn)
|
||||
_INITIALIZED_PATHS.add(resolved)
|
||||
except Exception:
|
||||
conn.close()
|
||||
raise
|
||||
return conn
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def connect_closing(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
board: Optional[str] = None,
|
||||
):
|
||||
"""Open a kanban DB connection and guarantee it is closed on exit.
|
||||
|
||||
Use this instead of ``with kb.connect() as conn:`` — sqlite3's
|
||||
built-in connection context manager only commits/rollbacks the
|
||||
transaction; it does NOT close the file descriptor. In long-lived
|
||||
processes (gateway, dashboard) that route every kanban operation
|
||||
through ``connect()`` (e.g. ``run_slash`` dispatching ``/kanban …``
|
||||
commands, ``decompose_task_endpoint`` calling
|
||||
``kanban_decompose.decompose_task``), the unclosed connections
|
||||
accumulate as open FDs to ``kanban.db`` and ``kanban.db-wal``. After
|
||||
enough operations the process hits the kernel FD limit and dies
|
||||
with ``[Errno 24] Too many open files``.
|
||||
|
||||
See #33159 for the production incident.
|
||||
|
||||
The ``connect()`` function itself remains unchanged so callers that
|
||||
intentionally manage the connection lifetime (tests, long-lived
|
||||
callers) continue to work.
|
||||
"""
|
||||
conn = connect(db_path=db_path, board=board)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def init_db(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
@@ -1466,6 +1629,45 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
|
||||
"""Read the SQLite header page_count and compare against actual file size.
|
||||
|
||||
Raises sqlite3.DatabaseError if the file is shorter than the header claims
|
||||
(torn-extend corruption).
|
||||
"""
|
||||
try:
|
||||
row = conn.execute("PRAGMA database_list").fetchone()
|
||||
if row is None:
|
||||
return
|
||||
path_str = row[2] # column 2 is the file path; empty for in-memory DBs
|
||||
if not path_str:
|
||||
return # in-memory or unnamed DB; skip
|
||||
path = path_str
|
||||
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
||||
file_size = os.path.getsize(path)
|
||||
with open(path, "rb") as f:
|
||||
f.seek(28)
|
||||
header_bytes = f.read(4)
|
||||
if len(header_bytes) < 4:
|
||||
return # can't read header; skip
|
||||
header_page_count = int.from_bytes(header_bytes, "big")
|
||||
if header_page_count == 0:
|
||||
return # new/empty DB; skip
|
||||
actual_pages = file_size // page_size
|
||||
if actual_pages < header_page_count:
|
||||
raise sqlite3.DatabaseError(
|
||||
f"torn-extend detected: page count mismatch on {path}: "
|
||||
f"header claims {header_page_count} pages, "
|
||||
f"file has {actual_pages} pages "
|
||||
f"(missing {header_page_count - actual_pages} pages, "
|
||||
f"file_size={file_size}, page_size={page_size})"
|
||||
)
|
||||
except sqlite3.DatabaseError:
|
||||
raise
|
||||
except Exception:
|
||||
pass # I/O errors during check are non-fatal; let normal ops continue
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def write_txn(conn: sqlite3.Connection):
|
||||
"""Context manager for an IMMEDIATE write transaction.
|
||||
@@ -1473,15 +1675,28 @@ def write_txn(conn: sqlite3.Connection):
|
||||
Use for any multi-statement write (creating a task + link, claiming a
|
||||
task + recording an event, etc.). A claim CAS inside this context is
|
||||
atomic -- at most one concurrent writer can succeed.
|
||||
|
||||
The explicit ROLLBACK on exception is wrapped in try/except so that
|
||||
a SQLite auto-rollback (which leaves no active transaction) does not
|
||||
shadow the original exception with a spurious rollback error.
|
||||
"""
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
yield conn
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except sqlite3.OperationalError:
|
||||
# SQLite has already auto-rolled-back the transaction (typical
|
||||
# under EIO, lock contention, or corruption). Nothing to undo;
|
||||
# do not let this secondary failure shadow the real one.
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
conn.execute("COMMIT")
|
||||
# Post-commit file-length check: header page_count must match actual file pages.
|
||||
# A discrepancy means a torn-extend — raise now rather than silently corrupt.
|
||||
_check_file_length_invariant(conn)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4169,6 +4384,29 @@ def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]":
|
||||
return ("unknown", None)
|
||||
|
||||
|
||||
def reap_worker_zombies() -> "list[int]":
|
||||
"""Reap all zombie children of this process without blocking.
|
||||
|
||||
Returns the list of reaped PIDs. Safe to call when there are no
|
||||
children (returns []). No-op on Windows.
|
||||
"""
|
||||
reaped: "list[int]" = []
|
||||
if os.name != "nt":
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
pid, status = os.waitpid(-1, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
break
|
||||
if pid == 0:
|
||||
break
|
||||
_record_worker_exit(pid, status)
|
||||
reaped.append(pid)
|
||||
except Exception:
|
||||
pass
|
||||
return reaped
|
||||
|
||||
|
||||
def _pid_alive(pid: Optional[int]) -> bool:
|
||||
"""Return True if ``pid`` is still running on this host.
|
||||
|
||||
@@ -4635,7 +4873,7 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
|
||||
# (task_id, pid, claimer, protocol_violation, error_text)
|
||||
with write_txn(conn):
|
||||
rows = conn.execute(
|
||||
"SELECT id, worker_pid, claim_lock FROM tasks "
|
||||
"SELECT id, worker_pid, claim_lock, started_at FROM tasks "
|
||||
"WHERE status = 'running' AND worker_pid IS NOT NULL"
|
||||
).fetchall()
|
||||
host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
|
||||
@@ -4644,6 +4882,14 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
|
||||
lock = row["claim_lock"] or ""
|
||||
if not lock.startswith(host_prefix):
|
||||
continue
|
||||
# Skip liveness check inside the launch-window grace period
|
||||
# so a freshly-spawned worker isn't reclaimed before its PID
|
||||
# is visible on /proc.
|
||||
started_at = row["started_at"] if "started_at" in row.keys() else None
|
||||
if started_at is not None:
|
||||
grace = _resolve_crash_grace_seconds()
|
||||
if time.time() - started_at < grace:
|
||||
continue
|
||||
if _pid_alive(row["worker_pid"]):
|
||||
continue
|
||||
|
||||
@@ -5125,38 +5371,9 @@ def dispatch_once(
|
||||
``board`` pins workspace/log/db resolution for this tick to a specific
|
||||
board. When omitted, the current-board resolution chain is used.
|
||||
"""
|
||||
# Reap zombie children from previously spawned workers.
|
||||
# The gateway-embedded dispatcher is the parent of every worker spawned
|
||||
# via _default_spawn (start_new_session=True only detaches the
|
||||
# controlling tty, not the parent). Without an explicit waitpid, each
|
||||
# completed worker becomes a <defunct> entry that lingers until gateway
|
||||
# exit. WNOHANG keeps this non-blocking; ChildProcessError means no
|
||||
# children to reap. Bounded: at most one tick's worth of completions
|
||||
# can be in <defunct> at once.
|
||||
#
|
||||
# We also record the exit status keyed by pid, so
|
||||
# ``detect_crashed_workers`` can distinguish a worker that exited
|
||||
# cleanly without calling ``kanban_complete`` / ``kanban_block``
|
||||
# (protocol violation — auto-block) from a real crash (OOM killer,
|
||||
# SIGKILL, non-zero exit — existing counter behavior).
|
||||
#
|
||||
# Windows has no zombies / no os.WNOHANG — subprocess.Popen handles
|
||||
# are freed when the Python object is garbage-collected or .wait() is
|
||||
# called explicitly. The kanban dispatcher discards the Popen handle
|
||||
# after spawn (``_default_spawn`` → abandon), so on Windows there's
|
||||
# nothing to reap here — skip the whole block.
|
||||
if os.name != "nt":
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_pid, _status = os.waitpid(-1, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
break
|
||||
if _pid == 0:
|
||||
break
|
||||
_record_worker_exit(_pid, _status)
|
||||
except Exception:
|
||||
pass
|
||||
# Reap zombie children from previously spawned workers. See
|
||||
# reap_worker_zombies() for the full rationale.
|
||||
reap_worker_zombies()
|
||||
|
||||
result = DispatchResult()
|
||||
result.reclaimed = release_stale_claims(conn)
|
||||
|
||||
@@ -281,7 +281,7 @@ def decompose_task(
|
||||
configured, API error, malformed response, decomposer returned
|
||||
fanout=true with empty task list) — those surface via ``ok=False``.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return DecomposeOutcome(task_id, False, "unknown task id")
|
||||
@@ -370,7 +370,7 @@ def decompose_task(
|
||||
return DecomposeOutcome(
|
||||
task_id, False, "decomposer returned fanout=false with no title/body",
|
||||
)
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
@@ -439,7 +439,7 @@ def decompose_task(
|
||||
})
|
||||
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
@@ -467,7 +467,7 @@ def decompose_task(
|
||||
|
||||
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
||||
"""Return task ids currently in the triage column."""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
|
||||
@@ -150,7 +150,7 @@ def specify_task(
|
||||
error, malformed response) — those surface via ``ok=False`` so the
|
||||
``--all`` sweep can continue past individual failures.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return SpecifyOutcome(task_id, False, "unknown task id")
|
||||
@@ -239,7 +239,7 @@ def specify_task(
|
||||
task_id, False, "LLM response missing title and body"
|
||||
)
|
||||
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
@@ -261,7 +261,7 @@ def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
||||
|
||||
``tenant`` narrows the sweep; ``None`` returns every triage task.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
tasks = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
|
||||
+320
-137
@@ -65,6 +65,39 @@ import os
|
||||
import sys
|
||||
|
||||
|
||||
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
||||
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
|
||||
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
|
||||
# before the Node TUI takes stdin into raw mode). During that window any
|
||||
# incoming bytes are echoed straight back to the user's shell scrollback as
|
||||
# ``^[[<…M`` text. The TUI itself runs `resetTerminalModes()` again in
|
||||
# `entry.tsx`; this is just the earlier cousin. ``HERMES_TUI_NO_EARLY_DISABLE``
|
||||
# escapes the behaviour for diagnostics.
|
||||
def _suppress_mouse_residue_early() -> None:
|
||||
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
|
||||
return
|
||||
if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]):
|
||||
return
|
||||
try:
|
||||
# Skip when stdout is redirected (`hermes --tui … >log`, CI capture):
|
||||
# the bytes can't reach the terminal anyway and would just pollute
|
||||
# the log with raw CSI.
|
||||
if not os.isatty(1):
|
||||
return
|
||||
# Disable every mouse-tracking variant we know about. Idempotent and
|
||||
# safe to send even when no tracking is currently asserted.
|
||||
os.write(
|
||||
1,
|
||||
b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l"
|
||||
b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_suppress_mouse_residue_early()
|
||||
|
||||
|
||||
def _is_termux_startup_environment_fast() -> bool:
|
||||
"""Tiny Termux check for pre-import startup shortcuts."""
|
||||
prefix = os.environ.get("PREFIX", "")
|
||||
@@ -589,10 +622,12 @@ def _has_any_provider_configured() -> bool:
|
||||
# being installed doesn't mean the user wants Hermes to use their tokens.
|
||||
if _has_hermes_config:
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
is_claude_code_token_valid,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
|
||||
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
|
||||
if read_claude_code_credentials is None or is_claude_code_token_valid is None:
|
||||
raise ImportError("anthropic plugin not registered")
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
if creds and (
|
||||
@@ -2084,6 +2119,13 @@ def cmd_postinstall(args):
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
_require_tty("model")
|
||||
if getattr(args, "refresh", False):
|
||||
try:
|
||||
from hermes_cli.models import clear_provider_models_cache
|
||||
clear_provider_models_cache()
|
||||
print(" Cleared model picker cache.")
|
||||
except Exception:
|
||||
pass
|
||||
select_provider_and_model(args=args)
|
||||
|
||||
|
||||
@@ -2964,6 +3006,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
"""Nous Portal provider: ensure logged in, then pick model."""
|
||||
from hermes_cli.auth import (
|
||||
get_provider_auth_state,
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
_update_config_for_provider,
|
||||
@@ -3059,8 +3102,21 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
# Fetch live pricing (non-blocking — returns empty dict on failure)
|
||||
pricing = get_pricing_for_provider("nous")
|
||||
|
||||
# Check if user is on free tier
|
||||
free_tier = check_nous_free_tier()
|
||||
# Force fresh account data for model selection so recent credit purchases
|
||||
# are reflected immediately.
|
||||
free_tier = check_nous_free_tier(force_fresh=True)
|
||||
if not free_tier:
|
||||
try:
|
||||
refreshed_creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=5 * 60,
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
)
|
||||
if refreshed_creds:
|
||||
creds = refreshed_creds
|
||||
except Exception:
|
||||
# Runtime inference has its own paid-entitlement recovery path; do
|
||||
# not block model selection if this opportunistic remint fails.
|
||||
pass
|
||||
|
||||
# Resolve portal URL early — needed both for upgrade links and for the
|
||||
# freeRecommendedModels endpoint below.
|
||||
@@ -3082,7 +3138,24 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
# newly-launched paid models surface in the picker too — independent
|
||||
# of CLI release cadence.
|
||||
unavailable_models: list[str] = []
|
||||
unavailable_message = ""
|
||||
if free_tier:
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
|
||||
_account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
unavailable_message = (
|
||||
format_nous_portal_entitlement_message(
|
||||
_account_info,
|
||||
capability="paid Nous models",
|
||||
)
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
unavailable_message = ""
|
||||
model_ids, pricing = union_with_portal_free_recommendations(
|
||||
model_ids, pricing, _nous_portal_url,
|
||||
)
|
||||
@@ -3104,7 +3177,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL
|
||||
|
||||
_url = (_nous_portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
print(f"Upgrade at {_url} to access paid models.")
|
||||
print(unavailable_message or f"Upgrade at {_url} to access paid models.")
|
||||
return
|
||||
|
||||
print(
|
||||
@@ -3117,6 +3190,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
pricing=pricing,
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_nous_portal_url,
|
||||
unavailable_message=unavailable_message,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
@@ -4032,13 +4106,15 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
|
||||
if use_entra:
|
||||
try:
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
build_token_provider,
|
||||
describe_active_credential,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_azure = registries.get_provider_namespace("azure")
|
||||
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
|
||||
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
|
||||
build_token_provider = _azure.get("build_token_provider")
|
||||
describe_active_credential = _azure.get("describe_active_credential")
|
||||
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
|
||||
if any(v is None for v in [EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider, describe_active_credential, has_azure_identity_installed]):
|
||||
raise ImportError("azure plugin not registered")
|
||||
except ImportError as exc:
|
||||
print()
|
||||
print(f"⚠ Could not import azure-identity adapter: {exc}")
|
||||
@@ -5352,12 +5428,14 @@ def _model_flow_bedrock(config, current_model=""):
|
||||
|
||||
# 1. Check for AWS credentials
|
||||
try:
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
discover_bedrock_models,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock = registries.get_provider_namespace("bedrock")
|
||||
has_aws_credentials = _bedrock.get("has_aws_credentials")
|
||||
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
|
||||
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
|
||||
discover_bedrock_models = _bedrock.get("discover_bedrock_models")
|
||||
if any(v is None for v in [has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, discover_bedrock_models]):
|
||||
raise ImportError("bedrock plugin not registered")
|
||||
except ImportError:
|
||||
print(" ✗ boto3 is not installed. Install it with:")
|
||||
print(" pip install boto3")
|
||||
@@ -5805,11 +5883,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
|
||||
def _run_anthropic_oauth_flow(save_env_value):
|
||||
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
|
||||
from agent.anthropic_adapter import (
|
||||
run_oauth_setup_token,
|
||||
read_claude_code_credentials,
|
||||
is_claude_code_token_valid,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
run_oauth_setup_token = _anthropic.get("run_oauth_setup_token")
|
||||
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
|
||||
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
|
||||
if run_oauth_setup_token is None:
|
||||
raise ImportError("anthropic plugin not registered")
|
||||
from hermes_cli.config import (
|
||||
save_anthropic_oauth_token,
|
||||
use_anthropic_claude_code_credentials,
|
||||
@@ -5917,11 +5997,13 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
existing_key = get_anthropic_key()
|
||||
cc_available = False
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
is_claude_code_token_valid,
|
||||
_is_oauth_token,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
|
||||
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
|
||||
_is_oauth_token = _anthropic.get("_is_oauth_token")
|
||||
if any(v is None for v in [read_claude_code_credentials, is_claude_code_token_valid, _is_oauth_token]):
|
||||
raise ImportError("anthropic plugin not registered")
|
||||
|
||||
cc_creds = read_claude_code_credentials()
|
||||
if cc_creds and is_claude_code_token_valid(cc_creds):
|
||||
@@ -6444,6 +6526,104 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _run_with_idle_timeout(
|
||||
cmd: list[str],
|
||||
cwd: Path,
|
||||
*,
|
||||
idle_timeout_seconds: int = 180,
|
||||
indent: str = " ",
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a subprocess that streams output, with an idle-output timeout.
|
||||
|
||||
Issue #33788: ``npm run build`` (Vite) was invoked with
|
||||
``capture_output=True`` and no timeout. On low-memory hosts (notably
|
||||
WSL2 with the default 4 GB cap) the build can stall or sit silent for
|
||||
minutes; users see a frozen terminal, assume the update is hung, and
|
||||
reboot — leaving the editable install in a half-state with the
|
||||
``hermes`` launcher present but ``hermes_cli`` not importable.
|
||||
|
||||
This helper fixes both halves: stdout is streamed (so the user sees
|
||||
progress), and if no bytes have appeared on stdout/stderr for
|
||||
``idle_timeout_seconds``, the process is terminated and the call
|
||||
returns with a non-zero ``returncode``. The caller's existing
|
||||
stale-dist fallback (#23817) takes over from there.
|
||||
|
||||
Returns a ``CompletedProcess`` with merged stdout (text), empty
|
||||
stderr, and an integer returncode. Never raises on idle timeout —
|
||||
propagation of failure is via the returncode.
|
||||
"""
|
||||
merged_chunks: list[str] = []
|
||||
last_output_ts = _time.monotonic()
|
||||
lock = threading.Lock()
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
except OSError as exc:
|
||||
# E.g. npm not on PATH between the which() check and now.
|
||||
return subprocess.CompletedProcess(cmd, 127, stdout="", stderr=str(exc))
|
||||
|
||||
def _reader() -> None:
|
||||
nonlocal last_output_ts
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
try:
|
||||
print(f"{indent}{line.rstrip()}", flush=True)
|
||||
except UnicodeEncodeError:
|
||||
# Windows cp1252 fallback — same pattern as _say().
|
||||
enc = getattr(sys.stdout, "encoding", None) or "ascii"
|
||||
safe = line.rstrip().encode(enc, errors="replace").decode(enc, errors="replace")
|
||||
print(f"{indent}{safe}", flush=True)
|
||||
with lock:
|
||||
merged_chunks.append(line)
|
||||
last_output_ts = _time.monotonic()
|
||||
|
||||
reader_thread = threading.Thread(target=_reader, daemon=True)
|
||||
reader_thread.start()
|
||||
|
||||
idle_killed = False
|
||||
while True:
|
||||
try:
|
||||
rc = proc.wait(timeout=5)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
with lock:
|
||||
idle = _time.monotonic() - last_output_ts
|
||||
if idle > idle_timeout_seconds:
|
||||
idle_killed = True
|
||||
proc.terminate()
|
||||
try:
|
||||
rc = proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
rc = proc.wait()
|
||||
break
|
||||
|
||||
# Drain reader so we don't leak the stdout file descriptor.
|
||||
reader_thread.join(timeout=2)
|
||||
|
||||
combined = "".join(merged_chunks)
|
||||
if idle_killed:
|
||||
msg = (
|
||||
f"\n ⚠ Build produced no output for {idle_timeout_seconds}s — terminated.\n"
|
||||
" Common causes: out-of-memory on a low-RAM host (WSL/container),\n"
|
||||
" a stuck Node process, or an antivirus scan stalling I/O.\n"
|
||||
)
|
||||
combined += msg
|
||||
# Force a non-zero rc even if terminate() raced with a clean exit.
|
||||
if rc == 0:
|
||||
rc = 124 # GNU `timeout` convention
|
||||
return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr="")
|
||||
|
||||
|
||||
def _run_npm_install_deterministic(
|
||||
npm: str,
|
||||
cwd: Path,
|
||||
@@ -6549,31 +6729,26 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if fatal:
|
||||
_say(" Run manually: cd web && npm install && npm run build")
|
||||
return False
|
||||
# First attempt
|
||||
r2 = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=web_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
# First attempt — stream output via idle-timeout helper (issue #33788).
|
||||
# capture_output=True on a long Vite build looks identical to a hang;
|
||||
# users react by rebooting, which leaves the editable install in a
|
||||
# half-state. Streaming + idle-kill makes failures observable AND
|
||||
# recoverable (the stale-dist fallback below handles the kill path).
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
|
||||
if r2.returncode != 0:
|
||||
# Retry once after a short delay — covers boot-time races on Windows
|
||||
# (antivirus scanning Node.js binaries, npm cache not ready, transient
|
||||
# I/O when launched via Scheduled Task at logon). See issue #23817.
|
||||
_time.sleep(3)
|
||||
r2 = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=web_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
|
||||
|
||||
if r2.returncode != 0:
|
||||
stderr_preview = (r2.stderr or "").strip()
|
||||
# _run_with_idle_timeout merges stderr into stdout; older callers
|
||||
# using subprocess.run kept them split. Pull from whichever has
|
||||
# content so the error surfaces regardless of which path produced
|
||||
# the CompletedProcess.
|
||||
build_output = (r2.stderr or "") + (r2.stdout or "")
|
||||
stderr_preview = build_output.strip()
|
||||
stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else ""
|
||||
dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
|
||||
dist_index = dist_dir / "index.html"
|
||||
@@ -7064,6 +7239,11 @@ def _update_via_zip(args):
|
||||
_install_python_dependencies_with_optional_fallback(pip_cmd)
|
||||
|
||||
_update_node_dependencies()
|
||||
# Core (Python deps + git pull / ZIP extract) is now complete; the CLI
|
||||
# is functional from this point onward. The web UI build below is
|
||||
# optional — a failure here only affects ``hermes dashboard``. Make
|
||||
# that visible so users don't panic and reboot mid-build (#33788).
|
||||
print("→ Core update complete. Building dashboard (optional)...")
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
# Sync skills
|
||||
@@ -7940,71 +8120,13 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
|
||||
|
||||
|
||||
def _refresh_active_lazy_features() -> None:
|
||||
"""Refresh lazy-installed backends after a code update.
|
||||
"""No-op — lazy deps removed.
|
||||
|
||||
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
|
||||
optional backends moved to ``tools/lazy_deps.py`` and only install on
|
||||
first use. ``hermes update`` runs ``uv pip install -e .[all]`` which
|
||||
leaves those packages untouched — so if we bump a pin in
|
||||
:data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already
|
||||
activated the backend keep the stale version forever.
|
||||
|
||||
This function asks lazy_deps which features the user has previously
|
||||
activated and reinstalls them under the current pins. Features the
|
||||
user never enabled stay quiet — no churn for cold backends.
|
||||
|
||||
Never raises. A failure here must not block the rest of the update.
|
||||
Optional backends are now proper plugin packages (hermes-agent-anthropic,
|
||||
hermes-agent-telegram, etc.) installed via extras. ``hermes update``
|
||||
refreshes them through ``uv pip install -e .[all]`` like any other dep.
|
||||
"""
|
||||
try:
|
||||
from tools import lazy_deps
|
||||
except Exception as exc:
|
||||
logger.debug("Lazy refresh skipped (import failed): %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
active = lazy_deps.active_features()
|
||||
except Exception as exc:
|
||||
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
|
||||
return
|
||||
|
||||
if not active:
|
||||
return
|
||||
|
||||
print()
|
||||
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
|
||||
|
||||
try:
|
||||
results = lazy_deps.refresh_active_features(prompt=False)
|
||||
except Exception as exc:
|
||||
# refresh_active_features is documented as never-raise, but defend
|
||||
# the update flow against future regressions.
|
||||
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
|
||||
return
|
||||
|
||||
refreshed = [f for f, s in results.items() if s == "refreshed"]
|
||||
current = [f for f, s in results.items() if s == "current"]
|
||||
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
|
||||
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
|
||||
|
||||
if refreshed:
|
||||
print(f" ↑ {len(refreshed)} refreshed: {', '.join(refreshed)}")
|
||||
if current:
|
||||
print(f" ✓ {len(current)} already current")
|
||||
if skipped:
|
||||
# Most common reason: security.allow_lazy_installs=false. Show one
|
||||
# line so the user knows why; not an error.
|
||||
names = ", ".join(f for f, _ in skipped)
|
||||
reason = skipped[0][1].split(": ", 1)[-1]
|
||||
print(f" · {len(skipped)} skipped ({reason}): {names}")
|
||||
if failed:
|
||||
for feature, status in failed:
|
||||
reason = status.split(": ", 1)[-1]
|
||||
# Clip noisy pip stderr to keep update output legible.
|
||||
if len(reason) > 200:
|
||||
reason = reason[:200] + "..."
|
||||
print(f" ⚠ {feature} failed to refresh: {reason}")
|
||||
print(" Backends keep their previously-installed version; rerun")
|
||||
print(" `hermes update` once the upstream issue is resolved.")
|
||||
pass
|
||||
|
||||
|
||||
def _install_python_dependencies_with_optional_fallback(
|
||||
@@ -8092,37 +8214,18 @@ def _install_psutil_android_compat(
|
||||
nothing is persisted in the repository.
|
||||
|
||||
Stopgap: remove this once https://github.com/giampaolo/psutil/pull/2762
|
||||
merges and ships in a release. ``scripts/install_psutil_android.py``
|
||||
contains the same logic for ``scripts/install.sh`` (fresh installs).
|
||||
Both copies should be removed together.
|
||||
merges and ships in a release. The standalone installer script uses the
|
||||
same shared helper and should be removed together.
|
||||
"""
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
psutil_url = (
|
||||
"https://files.pythonhosted.org/packages/aa/c6/"
|
||||
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
|
||||
"psutil-7.2.2.tar.gz"
|
||||
)
|
||||
from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
archive = tmp_path / "psutil.tar.gz"
|
||||
urllib.request.urlretrieve(psutil_url, archive)
|
||||
with tarfile.open(archive) as tar:
|
||||
tar.extractall(tmp_path)
|
||||
|
||||
src_root = next(
|
||||
p for p in tmp_path.iterdir() if p.is_dir() and p.name.startswith("psutil-")
|
||||
)
|
||||
common_py = src_root / "psutil" / "_common.py"
|
||||
content = common_py.read_text(encoding="utf-8")
|
||||
marker = 'LINUX = sys.platform.startswith("linux")'
|
||||
replacement = 'LINUX = sys.platform.startswith(("linux", "android"))'
|
||||
if marker not in content:
|
||||
raise RuntimeError("psutil Android compatibility patch marker not found")
|
||||
common_py.write_text(content.replace(marker, replacement), encoding="utf-8")
|
||||
urllib.request.urlretrieve(PSUTIL_URL, archive)
|
||||
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
|
||||
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)],
|
||||
@@ -8383,6 +8486,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
||||
"""
|
||||
from hermes_cli.config import detect_install_method
|
||||
method = detect_install_method(PROJECT_ROOT)
|
||||
if method == "docker":
|
||||
# Docker can't ``git fetch`` from within the container. Surface the
|
||||
# same long-form ``docker pull`` guidance ``hermes update`` (apply
|
||||
# path) uses — telling the user to "reinstall via curl" or that
|
||||
# ".git is missing" would point them at the wrong remediation.
|
||||
from hermes_cli.config import format_docker_update_message
|
||||
print(format_docker_update_message())
|
||||
sys.exit(1)
|
||||
if method == "pip":
|
||||
from hermes_cli.config import recommended_update_command
|
||||
from hermes_cli.banner import check_via_pypi
|
||||
@@ -8683,12 +8794,27 @@ def cmd_update(args):
|
||||
runs the update, then restores stdio on the way out (even on
|
||||
``sys.exit`` or unhandled exceptions).
|
||||
"""
|
||||
from hermes_cli.config import is_managed, managed_error
|
||||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_docker_update_message,
|
||||
is_managed,
|
||||
managed_error,
|
||||
)
|
||||
|
||||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
|
||||
# Docker users can't ``git pull`` — the image excludes ``.git`` from
|
||||
# the build context. Bail with a friendly explanation pointing at
|
||||
# ``docker pull`` BEFORE any of the apply-path / check-path branches
|
||||
# below get a chance to error out with misleading "Not a git
|
||||
# repository" text. See format_docker_update_message() for the full
|
||||
# rationale and tag-pinning / config-persistence notes.
|
||||
if detect_install_method(PROJECT_ROOT) == "docker":
|
||||
print(format_docker_update_message())
|
||||
sys.exit(1)
|
||||
|
||||
if getattr(args, "check", False):
|
||||
# --check honors --branch so the "any new commits?" answer matches
|
||||
# what a subsequent `hermes update --branch=<x>` would actually pull.
|
||||
@@ -8928,6 +9054,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
if commit_count == 0:
|
||||
_invalidate_update_cache()
|
||||
|
||||
# Even if origin is up to date, the fork may be behind upstream
|
||||
if is_fork and branch == "main":
|
||||
_sync_with_upstream_if_needed(git_cmd, PROJECT_ROOT)
|
||||
|
||||
# Restore stash and switch back to original branch if we moved
|
||||
if auto_stash_ref is not None:
|
||||
_restore_stashed_changes(
|
||||
@@ -8959,7 +9090,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
snap_id = create_quick_snapshot(label="pre-update")
|
||||
snap_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if snap_id:
|
||||
print(f" ✓ Pre-update snapshot: {snap_id}")
|
||||
except Exception as exc:
|
||||
@@ -9129,6 +9260,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_refresh_active_lazy_features()
|
||||
|
||||
_update_node_dependencies()
|
||||
# See note above (ZIP path): core is now complete, web UI build is
|
||||
# optional from a CLI perspective. Telegraphing this avoids the
|
||||
# "stuck at webui-build → reboot → broken install" trap (#33788).
|
||||
print("→ Core update complete. Building dashboard (optional)...")
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
print()
|
||||
@@ -11135,6 +11270,11 @@ def main():
|
||||
help="Select default model and provider",
|
||||
description="Interactively select your inference provider and default model",
|
||||
)
|
||||
model_parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Wipe the model picker disk cache and re-fetch every provider's live /v1/models list.",
|
||||
)
|
||||
model_parser.add_argument(
|
||||
"--portal-url",
|
||||
help="Portal base URL for Nous login (default: production portal)",
|
||||
@@ -11321,6 +11461,19 @@ def main():
|
||||
action="store_true",
|
||||
help="Replace any existing gateway instance (useful for systemd)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--no-supervise",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Inside the s6-overlay Docker image, normally `gateway run` is "
|
||||
"automatically redirected to the supervised s6 service (so the "
|
||||
"gateway gets auto-restart on crash, plus a supervised dashboard "
|
||||
"if HERMES_DASHBOARD is set). Pass --no-supervise to opt out and "
|
||||
"get the historical pre-s6 foreground behavior: the gateway is "
|
||||
"the container's main process and the container exits with the "
|
||||
"gateway's exit code. No effect outside an s6 container."
|
||||
),
|
||||
)
|
||||
_add_accept_hooks_flag(gateway_run)
|
||||
_add_accept_hooks_flag(gateway_parser)
|
||||
|
||||
@@ -12467,6 +12620,11 @@ Examples:
|
||||
],
|
||||
)
|
||||
skills_search.add_argument("--limit", type=int, default=10, help="Max results")
|
||||
skills_search.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output JSON instead of a table (full identifiers, scripting-friendly)",
|
||||
)
|
||||
|
||||
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
|
||||
skills_install.add_argument(
|
||||
@@ -12564,6 +12722,31 @@ Examples:
|
||||
help="Skip confirmation prompt when using --restore",
|
||||
)
|
||||
|
||||
skills_repair_official = skills_subparsers.add_parser(
|
||||
"repair-official",
|
||||
help="Backfill or restore official optional skills from repo source",
|
||||
description=(
|
||||
"Repair official optional skill provenance. By default, only backfills "
|
||||
"hub metadata for exact matches. Pass --restore to replace missing or "
|
||||
"mutated active copies from optional-skills/, moving existing copies to "
|
||||
"a restore backup first. Use name 'all' to repair every optional skill."
|
||||
),
|
||||
)
|
||||
skills_repair_official.add_argument(
|
||||
"name", help="Official optional skill folder/frontmatter name, or 'all'"
|
||||
)
|
||||
skills_repair_official.add_argument(
|
||||
"--restore",
|
||||
action="store_true",
|
||||
help="Restore from official optional source, backing up existing matching copies",
|
||||
)
|
||||
skills_repair_official.add_argument(
|
||||
"--yes",
|
||||
"-y",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompt when using --restore",
|
||||
)
|
||||
|
||||
skills_publish = skills_subparsers.add_parser(
|
||||
"publish", help="Publish a skill to a registry"
|
||||
)
|
||||
|
||||
+59
-39
@@ -294,32 +294,39 @@ class CustomAutoResult:
|
||||
# Flag parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
|
||||
"""Parse --provider and --global flags from /model command args.
|
||||
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]:
|
||||
"""Parse --provider, --global, and --refresh flags from /model command args.
|
||||
|
||||
Returns (model_input, explicit_provider, is_global).
|
||||
Returns (model_input, explicit_provider, is_global, force_refresh).
|
||||
|
||||
Examples::
|
||||
|
||||
"sonnet" -> ("sonnet", "", False)
|
||||
"sonnet --global" -> ("sonnet", "", True)
|
||||
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False)
|
||||
"--provider my-ollama" -> ("", "my-ollama", False)
|
||||
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True)
|
||||
"sonnet" -> ("sonnet", "", False, False)
|
||||
"sonnet --global" -> ("sonnet", "", True, False)
|
||||
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False)
|
||||
"--provider my-ollama" -> ("", "my-ollama", False, False)
|
||||
"--refresh" -> ("", "", False, True)
|
||||
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False)
|
||||
"""
|
||||
is_global = False
|
||||
explicit_provider = ""
|
||||
force_refresh = False
|
||||
|
||||
# Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash)
|
||||
# A single Unicode dash before a flag keyword becomes "--"
|
||||
import re as _re
|
||||
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global)', r'--\1', raw_args)
|
||||
raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|refresh)', r'--\1', raw_args)
|
||||
|
||||
# Extract --global
|
||||
if "--global" in raw_args:
|
||||
is_global = True
|
||||
raw_args = raw_args.replace("--global", "").strip()
|
||||
|
||||
# Extract --refresh (bust the model picker disk cache before listing)
|
||||
if "--refresh" in raw_args:
|
||||
force_refresh = True
|
||||
raw_args = raw_args.replace("--refresh", "").strip()
|
||||
|
||||
# Extract --provider <name>
|
||||
parts = raw_args.split()
|
||||
i = 0
|
||||
@@ -333,7 +340,7 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
|
||||
i += 1
|
||||
|
||||
model_input = " ".join(filtered).strip()
|
||||
return (model_input, explicit_provider, is_global)
|
||||
return (model_input, explicit_provider, is_global, force_refresh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1079,6 +1086,7 @@ def list_authenticated_providers(
|
||||
from hermes_cli.models import (
|
||||
OPENROUTER_MODELS, _PROVIDER_MODELS,
|
||||
_MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids,
|
||||
cached_provider_model_ids,
|
||||
get_curated_nous_model_ids,
|
||||
)
|
||||
|
||||
@@ -1151,8 +1159,12 @@ def list_authenticated_providers(
|
||||
if slug_norm != current_norm:
|
||||
return False
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
return bool(has_aws_credentials())
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock_ns = registries.get_provider_namespace("bedrock")
|
||||
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
|
||||
if has_aws_credentials:
|
||||
return bool(has_aws_credentials())
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -1239,13 +1251,15 @@ def list_authenticated_providers(
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
# Use curated list, falling back to models.dev if no curated list.
|
||||
# For preferred providers, merge models.dev entries into the curated
|
||||
# catalog so newly released models (e.g. mimo-v2.5-pro on opencode-go)
|
||||
# show up in the picker without requiring a Hermes release.
|
||||
model_ids = curated.get(hermes_id, [])
|
||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||
# Unified pathway: route through cached_provider_model_ids() so the
|
||||
# /model picker sees the SAME list `hermes model` would build, with
|
||||
# disk caching to keep the picker open snappy. Falls back to the
|
||||
# curated static list when the live fetcher returns nothing.
|
||||
model_ids = cached_provider_model_ids(hermes_id)
|
||||
if not model_ids:
|
||||
model_ids = curated.get(hermes_id, [])
|
||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
@@ -1332,10 +1346,12 @@ def list_authenticated_providers(
|
||||
# configured.
|
||||
if not has_creds and hermes_slug == "anthropic":
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
read_hermes_oauth_credentials,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
read_claude_code_credentials = _anthropic_ns.get("read_claude_code_credentials")
|
||||
read_hermes_oauth_credentials = _anthropic_ns.get("read_hermes_oauth_credentials")
|
||||
if read_claude_code_credentials is None or read_hermes_oauth_credentials is None:
|
||||
raise ImportError("anthropic credential readers not registered")
|
||||
hermes_creds = read_hermes_oauth_credentials()
|
||||
cc_creds = read_claude_code_credentials()
|
||||
if (hermes_creds and hermes_creds.get("accessToken")) or \
|
||||
@@ -1351,25 +1367,27 @@ def list_authenticated_providers(
|
||||
# matches what the user's authenticated Codex/Copilot backend
|
||||
# actually serves — including ChatGPT-Pro-only Codex slugs
|
||||
# (e.g. gpt-5.3-codex-spark) that aren't in the static curated
|
||||
# catalog. ``provider_model_ids()`` falls back to the curated
|
||||
# list when the live endpoint is unreachable, so this is safe
|
||||
# for unauthenticated and offline cases too.
|
||||
model_ids = provider_model_ids(hermes_slug)
|
||||
# catalog. ``cached_provider_model_ids()`` falls back to the
|
||||
# curated list when the live endpoint is unreachable, so this
|
||||
# is safe for unauthenticated and offline cases too.
|
||||
model_ids = cached_provider_model_ids(hermes_slug)
|
||||
# For aws_sdk providers (bedrock), use live discovery so the list
|
||||
# reflects the active region (eu.*, ap.*) not the static us.* list.
|
||||
elif overlay.auth_type == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
_ids = bedrock_model_ids_or_none()
|
||||
model_ids = _ids if _ids is not None else (curated.get(hermes_slug, []) or curated.get(pid, []))
|
||||
_ids = cached_provider_model_ids(hermes_slug)
|
||||
model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, []))
|
||||
except Exception:
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
else:
|
||||
# Use curated list — look up by Hermes slug, fall back to overlay key
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
# Merge with models.dev for preferred providers (same rationale as above).
|
||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
||||
# Unified pathway — see Section 1 rationale. Fall back to the
|
||||
# curated dict (with models.dev merge for preferred providers)
|
||||
# when the live fetcher comes up empty.
|
||||
model_ids = cached_provider_model_ids(hermes_slug)
|
||||
if not model_ids:
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
@@ -1436,13 +1454,15 @@ def list_authenticated_providers(
|
||||
# region (eu.*, us.*, ap.*) instead of the hardcoded us.* static list.
|
||||
if _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
_ids = bedrock_model_ids_or_none()
|
||||
_cp_model_ids = _ids if _ids is not None else curated.get(_cp.slug, [])
|
||||
_ids = cached_provider_model_ids(_cp.slug)
|
||||
_cp_model_ids = _ids if _ids else curated.get(_cp.slug, [])
|
||||
except Exception:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
else:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
# Unified pathway — same as sections 1 and 2.
|
||||
_cp_model_ids = cached_provider_model_ids(_cp.slug)
|
||||
if not _cp_model_ids:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
_cp_total = len(_cp_model_ids)
|
||||
_cp_top = _cp_model_ids[:max_models]
|
||||
|
||||
|
||||
+248
-25
@@ -32,6 +32,8 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
||||
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
|
||||
# (model_id, display description shown in menus)
|
||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("anthropic/claude-opus-4.8", ""),
|
||||
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
|
||||
("anthropic/claude-opus-4.7", ""),
|
||||
("anthropic/claude-opus-4.6", ""),
|
||||
("anthropic/claude-sonnet-4.6", ""),
|
||||
@@ -139,6 +141,7 @@ def _xai_curated_models() -> list[str]:
|
||||
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
@@ -290,6 +293,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"MiniMax-M2",
|
||||
],
|
||||
"anthropic": [
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
@@ -518,9 +522,19 @@ def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dic
|
||||
def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
|
||||
"""Return True if the account info indicates a free (unpaid) tier.
|
||||
|
||||
Checks ``subscription.monthly_charge == 0``. Returns False when
|
||||
the field is missing or unparseable (assumes paid — don't block users).
|
||||
Prefer the Portal's explicit ``paid_service_access.allowed`` entitlement
|
||||
decision. Legacy payloads fall back to ``subscription.monthly_charge == 0``.
|
||||
Returns False when both signals are missing or unparseable.
|
||||
"""
|
||||
paid_access = account_info.get("paid_service_access")
|
||||
if isinstance(paid_access, dict):
|
||||
allowed = paid_access.get("allowed")
|
||||
if isinstance(allowed, bool):
|
||||
return not allowed
|
||||
paid = paid_access.get("paid_access")
|
||||
if isinstance(paid, bool):
|
||||
return not paid
|
||||
|
||||
sub = account_info.get("subscription")
|
||||
if not isinstance(sub, dict):
|
||||
return False
|
||||
@@ -699,40 +713,28 @@ _FREE_TIER_CACHE_TTL: int = 180 # seconds (3 minutes)
|
||||
_free_tier_cache: tuple[bool, float] | None = None # (result, timestamp)
|
||||
|
||||
|
||||
def check_nous_free_tier() -> bool:
|
||||
def check_nous_free_tier(*, force_fresh: bool = False) -> bool:
|
||||
"""Check if the current Nous Portal user is on a free (unpaid) tier.
|
||||
|
||||
Results are cached for ``_FREE_TIER_CACHE_TTL`` seconds to avoid
|
||||
hitting the Portal API on every call. The cache is short-lived so
|
||||
that an account upgrade is reflected within a few minutes.
|
||||
|
||||
Returns False (assume paid) on any error — never blocks paying users.
|
||||
Returns True only when entitlement is known to be free. Unknown/error
|
||||
states return False so this compatibility wrapper does not block users.
|
||||
"""
|
||||
global _free_tier_cache
|
||||
now = time.monotonic()
|
||||
if _free_tier_cache is not None:
|
||||
if not force_fresh and _free_tier_cache is not None:
|
||||
cached_result, cached_at = _free_tier_cache
|
||||
if now - cached_at < _FREE_TIER_CACHE_TTL:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state, resolve_nous_runtime_credentials
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
# Ensure we have a fresh token (triggers refresh if needed)
|
||||
resolve_nous_runtime_credentials(min_key_ttl_seconds=60)
|
||||
|
||||
state = get_provider_auth_state("nous")
|
||||
if not state:
|
||||
_free_tier_cache = (False, now)
|
||||
return False
|
||||
access_token = state.get("access_token", "")
|
||||
portal_url = state.get("portal_base_url", "")
|
||||
if not access_token:
|
||||
_free_tier_cache = (False, now)
|
||||
return False
|
||||
|
||||
account_info = fetch_nous_account_tier(access_token, portal_url)
|
||||
result = is_nous_free_tier(account_info)
|
||||
account_info = get_nous_portal_account_info(force_fresh=force_fresh)
|
||||
result = account_info.is_free_tier
|
||||
_free_tier_cache = (result, now)
|
||||
return result
|
||||
except Exception:
|
||||
@@ -2045,6 +2047,12 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
# Live failed (or no creds). Fall back to the docs-hosted manifest
|
||||
# — NOT the in-repo _PROVIDER_MODELS["nous"] snapshot — so newly
|
||||
# added Portal models still surface without a Hermes release.
|
||||
manifest_ids = get_curated_nous_model_ids()
|
||||
if manifest_ids:
|
||||
return manifest_ids
|
||||
if normalized == "stepfun":
|
||||
try:
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
@@ -2108,7 +2116,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
# below — bedrock is not expected to appear in that table.
|
||||
if normalized == "bedrock":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock_ns = registries.get_provider_namespace("bedrock")
|
||||
bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none")
|
||||
if bedrock_model_ids_or_none is None:
|
||||
raise ImportError("bedrock_model_ids_or_none not found in bedrock provider")
|
||||
ids = bedrock_model_ids_or_none()
|
||||
if ids is not None:
|
||||
return ids
|
||||
@@ -2148,6 +2160,206 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
return curated_static
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic disk cache for provider_model_ids() — keeps /model picker fast.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Without this layer, every /model picker open re-fetches every authed
|
||||
# provider's /v1/models endpoint. On a well-configured user (anthropic +
|
||||
# openai + copilot + gemini + huggingface + ...) that's 2+ seconds of cold
|
||||
# HTTP roundtrips just to render the provider list.
|
||||
#
|
||||
# Cache strategy:
|
||||
# - One JSON file at $HERMES_HOME/provider_models_cache.json
|
||||
# - Per-provider entries keyed by (provider, credential fingerprint)
|
||||
# - Credential fingerprint = sha256 of env-var values that the provider
|
||||
# normally reads. Swap your OPENAI_API_KEY and the entry invalidates.
|
||||
# - 1h TTL by default. `force_refresh=True` skips the cache entirely
|
||||
# and overwrites it on success.
|
||||
# - Only NON-EMPTY results are cached. An empty/None response from a
|
||||
# transient network error never gets pinned.
|
||||
# - Cache file is best-effort. Any read/write error degrades silently
|
||||
# to a live fetch — the picker keeps working.
|
||||
|
||||
_PROVIDER_MODELS_CACHE_TTL = 3600 # 1h
|
||||
|
||||
|
||||
def _provider_models_cache_path() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "provider_models_cache.json"
|
||||
|
||||
|
||||
def _credential_fingerprint(provider: str) -> str:
|
||||
"""Return a short hash representing the credentials that
|
||||
``provider_model_ids(provider)`` would see right now.
|
||||
|
||||
Rotating any of the relevant env vars invalidates the cached entry
|
||||
for that provider. We hash AT LEAST the api-key + base-url env vars
|
||||
declared in ``PROVIDER_REGISTRY``. For OAuth-backed providers
|
||||
(codex, copilot, anthropic-via-claude-code, nous portal), the
|
||||
relevant tokens live in ``$HERMES_HOME/auth.json`` and external
|
||||
credential files. Rather than parse every shape, we additionally
|
||||
fold the mtime of those files into the fingerprint so refreshes
|
||||
after re-auth bust the cache.
|
||||
"""
|
||||
import hashlib
|
||||
import os as _os
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# Env vars from PROVIDER_REGISTRY for this slug
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pcfg = PROVIDER_REGISTRY.get(provider)
|
||||
if pcfg is not None:
|
||||
for ev in getattr(pcfg, "api_key_env_vars", ()) or ():
|
||||
parts.append(f"{ev}={_os.environ.get(ev, '')}")
|
||||
bev = getattr(pcfg, "base_url_env_var", "") or ""
|
||||
if bev:
|
||||
parts.append(f"{bev}={_os.environ.get(bev, '')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# OAuth / external-file mtimes that change on re-auth
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
for rel in ("auth.json", "credentials.json"):
|
||||
p = get_hermes_home() / rel
|
||||
try:
|
||||
parts.append(f"{rel}@{p.stat().st_mtime_ns}")
|
||||
except FileNotFoundError:
|
||||
parts.append(f"{rel}@missing")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# External well-known credential file locations
|
||||
for path in (
|
||||
_os.path.expanduser("~/.codex/auth.json"),
|
||||
_os.path.expanduser("~/.claude/.credentials.json"),
|
||||
_os.path.expanduser("~/.config/github-copilot/hosts.json"),
|
||||
_os.path.expanduser("~/.minimax/credentials.json"),
|
||||
):
|
||||
try:
|
||||
mt = _os.stat(path).st_mtime_ns
|
||||
parts.append(f"{path}@{mt}")
|
||||
except FileNotFoundError:
|
||||
parts.append(f"{path}@missing")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
blob = "|".join(parts).encode("utf-8", errors="replace")
|
||||
# blake2b for cache-key fingerprinting only — not for credential storage.
|
||||
# We never reverse this hash; collisions are harmless (worst case: cache
|
||||
# miss → live re-fetch). Use blake2b instead of sha256 here because
|
||||
# CodeQL's `py/weak-sensitive-data-hashing` rule flags sha256 over env
|
||||
# vars whose names contain "API_KEY" / "TOKEN" even when the hash is
|
||||
# used as an identity fingerprint, not for password storage. blake2b
|
||||
# is a keyed-hash primitive and isn't flagged.
|
||||
return hashlib.blake2b(blob, digest_size=8).hexdigest()
|
||||
|
||||
|
||||
def _load_provider_models_cache() -> dict:
|
||||
"""Return the full cache dict, or {} on any error."""
|
||||
try:
|
||||
path = _provider_models_cache_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_provider_models_cache(data: dict) -> None:
|
||||
"""Persist the cache dict. Best-effort — silent on any error."""
|
||||
try:
|
||||
from utils import atomic_json_write
|
||||
path = _provider_models_cache_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(path, data, indent=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cached_provider_model_ids(
|
||||
provider: Optional[str],
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL,
|
||||
) -> list[str]:
|
||||
"""Disk-cached wrapper around :func:`provider_model_ids`.
|
||||
|
||||
Hits the cache when fresh; otherwise calls the live function and
|
||||
persists a non-empty result. Always returns a list (never None).
|
||||
"""
|
||||
normalized = normalize_provider(provider) or (provider or "")
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
cache = _load_provider_models_cache()
|
||||
fp = _credential_fingerprint(normalized)
|
||||
entry = cache.get(normalized)
|
||||
now = time.time()
|
||||
|
||||
if (
|
||||
not force_refresh
|
||||
and isinstance(entry, dict)
|
||||
and entry.get("fp") == fp
|
||||
and isinstance(entry.get("models"), list)
|
||||
and entry["models"]
|
||||
and (now - float(entry.get("at", 0))) < ttl_seconds
|
||||
):
|
||||
return list(entry["models"])
|
||||
|
||||
# Cache miss / stale / forced refresh — call the live path.
|
||||
live = provider_model_ids(normalized, force_refresh=force_refresh)
|
||||
if live:
|
||||
cache[normalized] = {
|
||||
"fp": fp,
|
||||
"at": now,
|
||||
"models": list(live),
|
||||
}
|
||||
_save_provider_models_cache(cache)
|
||||
return list(live)
|
||||
|
||||
# Live fetch returned nothing. If we have a stale entry with the
|
||||
# SAME fingerprint, prefer it over an empty result — stale data
|
||||
# beats no data when the network is flaky.
|
||||
if (
|
||||
isinstance(entry, dict)
|
||||
and entry.get("fp") == fp
|
||||
and isinstance(entry.get("models"), list)
|
||||
and entry["models"]
|
||||
):
|
||||
return list(entry["models"])
|
||||
return list(live or [])
|
||||
|
||||
|
||||
def clear_provider_models_cache(provider: Optional[str] = None) -> None:
|
||||
"""Drop a single provider's cache entry, or wipe the whole cache.
|
||||
|
||||
``provider=None`` wipes everything; otherwise only that provider's
|
||||
entry is removed. Used by ``/model --refresh`` and
|
||||
``hermes model --refresh``.
|
||||
"""
|
||||
try:
|
||||
if provider is None:
|
||||
path = _provider_models_cache_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return
|
||||
cache = _load_provider_models_cache()
|
||||
normalized = normalize_provider(provider) or provider or ""
|
||||
if normalized in cache:
|
||||
del cache[normalized]
|
||||
_save_provider_models_cache(cache)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
"""Fetch available models from the Anthropic /v1/models endpoint.
|
||||
|
||||
@@ -2155,7 +2367,14 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
Claude Code auto-discovery). Returns sorted model IDs or None.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
|
||||
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
|
||||
# Beta header constants live in core agent.anthropic_format.
|
||||
from agent.anthropic_format import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
||||
if resolve_anthropic_token is None or _is_oauth_token is None:
|
||||
raise ImportError("anthropic provider services not registered")
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
@@ -2167,7 +2386,6 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
is_oauth = _is_oauth_token(token)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
headers["x-api-key"] = token
|
||||
@@ -3499,7 +3717,12 @@ def validate_requested_model(
|
||||
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
|
||||
if normalized == "bedrock":
|
||||
try:
|
||||
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock_ns = registries.get_provider_namespace("bedrock")
|
||||
discover_bedrock_models = _bedrock_ns.get("discover_bedrock_models")
|
||||
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
|
||||
if discover_bedrock_models is None or resolve_bedrock_region is None:
|
||||
raise ImportError("bedrock discovery functions not registered")
|
||||
region = resolve_bedrock_region()
|
||||
discovered = discover_bedrock_models(region)
|
||||
discovered_ids = {m["id"] for m in discovered}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Normalized Nous Portal account entitlement helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
|
||||
NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "error"]
|
||||
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalSubscriptionInfo:
|
||||
plan: Optional[str] = None
|
||||
tier: Optional[int] = None
|
||||
monthly_charge: Optional[float] = None
|
||||
current_period_end: Optional[str] = None
|
||||
credits_remaining: Optional[float] = None
|
||||
rollover_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPaidServiceAccessInfo:
|
||||
allowed: Optional[bool] = None
|
||||
paid_access: Optional[bool] = None
|
||||
reason: Optional[str] = None
|
||||
organisation_id: Optional[str] = None
|
||||
effective_at_ms: Optional[int] = None
|
||||
has_active_subscription: Optional[bool] = None
|
||||
active_subscription_is_paid: Optional[bool] = None
|
||||
subscription_tier: Optional[int] = None
|
||||
subscription_monthly_charge: Optional[float] = None
|
||||
subscription_credits_remaining: Optional[float] = None
|
||||
purchased_credits_remaining: Optional[float] = None
|
||||
total_usable_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalAccountInfo:
|
||||
logged_in: bool
|
||||
source: NousAccountInfoSource
|
||||
fresh: bool
|
||||
user_id: Optional[str] = None
|
||||
org_id: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
product_id: Optional[str] = None
|
||||
nous_client: Optional[str] = None
|
||||
portal_base_url: Optional[str] = None
|
||||
inference_base_url: Optional[str] = None
|
||||
inference_credential_present: bool = False
|
||||
credential_source: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
email: Optional[str] = None
|
||||
privy_did: Optional[str] = None
|
||||
subscription: Optional[NousPortalSubscriptionInfo] = None
|
||||
paid_service_access: Optional[bool] = None
|
||||
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
|
||||
raw_claims: Optional[dict[str, Any]] = None
|
||||
raw_account: Optional[dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.paid_service_access is True
|
||||
|
||||
@property
|
||||
def is_free_tier(self) -> bool:
|
||||
return self.paid_service_access is False
|
||||
|
||||
@property
|
||||
def tool_gateway_entitled(self) -> bool:
|
||||
return self.paid_service_access is True
|
||||
|
||||
|
||||
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
|
||||
"""Return the billing URL for a normalized Nous account snapshot."""
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL
|
||||
except Exception:
|
||||
DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
|
||||
|
||||
base = None
|
||||
if account_info is not None:
|
||||
base = account_info.portal_base_url
|
||||
if not isinstance(base, str) or not base.strip():
|
||||
base = DEFAULT_NOUS_PORTAL_URL
|
||||
return f"{base.rstrip('/')}/billing"
|
||||
|
||||
|
||||
def format_nous_portal_entitlement_message(
|
||||
account_info: Optional[NousPortalAccountInfo],
|
||||
*,
|
||||
capability: str = "this feature",
|
||||
include_refresh_hint: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Return user-facing guidance for a missing Nous paid entitlement.
|
||||
|
||||
``None`` means the account is known to have paid service access. The
|
||||
message intentionally works from normalized entitlement fields rather than
|
||||
subscription price alone: purchased credits without a subscription still
|
||||
count as paid access, while a paid subscription with exhausted usable
|
||||
credits does not.
|
||||
"""
|
||||
billing_url = nous_portal_billing_url(account_info)
|
||||
|
||||
if account_info is not None and account_info.paid_service_access is True:
|
||||
return None
|
||||
|
||||
if account_info is None:
|
||||
return (
|
||||
f"Hermes could not verify your Nous Portal entitlement, so {capability} "
|
||||
f"is unavailable. Run `hermes model` to refresh your login, or check "
|
||||
f"billing at {billing_url}."
|
||||
)
|
||||
|
||||
if not account_info.logged_in:
|
||||
if account_info.inference_credential_present:
|
||||
return (
|
||||
f"Nous inference credentials are configured, but Hermes cannot verify "
|
||||
f"your Nous Portal paid access for {capability}. Log in with "
|
||||
f"`hermes model` to enable Portal-managed features. Billing and "
|
||||
f"credits are managed at {billing_url}."
|
||||
)
|
||||
return (
|
||||
f"Log in to Nous Portal to use {capability}: run `hermes model`. "
|
||||
f"Billing and credits are managed at {billing_url}."
|
||||
)
|
||||
|
||||
if account_info.paid_service_access is None:
|
||||
detail = (
|
||||
f"Hermes could not verify your Nous Portal paid access, so {capability} "
|
||||
f"is unavailable."
|
||||
)
|
||||
if account_info.error:
|
||||
detail += f" Account lookup failed: {account_info.error}."
|
||||
if include_refresh_hint:
|
||||
detail += " Run `hermes model` to refresh your session."
|
||||
detail += f" Check billing at {billing_url}."
|
||||
return detail
|
||||
|
||||
access = account_info.paid_service_access_info
|
||||
reason = access.reason if access else None
|
||||
if reason == "account_missing":
|
||||
return (
|
||||
f"Hermes could not find a Nous Portal account or organisation for this "
|
||||
f"login, so {capability} is unavailable. Run `hermes model` to "
|
||||
f"authenticate again; if the problem persists, contact Nous support."
|
||||
)
|
||||
|
||||
if reason == "no_usable_credits" or account_info.paid_service_access is False:
|
||||
message = _no_paid_access_message(account_info, capability, billing_url)
|
||||
if include_refresh_hint and not account_info.fresh:
|
||||
message += " If you recently bought credits, run `hermes model` to refresh Hermes."
|
||||
return message
|
||||
|
||||
return (
|
||||
f"Your Nous Portal account does not currently have paid service access, "
|
||||
f"so {capability} is unavailable. Add credits or update billing at {billing_url}."
|
||||
)
|
||||
|
||||
|
||||
def _no_paid_access_message(
|
||||
account_info: NousPortalAccountInfo,
|
||||
capability: str,
|
||||
billing_url: str,
|
||||
) -> str:
|
||||
access = account_info.paid_service_access_info
|
||||
has_active_subscription = access.has_active_subscription if access else None
|
||||
active_subscription_is_paid = access.active_subscription_is_paid if access else None
|
||||
total_usable = access.total_usable_credits if access else None
|
||||
subscription_credits = access.subscription_credits_remaining if access else None
|
||||
purchased_credits = access.purchased_credits_remaining if access else None
|
||||
|
||||
if has_active_subscription and active_subscription_is_paid:
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal credits are exhausted{credit_detail}, so {capability} "
|
||||
f"is unavailable. Top up or renew credits at {billing_url}."
|
||||
)
|
||||
|
||||
if has_active_subscription and active_subscription_is_paid is False:
|
||||
return (
|
||||
f"Your current Nous Portal plan does not include paid service access, "
|
||||
f"so {capability} is unavailable. Upgrade or add credits at {billing_url}."
|
||||
)
|
||||
|
||||
if has_active_subscription is False:
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal account has no active subscription or usable credits"
|
||||
f"{credit_detail}, so {capability} is unavailable. Subscribe or add credits "
|
||||
f"at {billing_url}."
|
||||
)
|
||||
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal account has no usable paid credits{credit_detail}, so "
|
||||
f"{capability} is unavailable. Add credits or update billing at {billing_url}."
|
||||
)
|
||||
|
||||
|
||||
def _credit_detail(
|
||||
total_usable: Optional[float],
|
||||
subscription_credits: Optional[float],
|
||||
purchased_credits: Optional[float],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if total_usable is not None:
|
||||
parts.append(f"usable ${total_usable:.2f}")
|
||||
if subscription_credits is not None:
|
||||
parts.append(f"subscription ${subscription_credits:.2f}")
|
||||
if purchased_credits is not None:
|
||||
parts.append(f"purchased ${purchased_credits:.2f}")
|
||||
if not parts:
|
||||
return ""
|
||||
return f" ({', '.join(parts)})"
|
||||
|
||||
|
||||
def reset_nous_portal_account_info_cache() -> None:
|
||||
"""Clear the short-lived account-info cache used by tests."""
|
||||
global _account_info_cache
|
||||
_account_info_cache = None
|
||||
|
||||
|
||||
def get_nous_portal_account_info(
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
min_jwt_ttl_seconds: int = 60,
|
||||
) -> NousPortalAccountInfo:
|
||||
"""Return normalized Nous Portal account entitlement information.
|
||||
|
||||
By default, a valid unexpired OAuth access JWT is used as a low-latency
|
||||
local account snapshot. ``force_fresh=True`` always calls
|
||||
``/api/oauth/account`` and bypasses the short-lived cache. JWT claims are
|
||||
decoded locally for UX gating only; server APIs remain authoritative.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
except Exception as exc:
|
||||
return _error_info(error=exc, logged_in=False)
|
||||
|
||||
access_token = state.get("access_token")
|
||||
portal_base_url = _portal_base_url(state)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
pool_oauth_info = _info_from_oauth_pool(
|
||||
force_fresh=force_fresh,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
if pool_oauth_info is not None:
|
||||
return pool_oauth_info
|
||||
pool_info = _info_from_inference_key_pool(portal_base_url)
|
||||
if pool_info is not None:
|
||||
return pool_info
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=False,
|
||||
source="none",
|
||||
fresh=False,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
if not force_fresh:
|
||||
jwt_info = _info_from_valid_jwt(
|
||||
access_token,
|
||||
state=state,
|
||||
portal_base_url=portal_base_url,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
)
|
||||
if jwt_info is not None:
|
||||
return jwt_info
|
||||
|
||||
return _fresh_account_info(
|
||||
state=state,
|
||||
force_fresh=force_fresh,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _fresh_account_info(
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
force_fresh: bool,
|
||||
portal_base_url: Optional[str],
|
||||
) -> NousPortalAccountInfo:
|
||||
global _account_info_cache
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state, resolve_nous_access_token
|
||||
|
||||
access_token = resolve_nous_access_token()
|
||||
refreshed_state = get_provider_auth_state("nous") or state
|
||||
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
|
||||
cache_key = _cache_key(access_token, portal_base_url)
|
||||
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
|
||||
payload = _fetch_nous_account_info(access_token, portal_base_url)
|
||||
if not payload:
|
||||
return _error_info(
|
||||
error="empty_account_response",
|
||||
logged_in=True,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
if isinstance(payload.get("error"), str):
|
||||
return _error_info(
|
||||
error=payload.get("error") or "account_response_error",
|
||||
logged_in=True,
|
||||
portal_base_url=portal_base_url,
|
||||
raw_account=payload,
|
||||
)
|
||||
|
||||
info = _info_from_account_payload(
|
||||
payload,
|
||||
state=refreshed_state,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
return info
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
error=exc,
|
||||
logged_in=bool(state.get("access_token")),
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _info_from_inference_key_pool(
|
||||
portal_base_url: Optional[str],
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
"""Return an explicit unknown-entitlement snapshot for opaque Nous keys."""
|
||||
try:
|
||||
entry = _select_nous_pool_entry()
|
||||
if entry is None:
|
||||
return None
|
||||
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
|
||||
if not isinstance(runtime_key, str) or not runtime_key.strip():
|
||||
return None
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=False,
|
||||
source="inference_key",
|
||||
fresh=False,
|
||||
portal_base_url=(
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or portal_base_url
|
||||
),
|
||||
inference_base_url=(
|
||||
getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
),
|
||||
inference_credential_present=True,
|
||||
credential_source=f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
error="portal_oauth_missing",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _info_from_oauth_pool(
|
||||
*,
|
||||
force_fresh: bool,
|
||||
min_jwt_ttl_seconds: int,
|
||||
portal_base_url: Optional[str],
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
try:
|
||||
entry = _select_nous_pool_entry()
|
||||
except Exception:
|
||||
return None
|
||||
if entry is None or not _pool_entry_is_portal_oauth(entry):
|
||||
return None
|
||||
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
return None
|
||||
|
||||
entry_portal_url = (
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or portal_base_url
|
||||
)
|
||||
state = {
|
||||
"access_token": access_token,
|
||||
"client_id": getattr(entry, "client_id", None),
|
||||
"inference_base_url": (
|
||||
getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
),
|
||||
"agent_key": getattr(entry, "agent_key", None),
|
||||
"credential_source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
}
|
||||
|
||||
if not force_fresh:
|
||||
jwt_info = _info_from_valid_jwt(
|
||||
access_token,
|
||||
state=state,
|
||||
portal_base_url=entry_portal_url,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
)
|
||||
if jwt_info is not None:
|
||||
return jwt_info
|
||||
|
||||
try:
|
||||
payload = _fetch_nous_account_info(access_token, entry_portal_url)
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
error=exc,
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
if not payload:
|
||||
return _error_info(
|
||||
error="empty_account_response",
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
if isinstance(payload.get("error"), str):
|
||||
return _error_info(
|
||||
error=payload.get("error") or "account_response_error",
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
raw_account=payload,
|
||||
)
|
||||
return _info_from_account_payload(
|
||||
payload,
|
||||
state=state,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
|
||||
|
||||
def _select_nous_pool_entry() -> Optional[Any]:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("nous")
|
||||
if not pool or not pool.has_credentials():
|
||||
return None
|
||||
entries = list(pool.entries())
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
|
||||
agent_exp = _parse_iso_timestamp(getattr(entry, "agent_key_expires_at", None)) or 0.0
|
||||
access_exp = _parse_iso_timestamp(getattr(entry, "expires_at", None)) or 0.0
|
||||
priority = int(getattr(entry, "priority", 0) or 0)
|
||||
return (agent_exp, access_exp, -priority)
|
||||
|
||||
return max(entries, key=_entry_sort_key)
|
||||
|
||||
|
||||
def _pool_entry_is_portal_oauth(entry: Any) -> bool:
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
return False
|
||||
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
|
||||
refresh_token = getattr(entry, "refresh_token", None)
|
||||
return auth_type.startswith("oauth") or bool(refresh_token)
|
||||
|
||||
|
||||
def _fetch_nous_account_info(
|
||||
access_token: str,
|
||||
portal_base_url: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
||||
url = f"{base}/api/oauth/account"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _info_from_valid_jwt(
|
||||
token: str,
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
portal_base_url: Optional[str],
|
||||
min_jwt_ttl_seconds: int,
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
try:
|
||||
from hermes_cli.auth import _decode_jwt_claims
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
claims = _decode_jwt_claims(token)
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
exp = _coerce_float(claims.get("exp"))
|
||||
if exp is None or exp <= time.time() + max(0, int(min_jwt_ttl_seconds)):
|
||||
return None
|
||||
|
||||
paid_access = _coerce_bool(claims.get("paid_access"))
|
||||
subscription_tier = _coerce_int(claims.get("subscription_tier"))
|
||||
access_info = NousPaidServiceAccessInfo(
|
||||
allowed=paid_access,
|
||||
paid_access=paid_access,
|
||||
organisation_id=_coerce_str(claims.get("org_id")),
|
||||
subscription_tier=subscription_tier,
|
||||
)
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="jwt",
|
||||
fresh=False,
|
||||
user_id=_coerce_str(claims.get("sub")),
|
||||
org_id=_coerce_str(claims.get("org_id")),
|
||||
client_id=_coerce_str(claims.get("client_id") or state.get("client_id")),
|
||||
product_id=_coerce_str(claims.get("product_id")),
|
||||
nous_client=_coerce_str(claims.get("nous_client")),
|
||||
portal_base_url=portal_base_url,
|
||||
inference_base_url=_coerce_str(state.get("inference_base_url")),
|
||||
inference_credential_present=True,
|
||||
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
|
||||
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access_info,
|
||||
raw_claims=dict(claims),
|
||||
)
|
||||
|
||||
|
||||
def _info_from_account_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
portal_base_url: Optional[str],
|
||||
) -> NousPortalAccountInfo:
|
||||
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||
organisation = (
|
||||
payload.get("organisation")
|
||||
if isinstance(payload.get("organisation"), dict)
|
||||
else {}
|
||||
)
|
||||
subscription = _subscription_from_payload(payload.get("subscription"))
|
||||
access = _paid_service_access_from_payload(payload.get("paid_service_access"))
|
||||
paid_access = access.allowed if access else None
|
||||
if paid_access is None and access is not None:
|
||||
paid_access = access.paid_access
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="account_api",
|
||||
fresh=True,
|
||||
org_id=_coerce_str(organisation.get("id")) or (access.organisation_id if access else None),
|
||||
client_id=_coerce_str(state.get("client_id")),
|
||||
portal_base_url=portal_base_url,
|
||||
inference_base_url=_coerce_str(state.get("inference_base_url")),
|
||||
inference_credential_present=bool(state.get("access_token") or state.get("agent_key")),
|
||||
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
|
||||
email=_coerce_str(user.get("email")),
|
||||
privy_did=_coerce_str(user.get("privy_did")),
|
||||
subscription=subscription,
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access,
|
||||
raw_account=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return NousPortalSubscriptionInfo(
|
||||
plan=_coerce_str(value.get("plan")),
|
||||
tier=_coerce_int(value.get("tier")),
|
||||
monthly_charge=_coerce_float(value.get("monthly_charge")),
|
||||
current_period_end=_coerce_str(value.get("current_period_end")),
|
||||
credits_remaining=_coerce_float(value.get("credits_remaining")),
|
||||
rollover_credits=_coerce_float(value.get("rollover_credits")),
|
||||
)
|
||||
|
||||
|
||||
def _paid_service_access_from_payload(value: Any) -> Optional[NousPaidServiceAccessInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
allowed = _coerce_bool(value.get("allowed"))
|
||||
paid_access = _coerce_bool(value.get("paid_access"))
|
||||
return NousPaidServiceAccessInfo(
|
||||
allowed=allowed,
|
||||
paid_access=paid_access,
|
||||
reason=_coerce_str(value.get("reason")),
|
||||
organisation_id=_coerce_str(value.get("organisation_id")),
|
||||
effective_at_ms=_coerce_int(value.get("effective_at_ms")),
|
||||
has_active_subscription=_coerce_bool(value.get("has_active_subscription")),
|
||||
active_subscription_is_paid=_coerce_bool(value.get("active_subscription_is_paid")),
|
||||
subscription_tier=_coerce_int(value.get("subscription_tier")),
|
||||
subscription_monthly_charge=_coerce_float(value.get("subscription_monthly_charge")),
|
||||
subscription_credits_remaining=_coerce_float(value.get("subscription_credits_remaining")),
|
||||
purchased_credits_remaining=_coerce_float(value.get("purchased_credits_remaining")),
|
||||
total_usable_credits=_coerce_float(value.get("total_usable_credits")),
|
||||
)
|
||||
|
||||
|
||||
def _error_info(
|
||||
*,
|
||||
error: object,
|
||||
logged_in: bool,
|
||||
portal_base_url: Optional[str] = None,
|
||||
raw_account: Optional[dict[str, Any]] = None,
|
||||
) -> NousPortalAccountInfo:
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=logged_in,
|
||||
source="error",
|
||||
fresh=False,
|
||||
portal_base_url=portal_base_url,
|
||||
raw_account=raw_account,
|
||||
error=str(error),
|
||||
)
|
||||
|
||||
|
||||
def _portal_base_url(state: dict[str, Any]) -> Optional[str]:
|
||||
value = state.get("portal_base_url")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
|
||||
def _cache_key(access_token: str, portal_base_url: Optional[str]) -> str:
|
||||
digest = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
|
||||
return f"{portal_base_url or ''}:{digest}"
|
||||
|
||||
|
||||
def _parse_iso_timestamp(value: Any) -> Optional[float]:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_str(value: Any) -> Optional[str]:
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> Optional[bool]:
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> Optional[float]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -6,8 +6,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Optional, Set
|
||||
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.config import get_env_value, load_config
|
||||
from hermes_cli.nous_account import NousPortalAccountInfo, get_nous_portal_account_info
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
from utils import is_truthy_value
|
||||
from tools.tool_backend_helpers import (
|
||||
@@ -53,6 +53,7 @@ class NousSubscriptionFeatures:
|
||||
nous_auth_present: bool
|
||||
provider_is_nous: bool
|
||||
features: Dict[str, NousFeatureState]
|
||||
account_info: Optional[NousPortalAccountInfo] = None
|
||||
|
||||
@property
|
||||
def web(self) -> NousFeatureState:
|
||||
@@ -227,6 +228,8 @@ def _resolve_browser_feature_state(
|
||||
|
||||
def get_nous_subscription_features(
|
||||
config: Optional[Dict[str, object]] = None,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> NousSubscriptionFeatures:
|
||||
if config is None:
|
||||
config = load_config() or {}
|
||||
@@ -235,12 +238,19 @@ def get_nous_subscription_features(
|
||||
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
|
||||
|
||||
try:
|
||||
nous_status = get_nous_auth_status()
|
||||
if force_fresh:
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
else:
|
||||
account_info = get_nous_portal_account_info()
|
||||
except Exception:
|
||||
nous_status = {}
|
||||
account_info = None
|
||||
|
||||
managed_tools_flag = managed_nous_tools_enabled()
|
||||
nous_auth_present = bool(nous_status.get("logged_in"))
|
||||
managed_tools_flag = bool(
|
||||
account_info
|
||||
and account_info.logged_in
|
||||
and account_info.paid_service_access is True
|
||||
)
|
||||
nous_auth_present = bool(account_info and account_info.logged_in)
|
||||
subscribed = provider_is_nous or nous_auth_present
|
||||
|
||||
web_tool_enabled = _toolset_enabled(config, "web")
|
||||
@@ -317,6 +327,7 @@ def get_nous_subscription_features(
|
||||
modal_mode,
|
||||
has_direct=direct_modal,
|
||||
managed_ready=managed_modal_available,
|
||||
managed_enabled=managed_tools_flag,
|
||||
)
|
||||
|
||||
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
|
||||
@@ -483,6 +494,7 @@ def get_nous_subscription_features(
|
||||
nous_auth_present=nous_auth_present,
|
||||
provider_is_nous=provider_is_nous,
|
||||
features=features,
|
||||
account_info=account_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -493,11 +505,15 @@ def apply_nous_managed_defaults(
|
||||
config: Dict[str, object],
|
||||
*,
|
||||
enabled_toolsets: Optional[Iterable[str]] = None,
|
||||
force_fresh: bool = False,
|
||||
) -> set[str]:
|
||||
if not managed_nous_tools_enabled():
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
if not (
|
||||
features.account_info
|
||||
and features.account_info.logged_in
|
||||
and features.account_info.paid_service_access is True
|
||||
):
|
||||
return set()
|
||||
|
||||
features = get_nous_subscription_features(config)
|
||||
if not features.provider_is_nous:
|
||||
return set()
|
||||
|
||||
@@ -594,6 +610,8 @@ _ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser")
|
||||
|
||||
def get_gateway_eligible_tools(
|
||||
config: Optional[Dict[str, object]] = None,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (unconfigured, has_direct, already_managed) tool key lists.
|
||||
|
||||
@@ -604,7 +622,11 @@ def get_gateway_eligible_tools(
|
||||
All lists are empty when the user is not a paid Nous subscriber or
|
||||
is not using Nous as their provider.
|
||||
"""
|
||||
if not managed_nous_tools_enabled():
|
||||
if force_fresh:
|
||||
managed_enabled = managed_nous_tools_enabled(force_fresh=True)
|
||||
else:
|
||||
managed_enabled = managed_nous_tools_enabled()
|
||||
if not managed_enabled:
|
||||
return [], [], []
|
||||
|
||||
if config is None:
|
||||
@@ -695,7 +717,11 @@ def apply_gateway_defaults(
|
||||
return changed
|
||||
|
||||
|
||||
def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]:
|
||||
def prompt_enable_tool_gateway(
|
||||
config: Dict[str, object],
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
) -> set[str]:
|
||||
"""If eligible tools exist, prompt the user to enable the Tool Gateway.
|
||||
|
||||
Uses prompt_choice() with a description parameter so the curses TUI
|
||||
@@ -704,7 +730,10 @@ def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]:
|
||||
Returns the set of tools that were enabled, or empty set if the user
|
||||
declined or no tools were eligible.
|
||||
"""
|
||||
unconfigured, has_direct, already_managed = get_gateway_eligible_tools(config)
|
||||
unconfigured, has_direct, already_managed = get_gateway_eligible_tools(
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
if not unconfigured and not has_direct:
|
||||
return set()
|
||||
|
||||
|
||||
+285
-25
@@ -818,6 +818,270 @@ class PluginContext:
|
||||
name,
|
||||
)
|
||||
|
||||
# -- auth provider registration -------------------------------------------
|
||||
|
||||
def register_platform_entry(
|
||||
self,
|
||||
name: str,
|
||||
adapter_class: type,
|
||||
check_requirements: Callable,
|
||||
available_flag: str = "",
|
||||
constants: dict | None = None,
|
||||
helper_functions: dict | None = None,
|
||||
) -> None:
|
||||
"""Register a platform adapter entry in the capability registries.
|
||||
|
||||
This populates ``agent.plugin_registries.registries.platform_adapters``
|
||||
so core code can look up adapter classes, constants, and helper
|
||||
functions without importing from ``hermes_agent_*`` packages directly.
|
||||
|
||||
Call this **in addition to** :meth:`register_platform` — the two
|
||||
registries serve different consumers:
|
||||
|
||||
* ``register_platform`` → ``gateway.platform_registry`` (gateway
|
||||
adapter creation, setup wizard, status)
|
||||
* ``register_platform_entry`` → ``agent.plugin_registries`` (adapter
|
||||
class access, constants, helpers for send_message_tool, etc.)
|
||||
|
||||
Args:
|
||||
name: Platform identifier (e.g. ``"telegram"``).
|
||||
adapter_class: The adapter class itself (e.g. ``TelegramAdapter``).
|
||||
check_requirements: Callable returning ``bool`` — are deps installed?
|
||||
available_flag: Name of the module-level AVAILABLE boolean, if any.
|
||||
constants: Platform-specific constants (e.g.
|
||||
``{"FEISHU_DOMAIN": ..., "LARK_DOMAIN": ...}``).
|
||||
helper_functions: Platform-specific helpers (e.g.
|
||||
``{"_strip_mdv2": _strip_mdv2, "qr_register": qr_register}``).
|
||||
"""
|
||||
from agent.plugin_registries import registries, PlatformAdapterEntry
|
||||
|
||||
entry = PlatformAdapterEntry(
|
||||
name=name,
|
||||
adapter_class=adapter_class,
|
||||
check_requirements=check_requirements,
|
||||
available_flag=available_flag,
|
||||
constants=constants or {},
|
||||
helper_functions=helper_functions or {},
|
||||
)
|
||||
registries.register_platform(entry)
|
||||
logger.debug(
|
||||
"Plugin %s registered platform entry: %s",
|
||||
self.manifest.name,
|
||||
name,
|
||||
)
|
||||
|
||||
def register_tool_provider_entry(
|
||||
self,
|
||||
name: str,
|
||||
tool_functions: dict | None = None,
|
||||
check_fn: Callable | None = None,
|
||||
constants: dict | None = None,
|
||||
config_functions: dict | None = None,
|
||||
environment_classes: dict | None = None,
|
||||
) -> None:
|
||||
"""Register a tool provider entry in the capability registries.
|
||||
|
||||
This populates ``agent.plugin_registries.registries.tool_providers``
|
||||
so core code can look up tool functions, constants, and config
|
||||
helpers without importing from ``hermes_agent_*`` packages directly.
|
||||
|
||||
Args:
|
||||
name: Tool identifier (e.g. ``"tts"``, ``"stt"``).
|
||||
tool_functions: Dict of function name → callable
|
||||
(e.g. ``{"text_to_speech_tool": text_to_speech_tool}``).
|
||||
check_fn: Optional callable returning ``bool`` — are deps
|
||||
installed and configured?
|
||||
constants: Tool-specific constants
|
||||
(e.g. ``{"MAX_FILE_SIZE": 25 * 1024 * 1024}``).
|
||||
config_functions: Config/utility functions
|
||||
(e.g. ``{"is_stt_enabled": is_stt_enabled}``).
|
||||
environment_classes: Environment classes for terminal backends
|
||||
(e.g. ``{"DaytonaEnvironment": DaytonaEnvironment}``).
|
||||
"""
|
||||
from agent.plugin_registries import registries, ToolProviderEntry
|
||||
|
||||
entry = ToolProviderEntry(
|
||||
name=name,
|
||||
tool_functions=tool_functions or {},
|
||||
check_fn=check_fn,
|
||||
constants=constants or {},
|
||||
config_functions=config_functions or {},
|
||||
environment_classes=environment_classes or {},
|
||||
)
|
||||
registries.register_tool_provider(entry)
|
||||
logger.debug(
|
||||
"Plugin %s registered tool provider entry: %s",
|
||||
self.manifest.name,
|
||||
name,
|
||||
)
|
||||
|
||||
def register_provider_services(
|
||||
self,
|
||||
name: str,
|
||||
services: dict,
|
||||
) -> None:
|
||||
"""Register a namespace dict of provider-specific services.
|
||||
|
||||
This is the escape hatch for model-provider plugins that expose many
|
||||
symbols (anthropic has 50+). Each plugin registers its public surface
|
||||
as a flat dict of ``{symbol_name: callable_or_value}``. Core code
|
||||
looks up specific symbols instead of importing from the plugin
|
||||
package directly.
|
||||
|
||||
Args:
|
||||
name: Provider identifier (e.g. ``"anthropic"``, ``"bedrock"``).
|
||||
services: Dict of symbol name → callable or value.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_provider_services(name, services)
|
||||
logger.debug(
|
||||
"Plugin %s registered provider services: %s (%d symbols)",
|
||||
self.manifest.name,
|
||||
name,
|
||||
len(services),
|
||||
)
|
||||
|
||||
def register_auth_provider(
|
||||
self,
|
||||
name: str,
|
||||
provider: Any,
|
||||
*,
|
||||
cli_group: str = "",
|
||||
setup_subcommands: bool = False,
|
||||
) -> None:
|
||||
"""Register an authentication provider.
|
||||
|
||||
``provider`` must implement the :class:`agent.plugin_registries.AuthProvider`
|
||||
protocol (``name``, ``has_credentials``, ``check_env_vars``,
|
||||
``resolve_token``, ``refresh_token``). It may also expose
|
||||
provider-specific attributes (``_is_oauth_token``,
|
||||
``_HERMES_OAUTH_FILE``, ``read_claude_code_credentials``, etc.)
|
||||
that core code accesses via the registry.
|
||||
|
||||
Registered providers are queried by core code via
|
||||
``registries.get_auth_provider(name)`` instead of importing
|
||||
directly from ``hermes_agent_*`` packages.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_auth_provider(
|
||||
name, provider,
|
||||
cli_group=cli_group,
|
||||
setup_subcommands=setup_subcommands,
|
||||
)
|
||||
logger.debug(
|
||||
"Plugin %s registered auth provider: %s",
|
||||
self.manifest.name, name,
|
||||
)
|
||||
|
||||
def register_provider_resolver(
|
||||
self,
|
||||
name: str,
|
||||
resolver: Any,
|
||||
) -> None:
|
||||
"""Register a provider resolver callable.
|
||||
|
||||
The resolver handles ALL provider-specific client construction
|
||||
logic for auxiliary tasks. Core's ``resolve_provider_client()``
|
||||
dispatches to it instead of using per-provider if/elif branches.
|
||||
|
||||
Signature::
|
||||
|
||||
def resolver(
|
||||
*,
|
||||
model: str | None,
|
||||
explicit_api_key: str | None,
|
||||
explicit_base_url: str | None,
|
||||
async_mode: bool,
|
||||
is_vision: bool,
|
||||
main_runtime: dict | None,
|
||||
api_mode: str | None,
|
||||
) -> tuple[Any, str] | tuple[None, None]:
|
||||
...
|
||||
|
||||
Returns ``(client, default_model)`` or ``(None, None)``.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_provider_resolver(name, resolver)
|
||||
logger.debug(
|
||||
"Plugin %s registered provider resolver: %s",
|
||||
self.manifest.name, name,
|
||||
)
|
||||
|
||||
def register_transport(
|
||||
self,
|
||||
api_mode: str,
|
||||
transport_cls: type,
|
||||
) -> None:
|
||||
"""Register a ProviderTransport class for an api_mode string.
|
||||
|
||||
This lets the transport registry discover provider transports
|
||||
from plugins without core needing to import the plugin package.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries._transports[api_mode] = transport_cls
|
||||
logger.debug(
|
||||
"Plugin %s registered transport: %s → %s",
|
||||
self.manifest.name, api_mode, transport_cls.__name__,
|
||||
)
|
||||
|
||||
def register_credential_pool_hook(
|
||||
self,
|
||||
name: str,
|
||||
hook: Any,
|
||||
) -> None:
|
||||
"""Register a credential pool hook for provider-specific pool operations.
|
||||
|
||||
The hook should be a :class:`agent.plugin_registries.CredentialPoolHook`
|
||||
instance with optional ``sync_from_credentials_file``,
|
||||
``refresh_oauth``, and ``should_include_in_pool`` callables.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_credential_pool_hook(name, hook)
|
||||
logger.debug(
|
||||
"Plugin %s registered credential pool hook: %s",
|
||||
self.manifest.name, name,
|
||||
)
|
||||
|
||||
def register_pricing_provider(
|
||||
self,
|
||||
name: str,
|
||||
entries: list,
|
||||
) -> None:
|
||||
"""Register pricing entries for a provider.
|
||||
|
||||
``entries`` should be a list of
|
||||
:class:`agent.plugin_registries.PricingEntry` instances.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_pricing_provider(name, entries)
|
||||
logger.debug(
|
||||
"Plugin %s registered pricing provider: %s (%d entries)",
|
||||
self.manifest.name, name, len(entries),
|
||||
)
|
||||
|
||||
def register_provider_overlay(
|
||||
self,
|
||||
entry: Any,
|
||||
) -> None:
|
||||
"""Register a provider overlay entry.
|
||||
|
||||
``entry`` should be a :class:`agent.plugin_registries.ProviderOverlayEntry`
|
||||
instance.
|
||||
"""
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
registries.register_provider_overlay(entry)
|
||||
logger.debug(
|
||||
"Plugin %s registered provider overlay: %s",
|
||||
self.manifest.name, entry.provider_name,
|
||||
)
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
# -- auxiliary task registration ---------------------------------------
|
||||
@@ -1074,6 +1338,11 @@ class PluginManager:
|
||||
)
|
||||
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
|
||||
manifests.extend(bundled_platforms)
|
||||
bundled_providers = self._scan_directory(
|
||||
repo_plugins / "model-providers", source="bundled"
|
||||
)
|
||||
logger.debug(" bundled/model-providers: %d manifest(s)", len(bundled_providers))
|
||||
manifests.extend(bundled_providers)
|
||||
|
||||
# 2. User plugins (~/.hermes/plugins/)
|
||||
user_dir = get_hermes_home() / "plugins"
|
||||
@@ -1110,7 +1379,16 @@ class PluginManager:
|
||||
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
|
||||
winners: Dict[str, PluginManifest] = {}
|
||||
for manifest in manifests:
|
||||
winners[manifest.key or manifest.name] = manifest
|
||||
key = manifest.key or manifest.name
|
||||
existing = winners.get(key)
|
||||
# Bundled/workspace plugins take precedence over entry-points
|
||||
# for the same key — the local source is the one we're
|
||||
# actively developing; the entry-point is the published
|
||||
# version. Only let entry-points fill gaps where no bundled
|
||||
# version exists.
|
||||
if existing is not None and existing.source == "bundled" and manifest.source != "bundled":
|
||||
continue
|
||||
winners[key] = manifest
|
||||
for manifest in winners.values():
|
||||
lookup_key = manifest.key or manifest.name
|
||||
|
||||
@@ -1138,30 +1416,12 @@ class PluginManager:
|
||||
)
|
||||
continue
|
||||
|
||||
# Model provider plugins are loaded by providers/__init__.py
|
||||
# (its own lazy discovery keyed off first get_provider_profile()
|
||||
# call). We record the manifest here for introspection but do
|
||||
# not import the module — a second import would create two
|
||||
# ProviderProfile instances and break the "last writer wins"
|
||||
# override semantics between bundled and user plugins.
|
||||
if manifest.kind == "model-provider":
|
||||
loaded = LoadedPlugin(manifest=manifest, enabled=True)
|
||||
self._plugins[lookup_key] = loaded
|
||||
logger.debug(
|
||||
"Skipping '%s' (model-provider, handled by providers/ discovery)",
|
||||
lookup_key,
|
||||
)
|
||||
continue
|
||||
|
||||
# Built-in backends auto-load — they ship with hermes and must
|
||||
# just work. Selection among them (e.g. which image_gen backend
|
||||
# services calls) is driven by ``<category>.provider`` config,
|
||||
# enforced by the tool wrapper.
|
||||
#
|
||||
# Bundled platform plugins (gateway adapters like IRC) auto-load
|
||||
# for the same reason: every platform Hermes ships must be
|
||||
# available out of the box without the user having to opt in.
|
||||
if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}:
|
||||
# Model provider plugins auto-load just like backends and
|
||||
# platforms. They register their provider services (auth,
|
||||
# transport, metadata) via ctx.register_provider_services()
|
||||
# in their register() function, which populates the
|
||||
# capability registries that core code queries.
|
||||
if manifest.source == "bundled" and manifest.kind in {"backend", "platform", "model-provider"}:
|
||||
self._load_plugin(manifest)
|
||||
continue
|
||||
|
||||
|
||||
@@ -864,12 +864,35 @@ def _discover_memory_providers() -> list[tuple[str, str]]:
|
||||
|
||||
|
||||
def _discover_context_engines() -> list[tuple[str, str]]:
|
||||
"""Return [(name, description), ...] for available context engines."""
|
||||
"""Return [(name, description), ...] for available context engines.
|
||||
|
||||
Includes repo-shipped engines from ``plugins/context_engine/`` AND
|
||||
plugin-registered engines (third-party engines installed as Hermes
|
||||
plugins via ``ctx.register_context_engine``). Repo-shipped descriptions
|
||||
win when a plugin-registered engine collides on name.
|
||||
"""
|
||||
engines: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
try:
|
||||
from plugins.context_engine import discover_context_engines
|
||||
return [(name, desc) for name, desc, _avail in discover_context_engines()]
|
||||
for name, desc, _avail in discover_context_engines():
|
||||
if name not in seen:
|
||||
engines.append((name, desc))
|
||||
seen.add(name)
|
||||
except Exception:
|
||||
return []
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_context_engine
|
||||
discover_plugins()
|
||||
plugin_engine = get_plugin_context_engine()
|
||||
if plugin_engine and getattr(plugin_engine, "name", None) and plugin_engine.name not in seen:
|
||||
engines.append((plugin_engine.name, "installed plugin"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return engines
|
||||
|
||||
|
||||
def _get_current_memory_provider() -> str:
|
||||
|
||||
+40
-17
@@ -99,10 +99,8 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
|
||||
),
|
||||
"anthropic": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
|
||||
),
|
||||
# "anthropic" overlay moved to plugin: hermes_agent_anthropic register()
|
||||
# Plugin registers via ctx.register_provider_overlay() and core merges lazily.
|
||||
"zai": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
|
||||
@@ -204,17 +202,45 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
),
|
||||
# Azure Foundry: supports both OpenAI-style and Anthropic-style endpoints.
|
||||
# The transport is determined at runtime from config.yaml model.api_mode.
|
||||
"azure-foundry": HermesOverlay(
|
||||
transport="openai_chat", # default; overridden by api_mode in config
|
||||
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
|
||||
),
|
||||
"bedrock": HermesOverlay(
|
||||
transport="bedrock_converse",
|
||||
auth_type="aws_sdk",
|
||||
),
|
||||
# "azure-foundry" overlay moved to plugin: hermes_agent_azure register()
|
||||
# "bedrock" overlay moved to plugin: hermes_agent_bedrock register()
|
||||
# Plugins register via ctx.register_provider_overlay() and core merges lazily.
|
||||
}
|
||||
|
||||
|
||||
def _merge_plugin_overlays() -> None:
|
||||
"""Merge plugin-registered provider overlays into HERMES_OVERLAYS.
|
||||
|
||||
Called lazily from ``resolve_provider`` so that plugins have had a
|
||||
chance to register by the time we need the overlay data.
|
||||
"""
|
||||
global _plugin_overlays_merged
|
||||
if _plugin_overlays_merged:
|
||||
return
|
||||
_plugin_overlays_merged = True
|
||||
try:
|
||||
from agent.plugin_registries import registries
|
||||
for _name, _entry in registries.all_provider_overlays().items():
|
||||
if _name not in HERMES_OVERLAYS:
|
||||
HERMES_OVERLAYS[_name] = HermesOverlay(
|
||||
transport=_entry.transport,
|
||||
is_aggregator=_entry.is_aggregator,
|
||||
auth_type=_entry.auth_type,
|
||||
extra_env_vars=_entry.extra_env_vars,
|
||||
base_url_override=_entry.base_url_override,
|
||||
base_url_env_var=_entry.base_url_env_var,
|
||||
)
|
||||
# Also merge aliases from the plugin overlay entry
|
||||
for _alias in _entry.aliases:
|
||||
if _alias not in ALIASES:
|
||||
ALIASES[_alias] = _name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_plugin_overlays_merged = False
|
||||
|
||||
|
||||
# -- Resolved provider -------------------------------------------------------
|
||||
# The merged result of models.dev + overlay + user config.
|
||||
|
||||
@@ -335,11 +361,7 @@ ALIASES: Dict[str, str] = {
|
||||
"tencent-cloud": "tencent-tokenhub",
|
||||
"tencentmaas": "tencent-tokenhub",
|
||||
|
||||
# bedrock
|
||||
"aws": "bedrock",
|
||||
"aws-bedrock": "bedrock",
|
||||
"amazon-bedrock": "bedrock",
|
||||
"amazon": "bedrock",
|
||||
# bedrock aliases moved to plugin: hermes_agent_bedrock register()
|
||||
|
||||
# arcee
|
||||
"arcee-ai": "arcee",
|
||||
@@ -426,6 +448,7 @@ def get_provider(name: str) -> Optional[ProviderDef]:
|
||||
except Exception:
|
||||
mdev_info = None
|
||||
|
||||
_merge_plugin_overlays()
|
||||
overlay = HERMES_OVERLAYS.get(canonical)
|
||||
|
||||
if mdev_info is not None:
|
||||
|
||||
@@ -79,7 +79,7 @@ class XAIGrokAdapter(UpstreamAdapter):
|
||||
failed_credential: UpstreamCredential,
|
||||
status_code: int,
|
||||
) -> Optional[UpstreamCredential]:
|
||||
if status_code != 401:
|
||||
if status_code not in {401, 429}:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
@@ -87,16 +87,25 @@ class XAIGrokAdapter(UpstreamAdapter):
|
||||
if pool is None:
|
||||
return None
|
||||
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is None:
|
||||
if status_code == 429:
|
||||
# Mark the rate-limited key with its 1-hour cooldown and rotate
|
||||
# to the next available credential. Returns None when the pool
|
||||
# has no other key to offer — the 429 will flow back to the client.
|
||||
refreshed = pool.mark_exhausted_and_rotate(status_code=status_code)
|
||||
else:
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is None:
|
||||
refreshed = pool.mark_exhausted_and_rotate(status_code=status_code)
|
||||
if refreshed is None:
|
||||
return None
|
||||
|
||||
retry_cred = self._credential_from_entry(refreshed)
|
||||
if retry_cred.bearer == failed_credential.bearer:
|
||||
return None
|
||||
logger.info("proxy: xAI upstream rejected bearer; retrying with refreshed pool credential")
|
||||
logger.info(
|
||||
"proxy: xAI upstream returned %s; retrying with rotated pool credential",
|
||||
status_code,
|
||||
)
|
||||
return retry_cred
|
||||
|
||||
def _load_pool(self) -> Optional[CredentialPool]:
|
||||
|
||||
@@ -206,7 +206,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
|
||||
return session_or_response
|
||||
session = session_or_response
|
||||
|
||||
if upstream_resp.status == 401:
|
||||
if upstream_resp.status in {401, 429}:
|
||||
try:
|
||||
retry_cred = adapter.get_retry_credential(
|
||||
failed_credential=cred,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Helpers for the temporary psutil-on-Android compatibility installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
# Pin a version we know patches cleanly. Update when a newer psutil
|
||||
# changes the marker line shape and we need to follow upstream.
|
||||
PSUTIL_URL = (
|
||||
"https://files.pythonhosted.org/packages/aa/c6/"
|
||||
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
|
||||
"psutil-7.2.2.tar.gz"
|
||||
)
|
||||
|
||||
MARKER = 'LINUX = sys.platform.startswith("linux")'
|
||||
REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))'
|
||||
|
||||
|
||||
class PsutilAndroidInstallError(RuntimeError):
|
||||
"""Raised when the pinned psutil sdist is missing or unsafe."""
|
||||
|
||||
|
||||
def _normalize_member_parts(member_name: str) -> tuple[str, ...]:
|
||||
path = PurePosixPath(member_name)
|
||||
parts = tuple(part for part in path.parts if part not in ("", "."))
|
||||
if path.is_absolute() or ".." in parts or not parts:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Unsafe archive member path: {member_name!r}"
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
def _safe_extract_tar_gz(archive: Path, destination: Path) -> None:
|
||||
"""Extract a tar.gz without allowing traversal or link members."""
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
for member in tf.getmembers():
|
||||
parts = _normalize_member_parts(member.name)
|
||||
target = destination.joinpath(*parts)
|
||||
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
if not member.isfile():
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Unsupported archive member type: {member.name}"
|
||||
)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
extracted = tf.extractfile(member)
|
||||
if extracted is None:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Cannot read archive member: {member.name}"
|
||||
)
|
||||
|
||||
with extracted, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(extracted, dst)
|
||||
|
||||
try:
|
||||
target.chmod(member.mode & 0o777)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path:
|
||||
"""Safely extract the pinned psutil sdist and patch it for Android."""
|
||||
_safe_extract_tar_gz(archive, destination)
|
||||
|
||||
src_roots = sorted(
|
||||
(
|
||||
path for path in destination.iterdir()
|
||||
if path.is_dir() and path.name.startswith("psutil-")
|
||||
),
|
||||
key=lambda path: path.name,
|
||||
)
|
||||
if not src_roots:
|
||||
raise PsutilAndroidInstallError(
|
||||
"psutil sdist did not contain a psutil-* directory"
|
||||
)
|
||||
|
||||
src_root = src_roots[0]
|
||||
common_py = src_root / "psutil" / "_common.py"
|
||||
if not common_py.is_file():
|
||||
raise PsutilAndroidInstallError(
|
||||
f"psutil sdist did not contain {common_py.relative_to(src_root)!s}"
|
||||
)
|
||||
try:
|
||||
content = common_py.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Failed to read {common_py.relative_to(src_root)!s}"
|
||||
) from exc
|
||||
if MARKER not in content:
|
||||
raise PsutilAndroidInstallError(
|
||||
"psutil Android compatibility patch marker not found"
|
||||
)
|
||||
try:
|
||||
common_py.write_text(
|
||||
content.replace(MARKER, REPLACEMENT),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as exc:
|
||||
raise PsutilAndroidInstallError(
|
||||
f"Failed to write {common_py.relative_to(src_root)!s}"
|
||||
) from exc
|
||||
return src_root
|
||||
@@ -976,11 +976,13 @@ def _resolve_azure_foundry_runtime(
|
||||
auth_mode = "api_key"
|
||||
else:
|
||||
try:
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
build_token_provider,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_azure_ns = registries.get_provider_namespace("azure")
|
||||
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
|
||||
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
|
||||
build_token_provider = _azure_ns.get("build_token_provider")
|
||||
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider]):
|
||||
raise ImportError("azure provider services not fully registered")
|
||||
except Exception as exc:
|
||||
raise AuthError(
|
||||
"Azure Foundry Entra ID auth requires the 'azure-identity' "
|
||||
@@ -1072,7 +1074,11 @@ def _resolve_explicit_runtime(
|
||||
base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com"
|
||||
api_key = explicit_api_key
|
||||
if not api_key:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
|
||||
if resolve_anthropic_token is None:
|
||||
raise ImportError("anthropic provider services not registered")
|
||||
|
||||
api_key = resolve_anthropic_token()
|
||||
if not api_key:
|
||||
@@ -1512,7 +1518,11 @@ def resolve_runtime_provider(
|
||||
"config.yaml model section at a custom env var."
|
||||
)
|
||||
else:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic_ns = registries.get_provider_namespace("anthropic")
|
||||
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
|
||||
if resolve_anthropic_token is None:
|
||||
raise ImportError("anthropic provider services not registered")
|
||||
token = resolve_anthropic_token()
|
||||
if not token:
|
||||
raise AuthError(
|
||||
@@ -1530,12 +1540,14 @@ def resolve_runtime_provider(
|
||||
|
||||
# AWS Bedrock (native Converse API via boto3)
|
||||
if provider == "bedrock":
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
is_anthropic_bedrock_model,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_bedrock_ns = registries.get_provider_namespace("bedrock")
|
||||
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
|
||||
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
|
||||
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
|
||||
is_anthropic_bedrock_model = _bedrock_ns.get("is_anthropic_bedrock_model")
|
||||
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, is_anthropic_bedrock_model]):
|
||||
raise ImportError("bedrock provider services not fully registered")
|
||||
# When the user explicitly selected bedrock (not auto-detected),
|
||||
# trust boto3's credential chain — it handles IMDS, ECS task roles,
|
||||
# Lambda execution roles, SSO, and other implicit sources that our
|
||||
|
||||
@@ -566,8 +566,11 @@ class S6ServiceManager:
|
||||
1. Sources HERMES_HOME (and any extra env) via with-contenv —
|
||||
so e.g. ``-e HERMES_HOME=/data/hermes`` is honored at run
|
||||
time, not Python-substituted at registration time (OQ8-C).
|
||||
2. Activates the bundled venv.
|
||||
3. Drops to the hermes user and exec's
|
||||
2. Resets ``HOME`` to ``/opt/data`` before the privilege drop
|
||||
so with-contenv's root HOME does not leak into the
|
||||
unprivileged gateway process.
|
||||
3. Activates the bundled venv.
|
||||
4. Drops to the hermes user and exec's
|
||||
``hermes -p <profile> gateway run`` (or just ``hermes
|
||||
gateway run`` for the default profile — see below).
|
||||
|
||||
@@ -597,11 +600,20 @@ class S6ServiceManager:
|
||||
"#!/command/with-contenv sh",
|
||||
"# shellcheck shell=sh",
|
||||
"set -e",
|
||||
"export HOME=/opt/data",
|
||||
"cd /opt/data",
|
||||
". /opt/hermes/.venv/bin/activate",
|
||||
]
|
||||
for k, v in sorted(extra_env.items()):
|
||||
lines.append(f"export {k}={shlex.quote(v)}")
|
||||
# Sentinel for the supervised-child path. Prevents recursive
|
||||
# redirect when the supervised gateway re-enters
|
||||
# `_gateway_command_inner` with subcmd == "run" — without it the
|
||||
# supervisor would dispatch `gateway start` which would re-exec
|
||||
# `gateway run --replace` which would re-dispatch `gateway
|
||||
# start`, etc. See `_gateway_command_inner` for the matching
|
||||
# guard.
|
||||
lines.append("export HERMES_S6_SUPERVISED_CHILD=1")
|
||||
if profile == "default":
|
||||
lines.append("exec s6-setuidgid hermes hermes gateway run")
|
||||
else:
|
||||
@@ -620,6 +632,38 @@ class S6ServiceManager:
|
||||
— so a container started with ``-e HERMES_HOME=/data/hermes``
|
||||
gets its logs under /data/hermes/logs/..., not the build-time
|
||||
default.
|
||||
|
||||
Output routing — the script is two action directives, applied
|
||||
per line, in order:
|
||||
|
||||
1. ``1`` (forward to stdout) — propagates the line up the
|
||||
s6-supervise pipeline to /init's stdout, which is the
|
||||
container's stdout, which is ``docker logs``. Without
|
||||
this, supervised stdout would be terminated inside
|
||||
s6-log and never reach the container's log stream;
|
||||
users would have to ``docker exec`` and ``tail`` the
|
||||
file just to see startup banners. (Python's ``logging``
|
||||
module defaults to stderr, which s6-supervise leaves
|
||||
unfiltered — so warnings/errors already reach docker
|
||||
logs. This change is specifically about the rich-console
|
||||
banner output and other plain stdout writes.)
|
||||
2. ``T <log_dir>`` — also write a timestamped copy to the
|
||||
rotated log directory (``current`` + archived ``@*.s``
|
||||
files). This is what ``hermes logs`` reads and what
|
||||
persists across container restarts via the volume mount.
|
||||
|
||||
``T`` is non-sticky: it only prefixes lines for the next
|
||||
action directive. We deliberately put ``T`` between ``1``
|
||||
and the log dir (not before ``1``) so:
|
||||
|
||||
* ``docker logs`` shows raw lines — Python's logging
|
||||
formatter has its own timestamps, and ``docker logs
|
||||
--timestamps`` adds a third layer when desired. No
|
||||
double-stamping in the most common reading path.
|
||||
* The persisted file gets s6-log's own ISO 8601 timestamp
|
||||
so even output that lacked a Python-logger timestamp
|
||||
(rich banners, third-party libs' raw prints) is
|
||||
correlatable in ``current``.
|
||||
"""
|
||||
import shlex
|
||||
prof = shlex.quote(profile)
|
||||
@@ -630,7 +674,7 @@ class S6ServiceManager:
|
||||
f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n'
|
||||
f'mkdir -p "$log_dir"\n'
|
||||
f'chown -R hermes:hermes "$log_dir" 2>/dev/null || true\n'
|
||||
f'exec s6-setuidgid hermes s6-log n10 s1000000 T "$log_dir"\n'
|
||||
f'exec s6-setuidgid hermes s6-log 1 n10 s1000000 T "$log_dir"\n'
|
||||
)
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
+24
-51
@@ -2052,59 +2052,32 @@ def _setup_matrix():
|
||||
save_env_value("MATRIX_ENCRYPTION", "true")
|
||||
print_success("E2EE enabled")
|
||||
|
||||
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
|
||||
# Use the central lazy-deps feature group so we install ALL of
|
||||
# platform.matrix's dependencies (mautrix, Markdown, aiosqlite,
|
||||
# asyncpg, aiohttp-socks) — not just mautrix itself. The previous
|
||||
# hand-rolled ``pip install mautrix[encryption]`` left asyncpg /
|
||||
# aiosqlite uninstalled and broke E2EE connect with
|
||||
# ``No module named 'asyncpg'`` on every fresh install (#31116).
|
||||
matrix_pkg = "hermes-agent[matrix]"
|
||||
# Matrix deps are now a proper plugin package. Install it the normal way.
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
|
||||
_missing_before = feature_missing("platform.matrix")
|
||||
if _missing_before:
|
||||
print_info(
|
||||
f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)..."
|
||||
)
|
||||
try:
|
||||
_lazy_ensure("platform.matrix", prompt=False)
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
except Exception as exc:
|
||||
print_warning(
|
||||
f"Install failed — run manually: pip install "
|
||||
f"'mautrix[encryption]' asyncpg aiosqlite Markdown "
|
||||
f"aiohttp-socks"
|
||||
)
|
||||
print_info(f" Error: {exc}")
|
||||
__import__("hermes_agent_matrix")
|
||||
except ImportError:
|
||||
# tools.lazy_deps unavailable (extreme edge case — partial
|
||||
# install). Fall back to the legacy single-package install
|
||||
# path so the wizard still does *something*.
|
||||
try:
|
||||
__import__("mautrix")
|
||||
except ImportError:
|
||||
print_info(f"Installing {matrix_pkg}...")
|
||||
import subprocess
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
print_warning(
|
||||
f"Install failed — run manually: pip install "
|
||||
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
|
||||
)
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
print_info(f"Installing {matrix_pkg}...")
|
||||
import subprocess
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
print_warning(
|
||||
f"Install failed — run manually: pip install '{matrix_pkg}'"
|
||||
)
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
|
||||
+96
-12
@@ -58,7 +58,9 @@ def _resolve_short_name(name: str, sources, console: Console) -> str:
|
||||
table = Table()
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Trust", style="dim")
|
||||
table.add_column("Identifier", style="bold cyan")
|
||||
# overflow="fold" keeps the full slug visible (wraps instead of ellipsis-truncating)
|
||||
# so users can copy it for `hermes skills install`.
|
||||
table.add_column("Identifier", style="bold cyan", overflow="fold", no_wrap=False)
|
||||
for r in exact:
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim")
|
||||
trust_label = "official" if r.source == "official" else r.trust_level
|
||||
@@ -244,15 +246,39 @@ def _prompt_for_category(c: Console, existing: List[str]) -> str:
|
||||
|
||||
|
||||
def do_search(query: str, source: str = "all", limit: int = 10,
|
||||
console: Optional[Console] = None) -> None:
|
||||
"""Search registries and display results as a Rich table."""
|
||||
console: Optional[Console] = None, as_json: bool = False) -> None:
|
||||
"""Search registries and display results as a Rich table.
|
||||
|
||||
When ``as_json=True`` writes a JSON array of result records to stdout
|
||||
(one object per skill: ``name``, ``identifier``, ``source``,
|
||||
``trust_level``, ``description``) and skips the table render. This is
|
||||
the scripting / copy-paste handle: the full identifier is always
|
||||
intact, even for browse-sh slugs that the table would otherwise wrap.
|
||||
"""
|
||||
from tools.skills_hub import GitHubAuth, create_source_router, unified_search
|
||||
|
||||
c = console or _console
|
||||
c.print(f"\n[bold]Searching for:[/] {query}")
|
||||
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
if as_json:
|
||||
# Avoid Rich status spinner contaminating stdout — JSON consumers
|
||||
# expect a clean parseable stream.
|
||||
results = unified_search(query, sources, source_filter=source, limit=limit)
|
||||
payload = [
|
||||
{
|
||||
"name": r.name,
|
||||
"identifier": r.identifier,
|
||||
"source": r.source,
|
||||
"trust_level": r.trust_level,
|
||||
"description": r.description,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
c.print(f"\n[bold]Searching for:[/] {query}")
|
||||
with c.status("[bold]Searching registries..."):
|
||||
results = unified_search(query, sources, source_filter=source, limit=limit)
|
||||
|
||||
@@ -265,7 +291,11 @@ def do_search(query: str, source: str = "all", limit: int = 10,
|
||||
table.add_column("Description", max_width=60)
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Trust", style="dim")
|
||||
table.add_column("Identifier", style="dim")
|
||||
# overflow="fold" keeps the full slug visible (wraps instead of
|
||||
# ellipsis-truncating). Browse.sh slugs end in a `-XXXXXX` hash that
|
||||
# is part of the actual identifier — truncating it makes copy-paste
|
||||
# into `hermes skills install` fail.
|
||||
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)
|
||||
|
||||
for r in results:
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim")
|
||||
@@ -280,7 +310,8 @@ def do_search(query: str, source: str = "all", limit: int = 10,
|
||||
|
||||
c.print(table)
|
||||
c.print("[dim]Use: hermes skills inspect <identifier> to preview, "
|
||||
"hermes skills install <identifier> to install[/]\n")
|
||||
"hermes skills install <identifier> to install "
|
||||
"(--json for scripting)[/]\n")
|
||||
|
||||
|
||||
def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
@@ -519,11 +550,13 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
if bundle.source == "url" and not category and not skip_confirm:
|
||||
category = _prompt_for_category(c, _existing_categories())
|
||||
|
||||
# Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox")
|
||||
# Auto-detect the full parent path for official skills. Optional skills
|
||||
# can be nested (e.g. "official/mlops/training/trl-fine-tuning"), so keep
|
||||
# every identifier segment between "official" and the final skill slug.
|
||||
if bundle.source == "official" and not category:
|
||||
id_parts = bundle.identifier.split("/") # ["official", "category", "skill"]
|
||||
id_parts = bundle.identifier.split("/")
|
||||
if len(id_parts) >= 3:
|
||||
category = id_parts[1]
|
||||
category = "/".join(id_parts[1:-1])
|
||||
|
||||
# Check if already installed
|
||||
lock = HubLockFile()
|
||||
@@ -1039,6 +1072,48 @@ def do_reset(name: str, restore: bool = False,
|
||||
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
|
||||
|
||||
|
||||
def do_repair_official(name: str, restore: bool = False,
|
||||
console: Optional[Console] = None,
|
||||
skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Backfill or restore official optional skills from repo source."""
|
||||
from tools.skills_sync import restore_official_optional_skill
|
||||
|
||||
c = console or _console
|
||||
if restore and not skip_confirm:
|
||||
c.print(f"\n[bold]Restore official optional skill '{name}' from repo source?[/]")
|
||||
c.print("[dim]Existing matching active copies will be moved to a restore backup before copying the official source.[/]")
|
||||
try:
|
||||
answer = input("Confirm [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
if answer not in {"y", "yes"}:
|
||||
c.print("[dim]Cancelled.[/]\n")
|
||||
return
|
||||
|
||||
result = restore_official_optional_skill(name, restore=restore)
|
||||
if not result.get("ok"):
|
||||
c.print(f"[bold red]Error:[/] {result.get('message', 'Repair failed')}\n")
|
||||
return
|
||||
|
||||
c.print(f"[bold green]{result['message']}[/]")
|
||||
if result.get("restored"):
|
||||
c.print(f"[dim]Restored: {', '.join(result['restored'])}[/]")
|
||||
if result.get("backfilled"):
|
||||
c.print(f"[dim]Backfilled provenance: {', '.join(result['backfilled'])}[/]")
|
||||
if result.get("backed_up"):
|
||||
c.print(f"[dim]Backed up: {', '.join(result['backed_up'])}[/]")
|
||||
c.print(f"[dim]Backup dir: {result.get('backup_dir')}[/]")
|
||||
c.print()
|
||||
|
||||
if invalidate_cache:
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> None:
|
||||
"""Manage taps (custom GitHub repo sources)."""
|
||||
from tools.skills_hub import TapsManager
|
||||
@@ -1346,7 +1421,8 @@ def skills_command(args) -> None:
|
||||
if action == "browse":
|
||||
do_browse(page=args.page, page_size=args.size, source=args.source)
|
||||
elif action == "search":
|
||||
do_search(args.query, source=args.source, limit=args.limit)
|
||||
do_search(args.query, source=args.source, limit=args.limit,
|
||||
as_json=getattr(args, "json", False))
|
||||
elif action == "install":
|
||||
do_install(args.identifier, category=args.category, force=args.force,
|
||||
skip_confirm=getattr(args, "yes", False),
|
||||
@@ -1370,6 +1446,9 @@ def skills_command(args) -> None:
|
||||
elif action == "reset":
|
||||
do_reset(args.name, restore=getattr(args, "restore", False),
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
elif action == "repair-official":
|
||||
do_repair_official(args.name, restore=getattr(args, "restore", False),
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
elif action == "publish":
|
||||
do_publish(
|
||||
args.skill_path,
|
||||
@@ -1464,10 +1543,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "search":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills search <query> [--source skills-sh|well-known|github|official] [--limit N]\n")
|
||||
c.print("[bold red]Usage:[/] /skills search <query> [--source skills-sh|well-known|github|official] [--limit N] [--json]\n")
|
||||
return
|
||||
source = "all"
|
||||
limit = 10
|
||||
as_json = False
|
||||
query_parts = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
@@ -1480,10 +1560,14 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
i += 2
|
||||
elif args[i] == "--json":
|
||||
as_json = True
|
||||
i += 1
|
||||
else:
|
||||
query_parts.append(args[i])
|
||||
i += 1
|
||||
do_search(" ".join(query_parts), source=source, limit=limit, console=c)
|
||||
do_search(" ".join(query_parts), source=source, limit=limit,
|
||||
console=c, as_json=as_json)
|
||||
|
||||
elif action == "install":
|
||||
if not args:
|
||||
|
||||
+49
-14
@@ -16,6 +16,10 @@ from hermes_cli.auth import AuthError, resolve_provider
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
|
||||
from hermes_cli.models import provider_label
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
@@ -193,26 +197,57 @@ def show_status(args):
|
||||
qwen_status = {}
|
||||
minimax_status = {}
|
||||
|
||||
nous_logged_in = bool(nous_status.get("logged_in"))
|
||||
nous_account_info = None
|
||||
if (
|
||||
nous_status.get("logged_in")
|
||||
or nous_status.get("access_token")
|
||||
or nous_status.get("portal_base_url")
|
||||
or nous_status.get("inference_credential_present")
|
||||
or nous_status.get("error_code")
|
||||
):
|
||||
try:
|
||||
nous_account_info = get_nous_portal_account_info()
|
||||
except Exception:
|
||||
nous_account_info = None
|
||||
|
||||
nous_logged_in = bool(
|
||||
nous_status.get("logged_in")
|
||||
or (nous_account_info and nous_account_info.logged_in)
|
||||
)
|
||||
nous_inference_present = bool(
|
||||
nous_status.get("inference_credential_present")
|
||||
or (nous_account_info and nous_account_info.inference_credential_present)
|
||||
)
|
||||
nous_error = nous_status.get("error")
|
||||
nous_label = "logged in" if nous_logged_in else "not logged in (run: hermes auth add nous --type oauth)"
|
||||
if nous_logged_in:
|
||||
nous_label = "logged in"
|
||||
elif nous_inference_present:
|
||||
nous_label = "not logged in (Nous inference key configured)"
|
||||
else:
|
||||
nous_label = "not logged in (run: hermes auth add nous --type oauth)"
|
||||
print(
|
||||
f" {'Nous Portal':<12} {check_mark(nous_logged_in)} "
|
||||
f"{nous_label}"
|
||||
)
|
||||
portal_url = nous_status.get("portal_base_url") or "(unknown)"
|
||||
inference_url = (
|
||||
nous_status.get("inference_base_url")
|
||||
or (nous_account_info.inference_base_url if nous_account_info else None)
|
||||
)
|
||||
access_exp = _format_iso_timestamp(nous_status.get("access_expires_at"))
|
||||
key_exp = _format_iso_timestamp(nous_status.get("agent_key_expires_at"))
|
||||
refresh_label = "yes" if nous_status.get("has_refresh_token") else "no"
|
||||
if nous_logged_in or portal_url != "(unknown)" or nous_error:
|
||||
print(f" Portal URL: {portal_url}")
|
||||
if nous_inference_present and inference_url:
|
||||
print(f" Inference: {inference_url}")
|
||||
if nous_logged_in or nous_status.get("access_expires_at"):
|
||||
print(f" Access exp: {access_exp}")
|
||||
if nous_logged_in or nous_status.get("agent_key_expires_at"):
|
||||
if nous_logged_in or nous_inference_present or nous_status.get("agent_key_expires_at"):
|
||||
print(f" Key exp: {key_exp}")
|
||||
if nous_logged_in or nous_status.get("has_refresh_token"):
|
||||
print(f" Refresh: {refresh_label}")
|
||||
if nous_error and not nous_logged_in:
|
||||
if nous_error:
|
||||
print(f" Error: {nous_error}")
|
||||
|
||||
codex_logged_in = bool(codex_status.get("logged_in"))
|
||||
@@ -303,18 +338,18 @@ def show_status(args):
|
||||
else:
|
||||
state = "not configured"
|
||||
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
|
||||
elif nous_logged_in:
|
||||
# Logged into Nous but on the free tier — show upgrade nudge
|
||||
elif nous_logged_in or nous_inference_present:
|
||||
# Nous OAuth without entitlement, or an opaque inference key without
|
||||
# Portal account information, cannot enable the Tool Gateway.
|
||||
print()
|
||||
print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD))
|
||||
print(" Your free-tier Nous account does not include Tool Gateway access.")
|
||||
print(" Upgrade your subscription to unlock managed web, image, TTS, and browser tools.")
|
||||
try:
|
||||
portal_url = nous_status.get("portal_base_url", "").rstrip("/")
|
||||
if portal_url:
|
||||
print(f" Upgrade: {portal_url}")
|
||||
except Exception:
|
||||
pass
|
||||
message = format_nous_portal_entitlement_message(
|
||||
nous_account_info,
|
||||
capability="managed web, image, TTS, browser, and Modal tools",
|
||||
)
|
||||
if message:
|
||||
for line in message.splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
# =========================================================================
|
||||
# API-Key Providers
|
||||
|
||||
+252
-53
@@ -28,7 +28,8 @@ from hermes_cli.nous_subscription import (
|
||||
apply_nous_managed_defaults,
|
||||
get_nous_subscription_features,
|
||||
)
|
||||
from tools.tool_backend_helpers import fal_key_is_configured, managed_nous_tools_enabled
|
||||
from hermes_cli.nous_account import format_nous_portal_entitlement_message
|
||||
from tools.tool_backend_helpers import fal_key_is_configured
|
||||
from utils import base_url_hostname, is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -67,6 +68,7 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("skills", "📚 Skills", "list, view, manage"),
|
||||
("todo", "📋 Task Planning", "todo"),
|
||||
("memory", "💾 Memory", "persistent memory across sessions"),
|
||||
("context_engine", "🧩 Context Engine", "runtime tools from the active context engine"),
|
||||
("session_search", "🔎 Session Search", "search past conversations"),
|
||||
("clarify", "❓ Clarifying Questions", "clarify"),
|
||||
("delegation", "👥 Task Delegation", "delegate_task"),
|
||||
@@ -1294,6 +1296,24 @@ def _get_platform_tools(
|
||||
enabled_toolsets.add(pts)
|
||||
# else: known but not in config = user disabled it
|
||||
|
||||
# Context-engine tools are runtime-provided by the active engine, so they
|
||||
# are not part of any static platform composite. When a non-default engine
|
||||
# is selected, keep its recovery/status tools available even after a user
|
||||
# saves an explicit platform toolset list. Preserve the explicit empty-list
|
||||
# contract: selecting no configurable tools means no context-engine tools
|
||||
# either unless the user adds ``context_engine`` manually later.
|
||||
context_cfg = config.get("context") or {}
|
||||
if not isinstance(context_cfg, dict):
|
||||
context_cfg = {}
|
||||
context_engine_name = str(context_cfg.get("engine") or "compressor").strip().lower()
|
||||
explicit_empty_selection = (
|
||||
platform in platform_toolsets
|
||||
and isinstance(platform_toolsets.get(platform), list)
|
||||
and not toolset_names
|
||||
)
|
||||
if context_engine_name and context_engine_name != "compressor" and not explicit_empty_selection:
|
||||
enabled_toolsets.add("context_engine")
|
||||
|
||||
# Preserve any explicit non-configurable toolset entries (for example,
|
||||
# custom toolsets or MCP server names saved in platform_toolsets).
|
||||
explicit_passthrough = {
|
||||
@@ -1399,7 +1419,12 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
|
||||
save_config(config)
|
||||
|
||||
|
||||
def _toolset_has_keys(ts_key: str, config: dict = None) -> bool:
|
||||
def _toolset_has_keys(
|
||||
ts_key: str,
|
||||
config: dict = None,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> bool:
|
||||
"""Check if a toolset's required API keys are configured."""
|
||||
if config is None:
|
||||
config = load_config()
|
||||
@@ -1414,7 +1439,7 @@ def _toolset_has_keys(ts_key: str, config: dict = None) -> bool:
|
||||
return False
|
||||
|
||||
if ts_key in {"web", "image_gen", "tts", "browser"}:
|
||||
features = get_nous_subscription_features(config)
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
feature = features.features.get(ts_key)
|
||||
if feature and (feature.available or feature.managed_by_nous):
|
||||
return True
|
||||
@@ -1422,7 +1447,7 @@ def _toolset_has_keys(ts_key: str, config: dict = None) -> bool:
|
||||
# Check TOOL_CATEGORIES first (provider-aware)
|
||||
cat = TOOL_CATEGORIES.get(ts_key)
|
||||
if cat:
|
||||
for provider in _visible_providers(cat, config):
|
||||
for provider in _visible_providers(cat, config, force_fresh=force_fresh):
|
||||
env_vars = provider.get("env_vars", [])
|
||||
if not env_vars:
|
||||
return True # No-key provider (e.g. Local Browser, Edge TTS)
|
||||
@@ -1493,7 +1518,13 @@ def _estimate_tool_tokens() -> Dict[str, int]:
|
||||
return _tool_token_cache
|
||||
|
||||
|
||||
def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform: str = "cli") -> Set[str]:
|
||||
def _prompt_toolset_checklist(
|
||||
platform_label: str,
|
||||
enabled: Set[str],
|
||||
platform: str = "cli",
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
) -> Set[str]:
|
||||
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
from toolsets import resolve_toolset
|
||||
@@ -1511,7 +1542,10 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform:
|
||||
labels = []
|
||||
for ts_key, ts_label, ts_desc in effective:
|
||||
suffix = ""
|
||||
if not _toolset_has_keys(ts_key) and (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
|
||||
if (
|
||||
not _toolset_has_keys(ts_key, force_fresh=force_fresh)
|
||||
and (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key))
|
||||
):
|
||||
suffix = " [no API key]"
|
||||
labels.append(f"{ts_label} ({ts_desc}){suffix}")
|
||||
|
||||
@@ -1547,7 +1581,12 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform:
|
||||
|
||||
# ─── Provider-Aware Configuration ────────────────────────────────────────────
|
||||
|
||||
def _configure_toolset(ts_key: str, config: dict):
|
||||
def _configure_toolset(
|
||||
ts_key: str,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
):
|
||||
"""Configure a toolset - provider selection + API keys.
|
||||
|
||||
Uses TOOL_CATEGORIES for provider-aware config, falls back to simple
|
||||
@@ -1556,7 +1595,7 @@ def _configure_toolset(ts_key: str, config: dict):
|
||||
cat = TOOL_CATEGORIES.get(ts_key)
|
||||
|
||||
if cat:
|
||||
_configure_tool_category(ts_key, cat, config)
|
||||
_configure_tool_category(ts_key, cat, config, force_fresh=force_fresh)
|
||||
else:
|
||||
# Simple fallback for vision, moa, etc.
|
||||
_configure_simple_requirements(ts_key)
|
||||
@@ -1809,12 +1848,22 @@ def _plugin_tts_providers() -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def _visible_providers(cat: dict, config: dict) -> list[dict]:
|
||||
def _visible_providers(
|
||||
cat: dict,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Return provider entries visible for the current auth/config state."""
|
||||
features = get_nous_subscription_features(config)
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
managed_available = bool(
|
||||
features.account_info
|
||||
and features.account_info.logged_in
|
||||
and features.account_info.paid_service_access is True
|
||||
)
|
||||
visible = []
|
||||
for provider in cat.get("providers", []):
|
||||
if provider.get("managed_nous_feature") and not managed_nous_tools_enabled():
|
||||
if provider.get("managed_nous_feature") and not managed_available:
|
||||
continue
|
||||
if provider.get("requires_nous_auth") and not features.nous_auth_present:
|
||||
continue
|
||||
@@ -1855,6 +1904,31 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
|
||||
return visible
|
||||
|
||||
|
||||
def _hidden_nous_gateway_message(
|
||||
cat: dict,
|
||||
config: dict,
|
||||
capability: str,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> str:
|
||||
"""Return a reason when a category's Nous provider is hidden."""
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
managed_available = bool(
|
||||
features.account_info
|
||||
and features.account_info.logged_in
|
||||
and features.account_info.paid_service_access is True
|
||||
)
|
||||
if managed_available:
|
||||
return ""
|
||||
if not any(p.get("managed_nous_feature") for p in cat.get("providers", [])):
|
||||
return ""
|
||||
message = format_nous_portal_entitlement_message(
|
||||
features.account_info,
|
||||
capability=capability,
|
||||
)
|
||||
return message or ""
|
||||
|
||||
|
||||
_POST_SETUP_INSTALLED: dict = {
|
||||
# post_setup_key -> predicate(): True when the install side-effect
|
||||
# is already satisfied. Used by `_toolset_needs_configuration_prompt`
|
||||
@@ -1886,17 +1960,22 @@ def _post_setup_already_installed(post_setup_key: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
|
||||
def _toolset_needs_configuration_prompt(
|
||||
ts_key: str,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> bool:
|
||||
"""Return True when enabling this toolset should open provider setup."""
|
||||
cat = TOOL_CATEGORIES.get(ts_key)
|
||||
if not cat:
|
||||
return not _toolset_has_keys(ts_key, config)
|
||||
return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh)
|
||||
|
||||
# If any visible provider has a registered post_setup install-state
|
||||
# check that hasn't been satisfied (e.g. cua-driver binary not on
|
||||
# PATH yet), force the configuration flow so `_configure_provider`
|
||||
# invokes `_run_post_setup` and the install actually runs.
|
||||
for provider in _visible_providers(cat, config):
|
||||
for provider in _visible_providers(cat, config, force_fresh=force_fresh):
|
||||
post_setup = provider.get("post_setup")
|
||||
if post_setup and not _post_setup_already_installed(post_setup):
|
||||
return True
|
||||
@@ -1947,14 +2026,26 @@ def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
|
||||
pass
|
||||
return True
|
||||
|
||||
return not _toolset_has_keys(ts_key, config)
|
||||
return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh)
|
||||
|
||||
|
||||
def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
def _configure_tool_category(
|
||||
ts_key: str,
|
||||
cat: dict,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
):
|
||||
"""Configure a tool category with provider selection."""
|
||||
icon = cat.get("icon", "")
|
||||
name = cat["name"]
|
||||
providers = _visible_providers(cat, config)
|
||||
providers = _visible_providers(cat, config, force_fresh=force_fresh)
|
||||
hidden_nous_message = _hidden_nous_gateway_message(
|
||||
cat,
|
||||
config,
|
||||
f"the Nous Subscription provider for {name}",
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
|
||||
# Check Python version requirement
|
||||
if cat.get("requires_python"):
|
||||
@@ -1975,7 +2066,10 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
# For single-provider tools, show a note if available
|
||||
if cat.get("setup_note"):
|
||||
_print_info(f" {cat['setup_note']}")
|
||||
_configure_provider(provider, config)
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
_configure_provider(provider, config, force_fresh=force_fresh)
|
||||
else:
|
||||
# Multiple providers - let user choose
|
||||
print()
|
||||
@@ -1984,6 +2078,9 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN))
|
||||
if cat.get("setup_note"):
|
||||
_print_info(f" {cat['setup_note']}")
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
print()
|
||||
|
||||
# Plain text labels only (no ANSI codes in menu items)
|
||||
@@ -1992,7 +2089,10 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
# obvious which options cost extra vs. cost nothing on top of Nous.
|
||||
try:
|
||||
_nous_logged_in = bool(
|
||||
get_nous_subscription_features(config).nous_auth_present
|
||||
get_nous_subscription_features(
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
).nous_auth_present
|
||||
)
|
||||
except Exception:
|
||||
_nous_logged_in = False
|
||||
@@ -2004,7 +2104,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
if _is_provider_active(p, config):
|
||||
if _is_provider_active(p, config, force_fresh=force_fresh):
|
||||
configured = " [active]"
|
||||
elif not env_vars:
|
||||
configured = ""
|
||||
@@ -2024,7 +2124,11 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
provider_choices.append("Skip — keep defaults / configure later")
|
||||
|
||||
# Detect current provider as default
|
||||
default_idx = _detect_active_provider_index(providers, config)
|
||||
default_idx = _detect_active_provider_index(
|
||||
providers,
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
|
||||
provider_idx = _prompt_choice(f" {title}:", provider_choices, default_idx)
|
||||
|
||||
@@ -2033,10 +2137,15 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
_print_info(f" Skipped {name}")
|
||||
return
|
||||
|
||||
_configure_provider(providers[provider_idx], config)
|
||||
_configure_provider(providers[provider_idx], config, force_fresh=force_fresh)
|
||||
|
||||
|
||||
def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
def _is_provider_active(
|
||||
provider: dict,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> bool:
|
||||
"""Check if a provider entry matches the currently active config."""
|
||||
plugin_name = provider.get("image_gen_plugin_name")
|
||||
if plugin_name:
|
||||
@@ -2050,7 +2159,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
|
||||
managed_feature = provider.get("managed_nous_feature")
|
||||
if managed_feature:
|
||||
features = get_nous_subscription_features(config)
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
feature = features.features.get(managed_feature)
|
||||
if feature is None:
|
||||
return False
|
||||
@@ -2097,10 +2206,15 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _detect_active_provider_index(providers: list, config: dict) -> int:
|
||||
def _detect_active_provider_index(
|
||||
providers: list,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> int:
|
||||
"""Return the index of the currently active provider, or 0."""
|
||||
for i, p in enumerate(providers):
|
||||
if _is_provider_active(p, config):
|
||||
if _is_provider_active(p, config, force_fresh=force_fresh):
|
||||
return i
|
||||
# Fallback: env vars present → likely configured
|
||||
env_vars = p.get("env_vars", [])
|
||||
@@ -2403,15 +2517,29 @@ def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None:
|
||||
_configure_videogen_model_for_plugin(plugin_name, config)
|
||||
|
||||
|
||||
def _configure_provider(provider: dict, config: dict):
|
||||
def _configure_provider(
|
||||
provider: dict,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
):
|
||||
"""Configure a single provider - prompt for API keys and set config."""
|
||||
env_vars = provider.get("env_vars", [])
|
||||
managed_feature = provider.get("managed_nous_feature")
|
||||
|
||||
if provider.get("requires_nous_auth"):
|
||||
features = get_nous_subscription_features(config)
|
||||
if not features.nous_auth_present:
|
||||
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
entitled = bool(
|
||||
features.account_info and features.account_info.paid_service_access is True
|
||||
)
|
||||
if not features.nous_auth_present or not entitled:
|
||||
message = format_nous_portal_entitlement_message(
|
||||
features.account_info,
|
||||
capability=f"{provider.get('name', 'Nous Subscription')}",
|
||||
)
|
||||
_print_warning(
|
||||
f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}"
|
||||
)
|
||||
return
|
||||
|
||||
# Set TTS provider in config if applicable
|
||||
@@ -2501,7 +2629,10 @@ def _configure_provider(provider: dict, config: dict):
|
||||
_has_managed_sibling = True
|
||||
break
|
||||
if _has_managed_sibling:
|
||||
_features = get_nous_subscription_features(config)
|
||||
_features = get_nous_subscription_features(
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
_show_portal_hint = not _features.nous_auth_present
|
||||
except Exception:
|
||||
_show_portal_hint = False
|
||||
@@ -2619,7 +2750,11 @@ def _configure_simple_requirements(ts_key: str):
|
||||
_print_warning(" Skipped")
|
||||
|
||||
|
||||
def _reconfigure_tool(config: dict):
|
||||
def _reconfigure_tool(
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
):
|
||||
"""Let user reconfigure an existing tool's provider or API key."""
|
||||
# Build list of configurable tools that are currently set up
|
||||
configurable = []
|
||||
@@ -2627,7 +2762,10 @@ def _reconfigure_tool(config: dict):
|
||||
cat = TOOL_CATEGORIES.get(ts_key)
|
||||
reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
|
||||
if cat or reqs:
|
||||
if _toolset_has_keys(ts_key, config) or _toolset_enabled_for_reconfigure(ts_key, config):
|
||||
if (
|
||||
_toolset_has_keys(ts_key, config, force_fresh=force_fresh)
|
||||
or _toolset_enabled_for_reconfigure(ts_key, config)
|
||||
):
|
||||
configurable.append((ts_key, ts_label))
|
||||
|
||||
if not configurable:
|
||||
@@ -2646,7 +2784,12 @@ def _reconfigure_tool(config: dict):
|
||||
cat = TOOL_CATEGORIES.get(ts_key)
|
||||
|
||||
if cat:
|
||||
_configure_tool_category_for_reconfig(ts_key, cat, config)
|
||||
_configure_tool_category_for_reconfig(
|
||||
ts_key,
|
||||
cat,
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
else:
|
||||
_reconfigure_simple_requirements(ts_key)
|
||||
|
||||
@@ -2675,20 +2818,38 @@ def _toolset_enabled_for_reconfigure(ts_key: str, config: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
def _configure_tool_category_for_reconfig(
|
||||
ts_key: str,
|
||||
cat: dict,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
):
|
||||
"""Reconfigure a tool category - provider selection + API key update."""
|
||||
icon = cat.get("icon", "")
|
||||
name = cat["name"]
|
||||
providers = _visible_providers(cat, config)
|
||||
providers = _visible_providers(cat, config, force_fresh=force_fresh)
|
||||
hidden_nous_message = _hidden_nous_gateway_message(
|
||||
cat,
|
||||
config,
|
||||
f"the Nous Subscription provider for {name}",
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
|
||||
if len(providers) == 1:
|
||||
provider = providers[0]
|
||||
print()
|
||||
print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN))
|
||||
_reconfigure_provider(provider, config)
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
_reconfigure_provider(provider, config, force_fresh=force_fresh)
|
||||
else:
|
||||
print()
|
||||
print(color(f" --- {icon} {name} - Choose a provider ---", Colors.CYAN))
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
print()
|
||||
|
||||
provider_choices = []
|
||||
@@ -2698,7 +2859,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
if _is_provider_active(p, config):
|
||||
if _is_provider_active(p, config, force_fresh=force_fresh):
|
||||
configured = " [active]"
|
||||
elif not env_vars:
|
||||
configured = ""
|
||||
@@ -2706,21 +2867,43 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{badge}{tag}{configured}")
|
||||
|
||||
default_idx = _detect_active_provider_index(providers, config)
|
||||
default_idx = _detect_active_provider_index(
|
||||
providers,
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
|
||||
provider_idx = _prompt_choice(" Select provider:", provider_choices, default_idx)
|
||||
_reconfigure_provider(providers[provider_idx], config)
|
||||
_reconfigure_provider(
|
||||
providers[provider_idx],
|
||||
config,
|
||||
force_fresh=force_fresh,
|
||||
)
|
||||
|
||||
|
||||
def _reconfigure_provider(provider: dict, config: dict):
|
||||
def _reconfigure_provider(
|
||||
provider: dict,
|
||||
config: dict,
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
):
|
||||
"""Reconfigure a provider - update API keys."""
|
||||
env_vars = provider.get("env_vars", [])
|
||||
managed_feature = provider.get("managed_nous_feature")
|
||||
|
||||
if provider.get("requires_nous_auth"):
|
||||
features = get_nous_subscription_features(config)
|
||||
if not features.nous_auth_present:
|
||||
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
entitled = bool(
|
||||
features.account_info and features.account_info.paid_service_access is True
|
||||
)
|
||||
if not features.nous_auth_present or not entitled:
|
||||
message = format_nous_portal_entitlement_message(
|
||||
features.account_info,
|
||||
capability=f"{provider.get('name', 'Nous Subscription')}",
|
||||
)
|
||||
_print_warning(
|
||||
f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}"
|
||||
)
|
||||
return
|
||||
|
||||
if provider.get("tts_provider"):
|
||||
@@ -2921,11 +3104,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
auto_configured = apply_nous_managed_defaults(
|
||||
config,
|
||||
enabled_toolsets=new_enabled,
|
||||
force_fresh=True,
|
||||
)
|
||||
if managed_nous_tools_enabled():
|
||||
for ts_key in sorted(auto_configured):
|
||||
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
|
||||
print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN))
|
||||
for ts_key in sorted(auto_configured):
|
||||
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
|
||||
print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN))
|
||||
|
||||
# Walk through ALL selected tools that have provider options or
|
||||
# need API keys. This ensures browser (Local vs Browserbase),
|
||||
@@ -2993,7 +3176,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
|
||||
# "Reconfigure" selected
|
||||
if idx == _reconfig_idx:
|
||||
_reconfigure_tool(config)
|
||||
_reconfigure_tool(config, force_fresh=True)
|
||||
print()
|
||||
continue
|
||||
|
||||
@@ -3009,7 +3192,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
all_current = set()
|
||||
for pk in platform_keys:
|
||||
all_current |= _get_platform_tools(config, pk, include_default_mcp_servers=False)
|
||||
new_enabled = _prompt_toolset_checklist("All platforms", all_current)
|
||||
new_enabled = _prompt_toolset_checklist(
|
||||
"All platforms",
|
||||
all_current,
|
||||
force_fresh=True,
|
||||
)
|
||||
if new_enabled != all_current:
|
||||
for pk in platform_keys:
|
||||
prev = _get_platform_tools(config, pk, include_default_mcp_servers=False)
|
||||
@@ -3027,7 +3214,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
# Configure API keys for newly enabled tools
|
||||
for ts_key in sorted(added):
|
||||
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
|
||||
if _toolset_needs_configuration_prompt(ts_key, config):
|
||||
if _toolset_needs_configuration_prompt(
|
||||
ts_key,
|
||||
config,
|
||||
force_fresh=True,
|
||||
):
|
||||
_configure_toolset(ts_key, config)
|
||||
_save_platform_tools(config, pk, new_enabled)
|
||||
save_config(config)
|
||||
@@ -3049,7 +3240,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
|
||||
|
||||
# Show checklist
|
||||
new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled)
|
||||
new_enabled = _prompt_toolset_checklist(
|
||||
pinfo["label"],
|
||||
current_enabled,
|
||||
force_fresh=True,
|
||||
)
|
||||
|
||||
if new_enabled != current_enabled:
|
||||
added = new_enabled - current_enabled
|
||||
@@ -3067,7 +3262,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
# Configure newly enabled toolsets that need API keys
|
||||
for ts_key in sorted(added):
|
||||
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
|
||||
if _toolset_needs_configuration_prompt(ts_key, config):
|
||||
if _toolset_needs_configuration_prompt(
|
||||
ts_key,
|
||||
config,
|
||||
force_fresh=True,
|
||||
):
|
||||
_configure_toolset(ts_key, config)
|
||||
|
||||
_save_platform_tools(config, pkey, new_enabled)
|
||||
|
||||
+5
-1
@@ -779,7 +779,9 @@ def speak_text(text: str) -> None:
|
||||
_debug(f"speak_text: TTS begin (paused_recording={paused_recording})")
|
||||
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
from agent.plugin_registries import registries
|
||||
_tts = registries.get_tool_provider("tts")
|
||||
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
|
||||
|
||||
tts_text = text[:4000] if len(text) > 4000 else text
|
||||
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
|
||||
@@ -806,6 +808,8 @@ def speak_text(text: str) -> None:
|
||||
f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
|
||||
)
|
||||
|
||||
if text_to_speech_tool is None:
|
||||
raise ImportError("TTS plugin not registered")
|
||||
_debug(f"speak_text: synthesizing {len(tts_text)} chars -> {mp3_path}")
|
||||
text_to_speech_tool(text=tts_text, output_path=mp3_path)
|
||||
|
||||
|
||||
+34
-32
@@ -58,22 +58,10 @@ try:
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
# First try lazy-installing the dashboard extras. Only the user actually
|
||||
# running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
|
||||
# them out of every other install path. After install, re-import.
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("tool.dashboard", prompt=False)
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except Exception:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
||||
)
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
"Install with: pip install 'hermes-agent[dashboard]'"
|
||||
)
|
||||
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -1371,11 +1359,13 @@ def _anthropic_oauth_status() -> Dict[str, Any]:
|
||||
The dashboard reports the highest-priority source that's actually present.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
read_hermes_oauth_credentials,
|
||||
read_claude_code_credentials,
|
||||
_HERMES_OAUTH_FILE,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
read_hermes_oauth_credentials = _anthropic.get("read_hermes_oauth_credentials")
|
||||
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
|
||||
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
|
||||
if read_hermes_oauth_credentials is None:
|
||||
raise ImportError("anthropic plugin not registered")
|
||||
except ImportError:
|
||||
read_claude_code_credentials = None # type: ignore
|
||||
read_hermes_oauth_credentials = None # type: ignore
|
||||
@@ -1434,7 +1424,11 @@ def _claude_code_only_status() -> Dict[str, Any]:
|
||||
when they also have a separate Hermes-managed PKCE login.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import read_claude_code_credentials
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
|
||||
if read_claude_code_credentials is None:
|
||||
raise ImportError("anthropic plugin not registered")
|
||||
creds = read_claude_code_credentials()
|
||||
except Exception:
|
||||
creds = None
|
||||
@@ -1620,8 +1614,10 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||
# want to undo a disconnect.
|
||||
if provider_id in {"anthropic", "claude-code"}:
|
||||
try:
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
|
||||
if _HERMES_OAUTH_FILE is not None and _HERMES_OAUTH_FILE.exists():
|
||||
_HERMES_OAUTH_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1688,13 +1684,15 @@ _oauth_sessions_lock = threading.Lock()
|
||||
# Guarded so hermes web still starts if anthropic_adapter is unavailable;
|
||||
# Phase 2 endpoints will return 501 in that case.
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
_OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,
|
||||
_OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,
|
||||
_OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,
|
||||
_OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,
|
||||
_generate_pkce as _generate_pkce_pair,
|
||||
)
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
_ANTHROPIC_OAUTH_CLIENT_ID = _anthropic.get("_OAUTH_CLIENT_ID")
|
||||
_ANTHROPIC_OAUTH_TOKEN_URL = _anthropic.get("_OAUTH_TOKEN_URL")
|
||||
_ANTHROPIC_OAUTH_REDIRECT_URI = _anthropic.get("_OAUTH_REDIRECT_URI")
|
||||
_ANTHROPIC_OAUTH_SCOPES = _anthropic.get("_OAUTH_SCOPES")
|
||||
_generate_pkce_pair = _anthropic.get("_generate_pkce")
|
||||
if any(v is None for v in [_ANTHROPIC_OAUTH_CLIENT_ID, _ANTHROPIC_OAUTH_TOKEN_URL, _ANTHROPIC_OAUTH_REDIRECT_URI, _ANTHROPIC_OAUTH_SCOPES, _generate_pkce_pair]):
|
||||
raise ImportError("anthropic plugin not registered")
|
||||
_ANTHROPIC_OAUTH_AVAILABLE = True
|
||||
except ImportError:
|
||||
_ANTHROPIC_OAUTH_AVAILABLE = False
|
||||
@@ -1732,7 +1730,11 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
|
||||
Mirrors what auth_commands.add_command does so the dashboard flow leaves
|
||||
the system in the same state as ``hermes auth add anthropic``.
|
||||
"""
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
from agent.plugin_registries import registries
|
||||
_anthropic = registries.get_provider_namespace("anthropic")
|
||||
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
|
||||
if _HERMES_OAUTH_FILE is None:
|
||||
raise ImportError("anthropic plugin not registered")
|
||||
payload = {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
|
||||
+36
-1
@@ -54,7 +54,6 @@ SCHEMA_VERSION = 13
|
||||
_WAL_INCOMPAT_MARKERS = (
|
||||
"locking protocol", # SQLITE_PROTOCOL on NFS/SMB
|
||||
"not authorized", # Some FUSE mounts block WAL pragma outright
|
||||
"disk i/o error", # Flaky network FS during WAL setup
|
||||
)
|
||||
|
||||
# Last SessionDB() init error, per-process. Surfaced in /resume and
|
||||
@@ -125,6 +124,27 @@ def format_session_db_unavailable(prefix: str = "Session database not available"
|
||||
return f"{prefix}: {cause}{hint}."
|
||||
|
||||
|
||||
def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
|
||||
"""Read the journal mode from the SQLite DB header on disk.
|
||||
|
||||
Returns the mode string (e.g. ``"wal"``, ``"delete"``), or ``None``
|
||||
if the value cannot be determined (new DB, or PRAGMA read failed).
|
||||
"""
|
||||
try:
|
||||
row = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
mode = row[0]
|
||||
if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
|
||||
try:
|
||||
mode = mode.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
return str(mode).strip().lower() if mode is not None else None
|
||||
|
||||
|
||||
def apply_wal_with_fallback(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
@@ -147,7 +167,18 @@ def apply_wal_with_fallback(
|
||||
|
||||
Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
|
||||
both databases get identical fallback behavior.
|
||||
|
||||
Never downgrades to DELETE if the on-disk DB header reports WAL — see _on_disk_journal_mode.
|
||||
"""
|
||||
# Read-only probe — no flock, no checkpoint, no WAL/SHM unlink.
|
||||
# Skipping the set-pragma prevents WAL-init from unlinking files other connections hold open.
|
||||
try:
|
||||
current_mode = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
if current_mode and current_mode[0] == "wal":
|
||||
return "wal"
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return "wal"
|
||||
@@ -156,6 +187,10 @@ def apply_wal_with_fallback(
|
||||
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
|
||||
# Unrelated OperationalError — don't silently swallow.
|
||||
raise
|
||||
# Don't downgrade if another process already set WAL on disk.
|
||||
existing = _on_disk_journal_mode(conn)
|
||||
if existing == "wal":
|
||||
raise
|
||||
_log_wal_fallback_once(db_label, exc)
|
||||
conn.execute("PRAGMA journal_mode=DELETE")
|
||||
return "delete"
|
||||
|
||||
+9
-2
@@ -147,8 +147,15 @@ def create_environment(
|
||||
return DockerEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
|
||||
|
||||
elif env_type == "modal":
|
||||
from tools.environments.modal import ModalEnvironment
|
||||
return ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
|
||||
from agent.plugin_registries import registries
|
||||
_modal = registries.get_tool_provider("modal")
|
||||
_ModalEnvironment = _modal.environment_classes.get("ModalEnvironment") if _modal else None
|
||||
if _ModalEnvironment is None:
|
||||
raise ValueError(
|
||||
"Modal backend selected but the hermes_agent_modal plugin is not loaded. "
|
||||
"Ensure the modal plugin is installed and enabled."
|
||||
)
|
||||
return _ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', or 'modal'")
|
||||
|
||||
+233
@@ -260,6 +260,239 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# ── Plugin architecture (hermetic core boundary) ───────────────────
|
||||
#
|
||||
# These checks prove that under NixOS (sealed venv, no pip install),
|
||||
# the plugin system works correctly:
|
||||
# 1. Core never imports from hermes_agent_* packages directly
|
||||
# 2. Plugin registries are populated after discovery
|
||||
# 3. Provider service namespaces are queryable
|
||||
# 4. Optional plugins degrade gracefully (None returns, no crash)
|
||||
# 5. No ensure() / lazy_deps / pip-install at runtime
|
||||
|
||||
# Check 1: Zero direct hermes_agent_* imports in core code
|
||||
plugin-hermetic-boundary = pkgs.runCommand "hermes-plugin-hermetic-boundary" { } ''
|
||||
set -e
|
||||
echo "=== Checking core never imports from plugin packages ==="
|
||||
|
||||
# Search for direct imports from hermes_agent_* in core code
|
||||
# (excluding plugins/, tests/, website/, and comments)
|
||||
VIOLATIONS=$(${hermesVenv}/bin/python3 -c '
|
||||
import subprocess, re, sys
|
||||
result = subprocess.run(
|
||||
["grep", "-rn",
|
||||
"from hermes_agent_\\|import hermes_agent_",
|
||||
"${hermes-agent}/share/hermes-agent"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
# Filter: only .py files, not in plugins/ or tests/, not comments
|
||||
violations = []
|
||||
for line in lines:
|
||||
if not line.endswith(".py"):
|
||||
continue
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
filepath, lineno, content = parts
|
||||
# Skip plugin directories
|
||||
if "/plugins/" in filepath:
|
||||
continue
|
||||
# Skip test directories
|
||||
if "/tests/" in filepath or "/test_" in filepath:
|
||||
continue
|
||||
# Skip comments
|
||||
stripped = content.lstrip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
violations.append(line)
|
||||
for v in violations:
|
||||
print(v)
|
||||
sys.exit(1 if violations else 0)
|
||||
' 2>&1 || true)
|
||||
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "FAIL: Core code imports directly from plugin packages:"
|
||||
echo "$VIOLATIONS"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Zero direct hermes_agent_* imports in core"
|
||||
|
||||
echo "=== Checking no ensure() / LAZY_DEPS in core ==="
|
||||
ENSURE_VIOLATIONS=$(grep -rn 'ensure(' ${hermes-agent}/share/hermes-agent/agent/ ${hermes-agent}/share/hermes-agent/hermes_cli/ --include='*.py' 2>/dev/null | grep -v '__pycache__' | grep -v '# ' || true)
|
||||
if [ -n "$ENSURE_VIOLATIONS" ]; then
|
||||
echo "FAIL: ensure() still used in core:"
|
||||
echo "$ENSURE_VIOLATIONS"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: No ensure() calls in core code"
|
||||
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Check 2: Plugin registries populate after discovery
|
||||
plugin-registries-populate = pkgs.runCommand "hermes-plugin-registries-populate" { } ''
|
||||
set -e
|
||||
echo "=== Checking plugin registries populate after discovery ==="
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
RESULT=$(${hermesVenv}/bin/python3 -c '
|
||||
import json, sys
|
||||
from hermes_cli.plugins import PluginManager
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
pm = PluginManager()
|
||||
pm.discover_and_load(force=True)
|
||||
|
||||
out = {
|
||||
"provider_services": list(registries._provider_services.keys()),
|
||||
"platform_adapters": list(registries.platform_adapters.keys()),
|
||||
"tool_providers": list(registries.tool_providers.keys()),
|
||||
}
|
||||
json.dump(out, sys.stdout)
|
||||
' 2>/dev/null)
|
||||
|
||||
echo "Registry state: $RESULT"
|
||||
|
||||
# Verify provider services populated
|
||||
PROV_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.provider_services | length')
|
||||
if [ "$PROV_COUNT" -lt 1 ]; then
|
||||
echo "FAIL: No provider services registered (expected >= 1)"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $PROV_COUNT provider service(s) registered"
|
||||
|
||||
# Verify platform adapters populated
|
||||
PLAT_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.platform_adapters | length')
|
||||
if [ "$PLAT_COUNT" -lt 1 ]; then
|
||||
echo "FAIL: No platform adapters registered (expected >= 1)"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $PLAT_COUNT platform adapter(s) registered"
|
||||
|
||||
# Verify tool providers populated
|
||||
TOOL_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.tool_providers | length')
|
||||
if [ "$TOOL_COUNT" -lt 1 ]; then
|
||||
echo "FAIL: No tool providers registered (expected >= 1)"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $TOOL_COUNT tool provider(s) registered"
|
||||
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Check 3: Specific provider service lookups work
|
||||
plugin-provider-lookups = pkgs.runCommand "hermes-plugin-provider-lookups" { } ''
|
||||
set -e
|
||||
echo "=== Checking provider service lookups ==="
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
RESULT=$(${hermesVenv}/bin/python3 -c '
|
||||
import json, sys
|
||||
from hermes_cli.plugins import PluginManager
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
pm = PluginManager()
|
||||
pm.discover_and_load(force=True)
|
||||
|
||||
checks = {
|
||||
"anthropic.resolve_anthropic_token": registries.get_provider_service("anthropic", "resolve_anthropic_token") is not None,
|
||||
"bedrock.has_aws_credentials": registries.get_provider_service("bedrock", "has_aws_credentials") is not None,
|
||||
"azure.is_token_provider": registries.get_provider_service("azure", "is_token_provider") is not None,
|
||||
}
|
||||
json.dump(checks, sys.stdout)
|
||||
' 2>/dev/null)
|
||||
|
||||
echo "Lookup results: $RESULT"
|
||||
|
||||
for key in anthropic.resolve_anthropic_token bedrock.has_aws_credentials azure.is_token_provider; do
|
||||
VALUE=$(echo "$RESULT" | ${pkgs.jq}/bin/jq --arg k "$key" '.[$k]')
|
||||
if [ "$VALUE" != "true" ]; then
|
||||
echo "FAIL: $key lookup returned $VALUE (expected true)"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $key lookup works"
|
||||
done
|
||||
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Check 4: Missing plugins degrade gracefully (no crash)
|
||||
plugin-missing-graceful = pkgs.runCommand "hermes-plugin-missing-graceful" { } ''
|
||||
set -e
|
||||
echo "=== Checking missing plugins degrade gracefully ==="
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
${hermesVenv}/bin/python3 -c '
|
||||
from agent.plugin_registries import registries
|
||||
|
||||
# Lookup from non-existent provider — should return None, not crash
|
||||
result = registries.get_provider_service("nonexistent-provider", "some_function")
|
||||
assert result is None, f"Expected None for missing provider, got {result}"
|
||||
|
||||
# Lookup from empty registry — should return None
|
||||
result2 = registries.get_provider_namespace("no-such-provider")
|
||||
assert result2 == {}, f"Expected empty dict for missing namespace, got {result2}"
|
||||
|
||||
# Lookup specific tool provider that does not exist
|
||||
result3 = registries.get_tool_provider("nonexistent-tool")
|
||||
assert result3 is None, f"Expected None for missing tool provider, got {result3}"
|
||||
|
||||
print("PASS: All missing-plugin lookups return None gracefully")
|
||||
' 2>&1
|
||||
|
||||
echo "PASS: Missing plugins degrade gracefully (no crash)"
|
||||
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Check 5: No runtime pip install / ensure in gateway/run.py
|
||||
plugin-no-runtime-install = pkgs.runCommand "hermes-plugin-no-runtime-install" { } ''
|
||||
set -e
|
||||
echo "=== Checking no runtime pip install / ensure in core ==="
|
||||
|
||||
# Check gateway/run.py has no ensure() or pip install
|
||||
GATEWAY=${hermes-agent}/share/hermes-agent/gateway/run.py
|
||||
if [ -f "$GATEWAY" ]; then
|
||||
if grep -q 'ensure(' "$GATEWAY" || grep -q 'pip install' "$GATEWAY"; then
|
||||
echo "FAIL: gateway/run.py contains ensure() or pip install"
|
||||
grep -n 'ensure(\|pip install' "$GATEWAY"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: gateway/run.py has no ensure()/pip install"
|
||||
else
|
||||
echo "SKIP: gateway/run.py not found in package"
|
||||
fi
|
||||
|
||||
# Check run_agent.py has no ensure() or pip install
|
||||
RUN_AGENT=${hermes-agent}/share/hermes-agent/run_agent.py
|
||||
if [ -f "$RUN_AGENT" ]; then
|
||||
if grep -q 'ensure(' "$RUN_AGENT" || grep -q 'pip install' "$RUN_AGENT"; then
|
||||
echo "FAIL: run_agent.py contains ensure() or pip install"
|
||||
grep -n 'ensure(\|pip install' "$RUN_AGENT"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: run_agent.py has no ensure()/pip install"
|
||||
else
|
||||
echo "SKIP: run_agent.py not found in package"
|
||||
fi
|
||||
|
||||
# Check tools/lazy_deps.py is gone
|
||||
LAZY_DEPS=${hermes-agent}/share/hermes-agent/tools/lazy_deps.py
|
||||
if [ -f "$LAZY_DEPS" ]; then
|
||||
echo "FAIL: tools/lazy_deps.py still exists — should be removed"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: tools/lazy_deps.py removed"
|
||||
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Regression guard: messaging deps live outside [all], so the
|
||||
# #messaging variant must actually ship discord.py — otherwise
|
||||
# `nix profile install .#messaging` regresses to the broken default.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA=";
|
||||
hash = "sha256-RPPWPM0nEkwsaQHrkdEP+UMTZ2aF7JHUNfsIEnKt1l8=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
+58693
File diff suppressed because it is too large
Load Diff
@@ -174,7 +174,7 @@ def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]:
|
||||
|
||||
# Try register(ctx) pattern first (how plugins are written)
|
||||
if hasattr(mod, "register"):
|
||||
collector = _EngineCollector()
|
||||
collector = _EngineCollector(engine_name=name)
|
||||
try:
|
||||
mod.register(collector)
|
||||
if collector.engine:
|
||||
@@ -197,14 +197,80 @@ def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]:
|
||||
|
||||
|
||||
class _EngineCollector:
|
||||
"""Fake plugin context that captures register_context_engine calls."""
|
||||
"""Fake plugin context that captures register_context_engine calls.
|
||||
|
||||
def __init__(self):
|
||||
Plugin context engines using the standard ``register(ctx)`` pattern may
|
||||
also call ``ctx.register_command(...)`` to expose slash commands (e.g.
|
||||
``/lcm``). Forward those to the global plugin command registry so they
|
||||
behave identically to commands registered by normal plugins.
|
||||
"""
|
||||
|
||||
def __init__(self, engine_name: str = ""):
|
||||
self.engine = None
|
||||
self._engine_name = engine_name or "context_engine"
|
||||
self._registered_commands: list[str] = []
|
||||
|
||||
def register_context_engine(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def register_command(
|
||||
self,
|
||||
name: str,
|
||||
handler,
|
||||
description: str = "",
|
||||
args_hint: str = "",
|
||||
) -> None:
|
||||
"""Forward to the global plugin command registry."""
|
||||
clean = (name or "").lower().strip().lstrip("/").replace(" ", "-")
|
||||
if not clean:
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register a command with an empty name.",
|
||||
self._engine_name,
|
||||
)
|
||||
return
|
||||
|
||||
# Reject conflicts with built-in commands.
|
||||
try:
|
||||
from hermes_cli.commands import resolve_command
|
||||
if resolve_command(clean) is not None:
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register command '/%s' which conflicts "
|
||||
"with a built-in command. Skipping.",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
manager = get_plugin_manager()
|
||||
if clean in manager._plugin_commands:
|
||||
# Don't clobber a regular plugin's command — same conflict
|
||||
# policy the plugin system uses for plugin-vs-plugin collisions.
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register command '/%s' which "
|
||||
"is already registered by a plugin. Skipping.",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
return
|
||||
manager._plugin_commands[clean] = {
|
||||
"handler": handler,
|
||||
"description": description or "Context engine command",
|
||||
"plugin": f"context-engine:{self._engine_name}",
|
||||
"args_hint": (args_hint or "").strip(),
|
||||
}
|
||||
self._registered_commands.append(clean)
|
||||
logger.debug(
|
||||
"Context engine '%s' registered command: /%s",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Context engine '%s' could not register /%s: %s",
|
||||
self._engine_name, clean, exc,
|
||||
)
|
||||
|
||||
# No-op for other registration methods
|
||||
def register_tool(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Bridge module — delegates plugin registration to hermes_agent_dashboard."""
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — delegates to the inner hermes_agent_dashboard package."""
|
||||
from hermes_agent_dashboard import register as _inner_register
|
||||
_inner_register(ctx)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Hermes Agent web dashboard."""
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — dashboard registers via ctx.register_tool_provider_entry()."""
|
||||
pass
|
||||
@@ -0,0 +1,6 @@
|
||||
name: dashboard
|
||||
version: 0.1.0
|
||||
description: Web dashboard (FastAPI + uvicorn)
|
||||
kind: backend
|
||||
provides_tools: ["dashboard"]
|
||||
provides_hooks: []
|
||||
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent-dashboard"
|
||||
version = "0.1.0"
|
||||
description = "Hermes Agent web dashboard (FastAPI + Uvicorn)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"hermes-agent",
|
||||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
]
|
||||
|
||||
[project.entry-points."hermes_agent.plugins"]
|
||||
dashboard = "hermes_agent_dashboard:register"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["hermes_agent_dashboard*"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Bridge module — delegates plugin registration to hermes_agent_fal."""
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — delegates to the inner hermes_agent_fal package."""
|
||||
from hermes_agent_fal import register as _inner_register
|
||||
_inner_register(ctx)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""hermes-agent-fal: FAL.ai SDK plumbing plugin for Hermes Agent."""
|
||||
|
||||
from hermes_agent_fal.fal_common import ( # noqa: F401
|
||||
import_fal_client,
|
||||
_ManagedFalSyncClient,
|
||||
_extract_http_status,
|
||||
_normalize_fal_queue_url_format,
|
||||
)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Entry point for the hermes_agent.plugins entry point group.
|
||||
|
||||
Registers FAL SDK plumbing (import_fal_client, _ManagedFalSyncClient,
|
||||
etc.) in the plugin capability registry so core code can look them
|
||||
up without importing from ``hermes_agent_fal`` directly.
|
||||
"""
|
||||
from hermes_agent_fal.fal_common import (
|
||||
import_fal_client,
|
||||
_ManagedFalSyncClient,
|
||||
_extract_http_status,
|
||||
_normalize_fal_queue_url_format,
|
||||
)
|
||||
ctx.register_tool_provider_entry(
|
||||
name="fal",
|
||||
tool_functions={
|
||||
"import_fal_client": import_fal_client,
|
||||
},
|
||||
constants={
|
||||
"_normalize_fal_queue_url_format": _normalize_fal_queue_url_format,
|
||||
},
|
||||
config_functions={
|
||||
"_ManagedFalSyncClient": _ManagedFalSyncClient,
|
||||
"_extract_http_status": _extract_http_status,
|
||||
},
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user