Compare commits
316
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c3399b81 | ||
|
|
9ee6f7be36 | ||
|
|
cec9df17ca | ||
|
|
8f2631dc97 | ||
|
|
3a2c03061c | ||
|
|
781604ce4c | ||
|
|
0dc0c5ea6b | ||
|
|
3845d86b93 | ||
|
|
d473e7c938 | ||
|
|
91b174038c | ||
|
|
8055d0f092 | ||
|
|
9405cdc8dd | ||
|
|
08c0b22417 | ||
|
|
38c4f8c371 | ||
|
|
a1cb5fa2c7 | ||
|
|
45b00bb49a | ||
|
|
8836b3a113 | ||
|
|
6312dd8c3a | ||
|
|
30a0d5bc9e | ||
|
|
aa283d1e4f | ||
|
|
2fc2280e63 | ||
|
|
27a2c4f36f | ||
|
|
1cb850b674 | ||
|
|
b6ed3913d2 | ||
|
|
4de8009ce4 | ||
|
|
1596bb287e | ||
|
|
90b3c54de9 | ||
|
|
5641ae6469 | ||
|
|
549a69a925 | ||
|
|
3f0d44af8a | ||
|
|
eff4626747 | ||
|
|
175885218e | ||
|
|
119390a2a1 | ||
|
|
3625dbb844 | ||
|
|
aef04b2b53 | ||
|
|
a2d3cff53f | ||
|
|
ee0a9bf7c7 | ||
|
|
b922e3ff93 | ||
|
|
053969fd53 | ||
|
|
988cf1743b | ||
|
|
03bdeaa876 | ||
|
|
d86710528a | ||
|
|
6891e05e78 | ||
|
|
0673638560 | ||
|
|
ae9dfa510e | ||
|
|
7379f17556 | ||
|
|
0563ab0652 | ||
|
|
e46e4bcf47 | ||
|
|
3183b2e28c | ||
|
|
a4c18f65d4 | ||
|
|
b6294ea9f1 | ||
|
|
d04b3c193e | ||
|
|
5cd0673217 | ||
|
|
6bc309baf2 | ||
|
|
6928692cec | ||
|
|
75cd420b3b | ||
|
|
78d7fa1b5c | ||
|
|
904c0b479b | ||
|
|
38695254f8 | ||
|
|
2159d2a729 | ||
|
|
0dba60f73b | ||
|
|
632a7088a3 | ||
|
|
1bba5f27ab | ||
|
|
d6f2bdabda | ||
|
|
99ddba94ed | ||
|
|
10cd4138cc | ||
|
|
5e7c2ffa9f | ||
|
|
1c53d39eaa | ||
|
|
6a2e3c2d26 | ||
|
|
fd09b2c55e | ||
|
|
ddaf2f6712 | ||
|
|
dc235e93cb | ||
|
|
0aa9f6acfa | ||
|
|
0c0a905011 | ||
|
|
e4b9532c18 | ||
|
|
c0b17b3c0c | ||
|
|
2520c9ad68 | ||
|
|
62e81b2d9b | ||
|
|
fe7e0a8c1d | ||
|
|
6e179c44b1 | ||
|
|
58e1b04665 | ||
|
|
c77a697fa4 | ||
|
|
c3f28c651d | ||
|
|
2402ec5e7b | ||
|
|
f8b8dffccf | ||
|
|
f05353397d | ||
|
|
784d8dd2c2 | ||
|
|
3171845479 | ||
|
|
4bdae34771 | ||
|
|
655090b3d3 | ||
|
|
1083977261 | ||
|
|
21aeefe5fd | ||
|
|
a22c250001 | ||
|
|
95cf8f9842 | ||
|
|
4e4984a11a | ||
|
|
7e958dafc2 | ||
|
|
41ff6e5937 | ||
|
|
a87f0a82a5 | ||
|
|
18c9e89106 | ||
|
|
1709776120 | ||
|
|
7427b9d581 | ||
|
|
369075dc95 | ||
|
|
73d73f1f0d | ||
|
|
6bebab4761 | ||
|
|
95b5b72404 | ||
|
|
db2ce9e7d2 | ||
|
|
e28a668b40 | ||
|
|
2765b02021 | ||
|
|
c01a2df0a3 | ||
|
|
f247686c42 | ||
|
|
f32b66c758 | ||
|
|
c692000a57 | ||
|
|
2410e11395 | ||
|
|
0384398c65 | ||
|
|
26b83a5f5f | ||
|
|
c1485d52e3 | ||
|
|
f6a2ba6261 | ||
|
|
bc736ff543 | ||
|
|
f2d88c820c | ||
|
|
8d57281650 | ||
|
|
9d4fda9952 | ||
|
|
7d10105918 | ||
|
|
592a4ffb6b | ||
|
|
bc31ee5cf8 | ||
|
|
40217aa194 | ||
|
|
86a389fee2 | ||
|
|
ae6817f7f7 | ||
|
|
4126da65ae | ||
|
|
71ae98b792 | ||
|
|
cf8862cfa3 | ||
|
|
1386a7e478 | ||
|
|
db96fc60d0 | ||
|
|
ec7736f8a7 | ||
|
|
48083211ef | ||
|
|
a0fc3df878 | ||
|
|
f61fd59b62 | ||
|
|
00b8204cf4 | ||
|
|
e371bf5d68 | ||
|
|
66827f8947 | ||
|
|
a4d8f0f62a | ||
|
|
75d2c081c9 | ||
|
|
a30480bd2b | ||
|
|
28bb7e0a8e | ||
|
|
100536134c | ||
|
|
5407d25599 | ||
|
|
90f0f32eae | ||
|
|
40fa0c1d19 | ||
|
|
69b74c15a3 | ||
|
|
8cf6b3da9d | ||
|
|
bfecfabd0f | ||
|
|
44df52005a | ||
|
|
9992e32db3 | ||
|
|
042c1d6bb0 | ||
|
|
6dc068ef04 | ||
|
|
e785c0ad70 | ||
|
|
c834624f7d | ||
|
|
54bf798765 | ||
|
|
593e4b435e | ||
|
|
a618789dba | ||
|
|
3b6347af15 | ||
|
|
42612aa350 | ||
|
|
3c6e70aef1 | ||
|
|
2f0f03c40d | ||
|
|
5c2170a7c6 | ||
|
|
d77d877665 | ||
|
|
ac8e238bc8 | ||
|
|
8d129d013b | ||
|
|
300140e006 | ||
|
|
e71a2bd11b | ||
|
|
769ee86cd2 | ||
|
|
1b1e30510a | ||
|
|
f3acdd94fe | ||
|
|
78a54d2c00 | ||
|
|
e7c99651fb | ||
|
|
fb51253620 | ||
|
|
ef009a987a | ||
|
|
130396c658 | ||
|
|
a5c1f925b5 | ||
|
|
11d93096b3 | ||
|
|
d464d08a5f | ||
|
|
5a95fb2e14 | ||
|
|
0acb7f4583 | ||
|
|
a3cd974ee7 | ||
|
|
ea5a6c216b | ||
|
|
4df62d239e | ||
|
|
490b3e76b1 | ||
|
|
321ce94e25 | ||
|
|
c5e496e1c0 | ||
|
|
7a3c38d0b7 | ||
|
|
5cbc3fbdcc | ||
|
|
f30db14ced | ||
|
|
3a9bc9d88a | ||
|
|
5f66c36470 | ||
|
|
7a8589e782 | ||
|
|
7050c052e3 | ||
|
|
102eb4adc0 | ||
|
|
b1d3ead7fb | ||
|
|
c661fefa08 | ||
|
|
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 | ||
|
|
66265a0571 | ||
|
|
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 | ||
|
|
c9e5a9bb08 |
@@ -3,11 +3,9 @@ name: Contributor Attribution Check
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Only run when code files change (not docs-only PRs)
|
||||
- '*.py'
|
||||
- '**/*.py'
|
||||
- '.github/workflows/contributor-check.yml'
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -20,7 +18,21 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,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
|
||||
|
||||
@@ -149,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
|
||||
@@ -192,10 +196,12 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for smoke testing. Cached
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (arm64, smoke test)
|
||||
# Build once, load into the local daemon for smoke testing. PR arm64
|
||||
# builds deliberately avoid the gha cache: cold-cache arm64 builds can
|
||||
# outlive GitHub's short-lived Azure cache SAS token, then fail while
|
||||
# reading or writing cache blobs before the smoke test can run.
|
||||
- name: Build image (arm64, smoke test, uncached PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
@@ -203,6 +209,22 @@ jobs:
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
|
||||
# Main/release builds still use the per-arch gha cache so the digest
|
||||
# push below can reuse layers from this smoke-test build.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
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
|
||||
|
||||
@@ -228,6 +250,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
|
||||
|
||||
@@ -3,15 +3,9 @@ name: Supply Chain Audit
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- '**/*.pth'
|
||||
- '**/setup.py'
|
||||
- '**/setup.cfg'
|
||||
- '**/sitecustomize.py'
|
||||
- '**/usercustomize.py'
|
||||
- '**/__init__.pth'
|
||||
- 'pyproject.toml'
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -27,8 +21,44 @@ permissions:
|
||||
# advisory-only workflow instead.
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -147,10 +177,24 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -211,3 +255,16 @@ jobs:
|
||||
run: |
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
+10
@@ -78,6 +78,12 @@ 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/
|
||||
@@ -86,3 +92,7 @@ docs/superpowers/*
|
||||
# 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/
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
|
||||
- Document handling, web research, common dev workflows, system administration
|
||||
- Used regularly by a wide range of people
|
||||
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
|
||||
|
||||
|
||||
+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 iputils-ping 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,4 +1,9 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
|
||||
# below covers the wheel; this covers the sdist. See #34034 / #28149.
|
||||
recursive-include plugins plugin.yaml plugin.yml
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
|
||||
@@ -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,110 @@
|
||||
# Hermes Agent v0.15.1 (v2026.5.29)
|
||||
|
||||
**Release Date:** May 29, 2026
|
||||
**Since v0.15.0:** 28 commits · 21 merged PRs · hotfix release · 9 contributors
|
||||
|
||||
> **The Patch Release.** A same-day hotfix for v0.15.0. Headline fix: the dashboard infinite-reload loop that hit anyone running v0.15.0 in loopback mode (Docker, hosted Hermes, fresh installs). A handful of other v0.15.0 follow-ups go along for the ride — kanban worker SIGTERM, `/model` picker unification, `/yolo` session bypass, the full 19,932-entry skills.sh catalog, `.md` media delivery restoration, gateway probe-stepdown safety, web-URL redaction passthrough, kanban worker vision on referenced images, hindsight observation-default. Docker users get an explicit `--insecure` opt-in env var (no more bind-host inference), MCP server bare-command PATH resolution, and arm64 PR-build cache fixes.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Dashboard 401 reload loop fixed** — In loopback mode the dashboard's identity probe (`/api/auth/me`) returns 401 by design, but v0.15.0's stale-token reload guard treated every 401 as a rotated session token and full-page-reloaded to pick up a fresh one. Every successful sibling call cleared the one-shot reload guard, so the page reload-looped forever (Firefox: "Navigated to /sessions" storm; Chrome: React re-render storm). Fix adds an `allowUnauthorized` opt-out to `fetchJSON` that skips only the loopback stale-token reload — 401 still throws so `AuthWidget` swallows it, gated-mode `login_url` redirects are unaffected. Closes [#34206](https://github.com/NousResearch/hermes-agent/issues/34206), [#34202](https://github.com/NousResearch/hermes-agent/issues/34202). ([#30698](https://github.com/NousResearch/hermes-agent/pull/30698) — @austinpickett)
|
||||
|
||||
- **Docker dashboard `--insecure` is now an explicit env opt-in, never derived from bind host** — Previously the Docker entrypoint inferred `--insecure` when the dashboard bound to a non-loopback host. That conflated "I want LAN access" with "I want to disable the same-origin guard." The fix splits them: bind host is bind host, and disabling the dashboard's loopback auth requires an explicit `HERMES_DASHBOARD_INSECURE=1`. Existing setups that genuinely wanted insecure binding must now set the env var. ([#34188](https://github.com/NousResearch/hermes-agent/pull/34188), [#34204](https://github.com/NousResearch/hermes-agent/pull/34204) — @benbarclay)
|
||||
|
||||
- **MCP bare command resolution under Docker** — MCP servers configured with bare commands (`npx`, `npm`, `node`) now resolve against `/usr/local/bin` so they actually launch inside the Docker image where those binaries live. v0.15.0 left these failing silently in containers when the agent's effective PATH didn't include the Node toolchain location. ([#34186](https://github.com/NousResearch/hermes-agent/pull/34186) — @benbarclay)
|
||||
|
||||
- **Skills page sidebar / source pills restored** — A stale `useMemo` dependency in the new dashboard skills page collapsed the source pills and category sidebar to "All" only. Fixed; both surfaces now reflect the live catalog state. ([#34194](https://github.com/NousResearch/hermes-agent/pull/34194))
|
||||
|
||||
- **Kanban worker can be killed again** — `SIGTERM` on a kanban worker was being absorbed by an intermediate process and the worker stayed running. Closes [#28181](https://github.com/NousResearch/hermes-agent/issues/28181). ([#34045](https://github.com/NousResearch/hermes-agent/pull/34045))
|
||||
|
||||
- **Full skills.sh catalog (858 → 19,932 entries)** — The skills hub page was pulling a partial paginated catalog. The fetch now walks the sitemap, so all 19,932 skills.sh entries surface in the picker instead of just the first 858. ([#34025](https://github.com/NousResearch/hermes-agent/pull/34025))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
### Dashboard / Web
|
||||
|
||||
- **`/api/auth/me` 401 no longer triggers reload loop** in loopback mode — ([#30698](https://github.com/NousResearch/hermes-agent/pull/30698) — @austinpickett)
|
||||
- **Skills page source pills + category sidebar restored** — stale `useMemo` dep ([#34194](https://github.com/NousResearch/hermes-agent/pull/34194))
|
||||
|
||||
### Docker
|
||||
|
||||
- **`--insecure` is now explicit opt-in via env var**, not derived from bind host ([#34188](https://github.com/NousResearch/hermes-agent/pull/34188) — @benbarclay)
|
||||
- **Dashboard test suite repaired** to match the insecure-opt-in fix ([#34204](https://github.com/NousResearch/hermes-agent/pull/34204) — @benbarclay)
|
||||
- **arm64 PR builds skip the GHA cache** to avoid cache-thrash on cross-arch builders ([#33704](https://github.com/NousResearch/hermes-agent/pull/33704) — @BROCCOLO1D)
|
||||
|
||||
### MCP
|
||||
|
||||
- **Bare `npx`/`npm`/`node` resolve against `/usr/local/bin`** for Docker compatibility ([#34186](https://github.com/NousResearch/hermes-agent/pull/34186) — @benbarclay)
|
||||
|
||||
### Kanban
|
||||
|
||||
- **Worker SIGTERM actually terminates the process** ([#34045](https://github.com/NousResearch/hermes-agent/pull/34045))
|
||||
- **Workers receive images referenced in task bodies** for vision-capable models ([#34210](https://github.com/NousResearch/hermes-agent/pull/34210))
|
||||
|
||||
### Gateway
|
||||
|
||||
- **`.md` files deliver again** — media-delivery validation defaults to denylist-only instead of an overly-narrow allowlist ([#34022](https://github.com/NousResearch/hermes-agent/pull/34022))
|
||||
- **Probe stepdown safety** — on a context-overflow without an explicit provider context limit, the agent no longer steps down to a smaller model based on an unknown ceiling (salvage of [#33673](https://github.com/NousResearch/hermes-agent/pull/33673)) ([#33826](https://github.com/NousResearch/hermes-agent/pull/33826))
|
||||
|
||||
### CLI
|
||||
|
||||
- **`/yolo` mid-session enables the per-session bypass** instead of just toggling the env var (which the running agent had already snapshotted) ([#33931](https://github.com/NousResearch/hermes-agent/pull/33931) — @kshitijk4poor)
|
||||
- **`/model` and `hermes model` show the same list**, plus disk cache for picker startup ([#33867](https://github.com/NousResearch/hermes-agent/pull/33867))
|
||||
|
||||
### Skills
|
||||
|
||||
- **Full skills.sh catalog via sitemap** — 858 → 19,932 entries ([#34025](https://github.com/NousResearch/hermes-agent/pull/34025))
|
||||
|
||||
### Redaction
|
||||
|
||||
- **Web URLs pass through unchanged** — the redactor was eating query parameters that looked credential-shaped ([#34029](https://github.com/NousResearch/hermes-agent/pull/34029))
|
||||
|
||||
---
|
||||
|
||||
## ✨ Small Features
|
||||
|
||||
- **Hindsight default narrowed to observation-only** for `recall_types` — tool path is also narrowed ([#34079](https://github.com/NousResearch/hermes-agent/pull/34079) — @nicoloboschi, follow-up [#34091](https://github.com/NousResearch/hermes-agent/pull/4df62d239e38bf8c212a595721c9c01e176f6c3a) — @kshitijk4poor)
|
||||
- **Memory providers receive completed-turn message context** — salvage of [#28065](https://github.com/NousResearch/hermes-agent/pull/28065) ([#34097](https://github.com/NousResearch/hermes-agent/pull/34097) — @kshitijk4poor, credit to @devwdave)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **`--no-supervise` / `HERMES_GATEWAY_NO_SUPERVISE` documented** in the reference docs (follow-up to [#33583](https://github.com/NousResearch/hermes-agent/pull/33583)) ([#33751](https://github.com/NousResearch/hermes-agent/pull/33751) — @r266-tech)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Infrastructure
|
||||
|
||||
- **Vercel deploy workflow accepts `workflow_dispatch`** so docs deploys can be manually triggered ([#34081](https://github.com/NousResearch/hermes-agent/pull/34081))
|
||||
- **`@nous-research/ui` bumped to 0.18.2** (Nix `npmDepsHash` also updated to match) ([#34193](https://github.com/NousResearch/hermes-agent/pull/34193) follow-ups — @austinpickett)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- @teknium1
|
||||
|
||||
### Community
|
||||
- @austinpickett — dashboard 401 reload-loop fix (the headline), `@nous-research/ui` bump, Nix `npmDepsHash` updates
|
||||
- @benbarclay — Docker `--insecure` opt-in, MCP bare-command resolution, dashboard test repair
|
||||
- @kshitijk4poor — `/yolo` session bypass, completed-turn memory context salvage, hindsight follow-up docs
|
||||
- @nicoloboschi — hindsight `recall_types` observation default
|
||||
- @BROCCOLO1D — arm64 PR build cache fix
|
||||
- @r266-tech — `--no-supervise` reference docs
|
||||
- @yangguangjin — probe stepdown safety (salvage of @yanghd's #33673)
|
||||
- @devwdave — completed-turn memory context (credited via salvage)
|
||||
- @andrewhosf — co-author
|
||||
|
||||
### Issue Reporters (the 401 loop)
|
||||
- @routesmith ([#34206](https://github.com/NousResearch/hermes-agent/issues/34206))
|
||||
- @beeaton ([#34202](https://github.com/NousResearch/hermes-agent/issues/34202))
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.5.28...v2026.5.29](https://github.com/NousResearch/hermes-agent/compare/v2026.5.28...v2026.5.29)
|
||||
@@ -907,72 +907,6 @@ def _build_polished_completion_content(
|
||||
return [_text(text)]
|
||||
|
||||
|
||||
def _build_patch_mode_content(patch_text: str) -> List[Any]:
|
||||
"""Parse V4A patch mode input into ACP diff blocks when possible."""
|
||||
if not patch_text:
|
||||
return [acp.tool_content(acp.text_block(""))]
|
||||
|
||||
try:
|
||||
from tools.patch_parser import OperationType, parse_v4a_patch
|
||||
|
||||
operations, error = parse_v4a_patch(patch_text)
|
||||
if error or not operations:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
content: List[Any] = []
|
||||
for op in operations:
|
||||
if op.operation == OperationType.UPDATE:
|
||||
old_chunks: list[str] = []
|
||||
new_chunks: list[str] = []
|
||||
for hunk in op.hunks:
|
||||
old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}]
|
||||
new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}]
|
||||
if old_lines or new_lines:
|
||||
old_chunks.append("\n".join(old_lines))
|
||||
new_chunks.append("\n".join(new_lines))
|
||||
|
||||
old_text = "\n...\n".join(chunk for chunk in old_chunks if chunk)
|
||||
new_text = "\n...\n".join(chunk for chunk in new_chunks if chunk)
|
||||
if old_text or new_text:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=old_text or None,
|
||||
new_text=new_text or "",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.ADD:
|
||||
added_lines = [line.content for hunk in op.hunks for line in hunk.lines if line.prefix == "+"]
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
new_text="\n".join(added_lines),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.DELETE:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=f"Delete file: {op.file_path}",
|
||||
new_text="",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.MOVE:
|
||||
content.append(
|
||||
acp.tool_content(acp.text_block(f"Move file: {op.file_path} -> {op.new_path}"))
|
||||
)
|
||||
|
||||
return content or [acp.tool_content(acp.text_block(patch_text))]
|
||||
except Exception:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
|
||||
def _strip_diff_prefix(path: str) -> str:
|
||||
raw = str(path or "").strip()
|
||||
if raw.startswith(("a/", "b/")):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.1",
|
||||
"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.1",
|
||||
"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
|
||||
|
||||
+17
-4
@@ -27,7 +27,6 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
@@ -37,7 +36,6 @@ from agent.memory_manager import StreamingContextScrubber
|
||||
from agent.model_metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
fetch_model_metadata,
|
||||
get_model_context_length,
|
||||
is_local_endpoint,
|
||||
query_ollama_num_ctx,
|
||||
)
|
||||
@@ -52,7 +50,6 @@ from agent.tool_guardrails import (
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from hermes_constants import get_hermes_home
|
||||
from model_tools import check_toolset_requirements, get_tool_definitions
|
||||
from utils import base_url_host_matches
|
||||
|
||||
# Use the same logger name as run_agent so tests patching ``run_agent.logger``
|
||||
@@ -183,6 +180,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 +263,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
|
||||
@@ -1119,6 +1118,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:
|
||||
@@ -1197,6 +1198,18 @@ def init_agent(
|
||||
_agent_section = {}
|
||||
agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")
|
||||
|
||||
# Universal task-completion guidance toggle. Default True. Surfaced
|
||||
# as a separate flag from tool_use_enforcement because the guidance
|
||||
# applies to ALL models, not just the model families enforcement
|
||||
# targets.
|
||||
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
||||
|
||||
# Local Python toolchain probe toggle. Default True. When False,
|
||||
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
||||
# line). Useful for users on exotic setups where the probe heuristics
|
||||
# are noisy.
|
||||
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
|
||||
|
||||
# App-level API retry count (wraps each model API call). Default 3,
|
||||
# overridable via agent.api_max_retries in config.yaml. See #11616.
|
||||
try:
|
||||
@@ -1458,7 +1471,6 @@ def init_agent(
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
_ctx = getattr(agent.context_compressor, "context_length", 0)
|
||||
if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH:
|
||||
raise ValueError(
|
||||
@@ -1518,6 +1530,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)
|
||||
|
||||
+172
-81
@@ -25,24 +25,17 @@ from __future__ import annotations
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from agent.message_sanitization import (
|
||||
_repair_tool_call_arguments,
|
||||
_sanitize_surrogates,
|
||||
)
|
||||
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
|
||||
from agent.trajectory import convert_scratchpad_to_think
|
||||
from agent.credential_pool import STATUS_EXHAUSTED
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from agent.error_classifier import FailoverReason
|
||||
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -560,6 +553,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:
|
||||
@@ -1361,81 +1372,129 @@ 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.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
_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 = (
|
||||
@@ -1633,6 +1692,8 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -1928,6 +1989,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.
|
||||
|
||||
+14
-20
@@ -77,16 +77,16 @@ ADAPTIVE_EFFORT_MAP = {
|
||||
# xhigh as a distinct level between high and max; older adaptive-thinking
|
||||
# models (4.6) reject it with a 400. Keep this substring list in sync with
|
||||
# the Anthropic migration guide as new model families ship.
|
||||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7")
|
||||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
||||
|
||||
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
|
||||
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
|
||||
# and drops temperature/top_p/top_k).
|
||||
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7")
|
||||
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
|
||||
|
||||
# Models where temperature/top_p/top_k return 400 if set to non-default values.
|
||||
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
|
||||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7")
|
||||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
||||
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||
|
||||
# ── Max output token limits per Anthropic model ───────────────────────
|
||||
@@ -94,6 +94,8 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
||||
# starves thinking-enabled models (thinking tokens count toward the limit).
|
||||
_ANTHROPIC_OUTPUT_LIMITS = {
|
||||
# Claude 4.8
|
||||
"claude-opus-4-8": 128_000,
|
||||
# Claude 4.7
|
||||
"claude-opus-4-7": 128_000,
|
||||
# Claude 4.6
|
||||
@@ -892,20 +894,6 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def read_claude_managed_key() -> Optional[str]:
|
||||
"""Read Claude's native managed key from ~/.claude.json for diagnostics only."""
|
||||
claude_json = Path.home() / ".claude.json"
|
||||
if claude_json.exists():
|
||||
try:
|
||||
data = json.loads(claude_json.read_text(encoding="utf-8"))
|
||||
primary_key = data.get("primaryApiKey", "")
|
||||
if isinstance(primary_key, str) and primary_key.strip():
|
||||
return primary_key.strip()
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read ~/.claude.json: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
||||
"""Check if Claude Code credentials have a non-expired access token."""
|
||||
import time
|
||||
@@ -1254,10 +1242,16 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
print()
|
||||
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
print(" (Browser opened automatically)")
|
||||
from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui
|
||||
except Exception:
|
||||
pass
|
||||
_can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior
|
||||
|
||||
if _can_open_gui():
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
print(" (Browser opened automatically)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print()
|
||||
print("After authorizing, you'll see a code. Paste it below.")
|
||||
|
||||
+161
-30
@@ -700,12 +700,20 @@ class _CodexCompletionsAdapter:
|
||||
# xAI's Responses endpoint rejects ``pattern`` and ``format`` JSON Schema
|
||||
# keywords (HTTP 400). Strip them here to match the parity guarantee that
|
||||
# chat_completion_helpers.py provides for the main-agent xAI path.
|
||||
#
|
||||
# Deep-copy before sanitizing — ``list(tools)`` is only a shallow
|
||||
# copy of the outer list, but the sanitizers mutate the inner
|
||||
# parameter dicts in place. Without a deep copy the caller's
|
||||
# tool registry permanently loses its slash-containing enum
|
||||
# constraints after the first auxiliary xAI call. See #27907.
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools, _ = strip_pattern_and_format(list(tools))
|
||||
tools = _copy.deepcopy(list(tools))
|
||||
tools, _ = strip_pattern_and_format(tools)
|
||||
tools, _ = strip_slash_enum(tools)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -828,7 +836,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 []):
|
||||
@@ -1235,8 +1243,23 @@ def _read_nous_auth() -> Optional[dict]:
|
||||
|
||||
|
||||
def _nous_api_key(provider: dict) -> str:
|
||||
"""Extract the Nous runtime credential from the compatibility field."""
|
||||
return provider.get("agent_key") or provider.get("access_token", "")
|
||||
"""Extract a usable Nous inference JWT from stored auth state."""
|
||||
from hermes_cli.auth import _nous_invoke_jwt_is_usable
|
||||
|
||||
for token_key, expiry_key in (
|
||||
("agent_key", "agent_key_expires_at"),
|
||||
("access_token", "expires_at"),
|
||||
):
|
||||
token = provider.get(token_key)
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
continue
|
||||
if _nous_invoke_jwt_is_usable(
|
||||
token,
|
||||
scope=provider.get("scope"),
|
||||
expires_at=provider.get(expiry_key),
|
||||
):
|
||||
return token
|
||||
return ""
|
||||
|
||||
|
||||
def _nous_base_url() -> str:
|
||||
@@ -1248,25 +1271,16 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[
|
||||
"""Return fresh Nous runtime credentials when available.
|
||||
|
||||
This mirrors the main agent's 401 recovery path and keeps auxiliary
|
||||
clients aligned with the singleton auth store + JWT/mint flow instead of
|
||||
clients aligned with the singleton auth store + JWT refresh flow instead of
|
||||
relying only on whatever raw tokens happen to be sitting in auth.json
|
||||
or the credential pool.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
inference_auth_mode=(
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
if force_refresh
|
||||
else NOUS_INFERENCE_AUTH_MODE_AUTO
|
||||
),
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary Nous runtime credential resolution failed: %s", exc)
|
||||
@@ -1550,13 +1564,9 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
_mark_provider_unhealthy("nous", ttl=60)
|
||||
return None, None
|
||||
if runtime is None and nous:
|
||||
# Runtime credential mint failed but stored Nous auth is still present.
|
||||
# Falls back to the raw stored token below; surface a debug line so
|
||||
# operators investigating expired/invalid sessions have a breadcrumb,
|
||||
# without blocking the fallback path the rest of this function relies on.
|
||||
logger.debug(
|
||||
"Auxiliary Nous: runtime credential mint failed; falling back to "
|
||||
"stored auth.json token."
|
||||
"Auxiliary Nous: runtime JWT refresh failed; checking stored "
|
||||
"auth.json token."
|
||||
)
|
||||
global auxiliary_is_nous
|
||||
auxiliary_is_nous = True
|
||||
@@ -1594,6 +1604,13 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
api_key, base_url = runtime
|
||||
else:
|
||||
api_key = _nous_api_key(nous or {})
|
||||
if not api_key:
|
||||
logger.warning(
|
||||
"Auxiliary Nous client unavailable: no usable inference JWT found "
|
||||
"(run: hermes auth add nous)."
|
||||
)
|
||||
_mark_provider_unhealthy("nous", ttl=60)
|
||||
return None, None
|
||||
base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/")
|
||||
return (
|
||||
OpenAI(
|
||||
@@ -2244,11 +2261,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 +2281,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 +2321,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
|
||||
@@ -2337,7 +2374,16 @@ def _is_auth_error(exc: Exception) -> bool:
|
||||
if status == 401:
|
||||
return True
|
||||
err_lower = str(exc).lower()
|
||||
return "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower()
|
||||
if "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower():
|
||||
return True
|
||||
# xAI returns HTTP 403 with "unauthenticated:bad-credentials" when an OAuth2
|
||||
# access token has expired or is invalid — semantically a 401 auth failure,
|
||||
# even though the status code is 403 (PermissionDenied).
|
||||
if status == 403 and "bad-credentials" in err_lower:
|
||||
return True
|
||||
if "unauthenticated" in err_lower and "bad-credentials" in err_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_unsupported_parameter_error(exc: Exception, param: str) -> bool:
|
||||
@@ -2490,6 +2536,8 @@ def _recoverable_pool_provider(
|
||||
return "copilot"
|
||||
if base_url_host_matches(base, "api.kimi.com"):
|
||||
return "kimi-coding"
|
||||
if base_url_host_matches(base, "api.x.ai"):
|
||||
return "xai-oauth"
|
||||
# For api_key providers not in the hardcoded list (e.g. opencode-go), match
|
||||
# the client base URL against all registered api_key providers so that
|
||||
# credential-pool rotation works for any provider the user configured.
|
||||
@@ -2686,15 +2734,11 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "nous":
|
||||
from hermes_cli.auth import (
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
force_refresh=True,
|
||||
)
|
||||
if not str(creds.get("api_key", "") or "").strip():
|
||||
return False
|
||||
@@ -2711,6 +2755,24 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "xai-oauth":
|
||||
# Preference: pool-level refresh (uses refresh_token from pool entry),
|
||||
# then fall back to singleton auth-store resolver.
|
||||
pool = load_pool(normalized)
|
||||
if pool and pool.has_credentials():
|
||||
# Ensure a current entry is selected before trying to refresh.
|
||||
pool.select()
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is not None and str(getattr(refreshed, "runtime_api_key", "") or "").strip():
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
|
||||
|
||||
creds = resolve_xai_oauth_runtime_credentials(force_refresh=True)
|
||||
if not str(creds.get("api_key", "") or "").strip():
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc)
|
||||
return False
|
||||
@@ -4937,6 +4999,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",
|
||||
@@ -5339,6 +5436,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
|
||||
|
||||
@@ -1167,18 +1167,6 @@ def _extract_provider_from_arn(arn: str) -> str:
|
||||
"""
|
||||
match = re.search(r"foundation-model/([^.]+)", arn)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def get_bedrock_model_ids(region: str) -> List[str]:
|
||||
"""Return a flat list of available Bedrock model IDs for the given region.
|
||||
|
||||
Convenience wrapper around ``discover_bedrock_models()`` for use in
|
||||
the model selection UI.
|
||||
"""
|
||||
models = discover_bedrock_models(region)
|
||||
return [m["id"] for m in models]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classification — Bedrock-specific exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -186,37 +186,6 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]:
|
||||
return None
|
||||
|
||||
|
||||
def get_active_browser_provider() -> Optional[BrowserProvider]:
|
||||
"""Resolve the currently-active cloud browser provider.
|
||||
|
||||
Reads ``browser.cloud_provider`` from config.yaml; falls back per the
|
||||
module docstring. Returns None for local mode or when no provider is
|
||||
available.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {})
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read browser config: %s", exc)
|
||||
browser_cfg = {}
|
||||
|
||||
configured: Optional[str] = None
|
||||
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
|
||||
try:
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
|
||||
configured = normalize_browser_cloud_provider(
|
||||
browser_cfg.get("cloud_provider")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("normalize_browser_cloud_provider failed: %s", exc)
|
||||
configured = None
|
||||
|
||||
return _resolve(configured)
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the registry. **Test-only.**"""
|
||||
with _lock:
|
||||
|
||||
@@ -15,49 +15,23 @@ sites unchanged. Symbols that tests patch on ``run_agent`` (e.g.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
from agent.message_sanitization import (
|
||||
_sanitize_surrogates,
|
||||
_sanitize_messages_surrogates,
|
||||
_sanitize_structure_surrogates,
|
||||
_sanitize_messages_non_ascii,
|
||||
_sanitize_tools_non_ascii,
|
||||
_sanitize_structure_non_ascii,
|
||||
_strip_images_from_messages,
|
||||
_strip_non_ascii,
|
||||
_repair_tool_call_arguments,
|
||||
_escape_invalid_chars_in_json_strings,
|
||||
)
|
||||
from agent.tool_dispatch_helpers import (
|
||||
_is_multimodal_tool_result,
|
||||
_multimodal_text_summary,
|
||||
)
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.tool_guardrails import (
|
||||
ToolGuardrailDecision,
|
||||
append_toolguard_guidance,
|
||||
toolguard_synthetic_result,
|
||||
)
|
||||
from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
@@ -129,6 +103,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):
|
||||
"""
|
||||
@@ -157,13 +149,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# #29507: dispatch on the calling thread.
|
||||
#
|
||||
@@ -256,32 +241,96 @@ 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
|
||||
|
||||
# No-byte TTFB cutoff. The OpenAI SDK's own streaming read timeout is far
|
||||
# longer (openai 2.x DEFAULT_TIMEOUT.read = 600s), so a tight 12s default
|
||||
# killed subscription-backed Codex requests mid-prefill before the backend
|
||||
# had a chance to emit its first SSE event. Default to 120s — long enough to
|
||||
# clear normal backend admission / prompt prefill, short enough to still
|
||||
# reconnect promptly when the socket is genuinely wedged. Set
|
||||
# HERMES_CODEX_TTFB_TIMEOUT_SECONDS=0 to disable this watchdog entirely.
|
||||
_ttfb_enabled = _codex_watchdog_enabled
|
||||
_ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 120.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", 120.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 +377,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 +410,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 +467,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."
|
||||
@@ -500,12 +588,23 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
# It also rejects ``enum`` values containing ``/`` (HuggingFace IDs
|
||||
# like ``Qwen/Qwen3.5-0.8B`` shipped by MCP servers) — same 400 with
|
||||
# the same opaque message; strip those enums too.
|
||||
#
|
||||
# Deep-copy ``tools_for_api`` before sanitizing: the sanitizers
|
||||
# mutate in place (documented contract on ``strip_slash_enum`` /
|
||||
# ``strip_pattern_and_format``), and ``tools_for_api`` is a direct
|
||||
# reference to ``agent.tools``. Without the copy, the first xAI
|
||||
# request permanently strips constraints from the shared per-agent
|
||||
# tool registry — every subsequent non-xAI call from the same
|
||||
# agent (auxiliary task routed to Anthropic, OpenRouter fallback,
|
||||
# main-model swap) sees the already-stripped schema. See #27907.
|
||||
if is_xai_responses:
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools_for_api = _copy.deepcopy(tools_for_api)
|
||||
tools_for_api, _ = strip_pattern_and_format(tools_for_api)
|
||||
tools_for_api, _ = strip_slash_enum(tools_for_api)
|
||||
except Exception as exc:
|
||||
@@ -1042,6 +1141,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.
|
||||
@@ -1129,7 +1247,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}"
|
||||
)
|
||||
@@ -1503,13 +1621,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# See #29507 explanation in the non-streaming variant above. A
|
||||
# stranger thread (the interrupt-check / stale-stream detector loop)
|
||||
@@ -2118,7 +2229,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 +2336,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(
|
||||
@@ -960,6 +980,48 @@ def _extract_responses_reasoning_text(item: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _format_responses_error(error_obj: Any, response_status: str) -> str:
|
||||
"""Build a human-readable error string from a Responses ``response.error`` payload.
|
||||
|
||||
The OpenAI Responses API carries failure details under ``response.error``
|
||||
on terminal ``response.failed`` events, in the shape
|
||||
``{"code": "rate_limit_exceeded", "message": "Slow down", "param": ...}``.
|
||||
Earlier code only surfaced ``message``, which left users staring at bare
|
||||
strings like ``"Slow down"`` while the failure mode (rate limit vs
|
||||
context-length vs internal_error vs model-overloaded) was hidden in
|
||||
``code``. We now prefix ``code`` when both are present so consumers can
|
||||
distinguish failure modes without parsing the bare message.
|
||||
|
||||
Falls back to ``code`` alone when ``message`` is empty, and to a stable
|
||||
default referencing the response status when no error payload is
|
||||
available at all. Adapted from anomalyco/opencode#28757.
|
||||
"""
|
||||
# Pull code and message from either dict or attribute-style payloads.
|
||||
code: Any = None
|
||||
message: Any = None
|
||||
if isinstance(error_obj, dict):
|
||||
code = error_obj.get("code")
|
||||
message = error_obj.get("message")
|
||||
elif error_obj is not None:
|
||||
code = getattr(error_obj, "code", None)
|
||||
message = getattr(error_obj, "message", None)
|
||||
|
||||
code_str = str(code).strip() if isinstance(code, str) else (str(code).strip() if code else "")
|
||||
message_str = str(message).strip() if isinstance(message, str) else (str(message).strip() if message else "")
|
||||
|
||||
if code_str and message_str:
|
||||
return f"{code_str}: {message_str}"
|
||||
if message_str:
|
||||
return message_str
|
||||
if code_str:
|
||||
return code_str
|
||||
if error_obj:
|
||||
# Last-resort: stringify whatever the provider sent so it's at least
|
||||
# visible in logs/UI rather than silently swallowed.
|
||||
return str(error_obj)
|
||||
return f"Responses API returned status '{response_status}'"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full response normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1003,10 +1065,7 @@ def _normalize_codex_response(
|
||||
|
||||
if response_status in {"failed", "cancelled"}:
|
||||
error_obj = getattr(response, "error", None)
|
||||
if isinstance(error_obj, dict):
|
||||
error_msg = error_obj.get("message") or str(error_obj)
|
||||
else:
|
||||
error_msg = str(error_obj) if error_obj else f"Responses API returned status '{response_status}'"
|
||||
error_msg = _format_responses_error(error_obj, response_status)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
content_parts: List[str] = []
|
||||
|
||||
@@ -16,7 +16,6 @@ compatibility.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
+244
-16
@@ -75,6 +75,44 @@ _IMAGE_TOKEN_ESTIMATE = 1600
|
||||
_IMAGE_CHAR_EQUIVALENT = _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
|
||||
# Hard ceiling for the deterministic summary-failure handoff. The fallback is
|
||||
# only meant to preserve continuity anchors from the dropped window, not to
|
||||
# become another unbounded transcript copy after the LLM summarizer failed.
|
||||
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
|
||||
_FALLBACK_TURN_MAX_CHARS = 700
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
|
||||
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
|
||||
value = value.strip()
|
||||
if value and value not in items and len(items) < limit:
|
||||
items.append(value)
|
||||
|
||||
|
||||
def _extract_tool_call_name_and_args(tool_call: Any) -> tuple[str, str]:
|
||||
"""Return a best-effort ``(name, arguments)`` pair for dict/object tool calls."""
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function") or {}
|
||||
return str(fn.get("name") or "unknown"), str(fn.get("arguments") or "")
|
||||
|
||||
fn = getattr(tool_call, "function", None)
|
||||
if fn is None:
|
||||
return "unknown", ""
|
||||
return str(getattr(fn, "name", None) or "unknown"), str(getattr(fn, "arguments", None) or "")
|
||||
|
||||
|
||||
def _extract_tool_call_id(tool_call: Any) -> str:
|
||||
if isinstance(tool_call, dict):
|
||||
return str(tool_call.get("id") or "")
|
||||
return str(getattr(tool_call, "id", "") or "")
|
||||
|
||||
|
||||
def _collect_path_mentions(text: str, relevant_files: list[str], *, limit: int = 12) -> None:
|
||||
for match in _PATH_MENTION_RE.findall(text):
|
||||
_dedupe_append(relevant_files, match.rstrip(".,:;"), limit=limit)
|
||||
|
||||
|
||||
def _content_length_for_budget(raw_content: Any) -> int:
|
||||
"""Return the effective char-length of a message's content for token budgeting.
|
||||
@@ -537,8 +575,8 @@ class ContextCompressor(ContextEngine):
|
||||
self.quiet_mode = quiet_mode
|
||||
# When True, summary-generation failure aborts compression entirely
|
||||
# (returns messages unchanged, sets _last_compress_aborted=True).
|
||||
# When False (default = historical behavior), insert a static
|
||||
# "summary unavailable" placeholder and drop the middle window.
|
||||
# When False (default = historical behavior), insert a
|
||||
# deterministic "summary unavailable" handoff and drop the middle window.
|
||||
self.abort_on_summary_failure = abort_on_summary_failure
|
||||
|
||||
self.context_length = get_model_context_length(
|
||||
@@ -884,6 +922,195 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _build_static_fallback_summary(
|
||||
self,
|
||||
turns_to_summarize: List[Dict[str, Any]],
|
||||
reason: str | None = None,
|
||||
) -> str:
|
||||
"""Build a deterministic handoff when the LLM summarizer is unavailable.
|
||||
|
||||
This is intentionally much less rich than an LLM-written summary, but it
|
||||
is still better than a bare "N messages were removed" marker. It keeps
|
||||
the most useful continuity anchors that can be extracted locally:
|
||||
recent user asks, assistant/tool actions, files/commands mentioned in
|
||||
tool calls, and any error text. The result uses the normal summary
|
||||
structure so downstream prompts can recover gracefully after a provider
|
||||
outage or summary-model failure.
|
||||
"""
|
||||
user_asks: list[str] = []
|
||||
assistant_actions: list[str] = []
|
||||
tool_actions: list[str] = []
|
||||
relevant_files: list[str] = []
|
||||
blockers: list[str] = []
|
||||
last_dropped_turns: list[str] = []
|
||||
|
||||
def _compact_fallback_turn(value: Any) -> str:
|
||||
text = redact_sensitive_text(_content_text_for_contains(value))
|
||||
text = re.sub(r"\bgh[pousr]_[A-Za-z0-9_]{8,}\b", "[REDACTED]", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) > _FALLBACK_TURN_MAX_CHARS:
|
||||
text = text[: _FALLBACK_TURN_MAX_CHARS - 15].rstrip() + " ...[truncated]"
|
||||
return re.sub(r"\bgh[pousr]_[A-Za-z0-9_.-]+", "[REDACTED]", text)
|
||||
|
||||
def _remember_dropped_turn(label: str, text: str, *, limit: int = 8) -> None:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
last_dropped_turns.append(f"{label}: {text}")
|
||||
if len(last_dropped_turns) > limit:
|
||||
del last_dropped_turns[0]
|
||||
|
||||
def _collect_paths_from_jsonish(obj: Any) -> None:
|
||||
if isinstance(obj, dict):
|
||||
for key, val in obj.items():
|
||||
if key in {"path", "workdir", "file_path", "output_path"} and isinstance(val, str):
|
||||
_dedupe_append(relevant_files, val, limit=12)
|
||||
_collect_paths_from_jsonish(val)
|
||||
elif isinstance(obj, list):
|
||||
for val in obj:
|
||||
_collect_paths_from_jsonish(val)
|
||||
elif isinstance(obj, str):
|
||||
_collect_path_mentions(obj, relevant_files)
|
||||
|
||||
call_id_to_tool: dict[str, tuple[str, str]] = {}
|
||||
for msg in turns_to_summarize:
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, raw_args = _extract_tool_call_name_and_args(tc)
|
||||
args = redact_sensitive_text(raw_args)
|
||||
call_id = _extract_tool_call_id(tc)
|
||||
if call_id:
|
||||
call_id_to_tool[call_id] = (name, args)
|
||||
if args:
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
except Exception:
|
||||
parsed = args
|
||||
_collect_paths_from_jsonish(parsed)
|
||||
|
||||
for msg in turns_to_summarize:
|
||||
role = msg.get("role", "unknown")
|
||||
text = _compact_fallback_turn(msg.get("content"))
|
||||
_collect_path_mentions(text, relevant_files)
|
||||
|
||||
turn_text = text
|
||||
turn_tool_names: list[str] = []
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, _args = _extract_tool_call_name_and_args(tc)
|
||||
turn_tool_names.append(name)
|
||||
if turn_tool_names:
|
||||
prefix = "tool calls: " + ", ".join(turn_tool_names[:6])
|
||||
turn_text = f"{prefix}; {turn_text}" if turn_text else prefix
|
||||
_remember_dropped_turn(str(role).upper(), turn_text)
|
||||
|
||||
if len(text) > 600:
|
||||
text = text[:420].rstrip() + " ... " + text[-160:].lstrip()
|
||||
|
||||
if role == "user" and text:
|
||||
user_asks.append(text)
|
||||
elif role == "assistant":
|
||||
tool_names: list[str] = []
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, _args = _extract_tool_call_name_and_args(tc)
|
||||
tool_names.append(name)
|
||||
if tool_names:
|
||||
assistant_actions.append(
|
||||
"Called tool(s): " + ", ".join(tool_names[:6])
|
||||
)
|
||||
elif text:
|
||||
assistant_actions.append(text)
|
||||
elif role == "tool":
|
||||
call_id = str(msg.get("tool_call_id") or "")
|
||||
tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", ""))
|
||||
tool_actions.append(
|
||||
_summarize_tool_result(tool_name, tool_args, text or "")
|
||||
)
|
||||
if re.search(
|
||||
r"\b(error|failed|exception|traceback|timeout|timed out|fatal)\b",
|
||||
text,
|
||||
re.I,
|
||||
):
|
||||
blockers.append(text[:500])
|
||||
|
||||
def _bullets(items: list[str], limit: int = 8) -> str:
|
||||
unique: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
unique.append(item)
|
||||
if len(unique) >= limit:
|
||||
break
|
||||
return "\n".join(f"- {item}" for item in unique) if unique else "None."
|
||||
|
||||
completed: list[str] = []
|
||||
for idx, item in enumerate((assistant_actions + tool_actions)[:12], start=1):
|
||||
completed.append(f"{idx}. {item}")
|
||||
|
||||
active_task = (
|
||||
f"User asked: {user_asks[-1]!r}"
|
||||
if user_asks
|
||||
else "Unknown from deterministic fallback."
|
||||
)
|
||||
previous_summary_note = ""
|
||||
if self._previous_summary:
|
||||
previous_summary_note = (
|
||||
"\n\nPrevious compaction summary was present and should still be treated as "
|
||||
"background continuity context, but the latest LLM summary update failed."
|
||||
)
|
||||
|
||||
reason_text = f" Summary failure reason: {reason}." if reason else ""
|
||||
body = f"""## Active Task
|
||||
{active_task}
|
||||
|
||||
## Goal
|
||||
Recovered from a deterministic fallback because the LLM context summarizer was unavailable. Continue from the protected recent messages after this summary and use current file/system state for exact details.{previous_summary_note}
|
||||
|
||||
## Constraints & Preferences
|
||||
- This fallback was generated locally without an LLM summary call.
|
||||
- Secrets and credentials were redacted before preservation.
|
||||
- The summary may be incomplete; prefer verifying current files, git state, processes, and test results instead of assuming omitted details.
|
||||
|
||||
## Completed Actions
|
||||
{chr(10).join(completed) if completed else "None recoverable from compacted turns."}
|
||||
|
||||
## Active State
|
||||
Unknown from deterministic fallback. Inspect current repository/session state if needed.
|
||||
|
||||
## In Progress
|
||||
{active_task}
|
||||
|
||||
## Blocked
|
||||
{_bullets(blockers, limit=5)}
|
||||
|
||||
## Key Decisions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Resolved Questions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Pending User Asks
|
||||
{active_task}
|
||||
|
||||
## Relevant Files
|
||||
{_bullets(relevant_files, limit=12)}
|
||||
|
||||
## Remaining Work
|
||||
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
|
||||
|
||||
## Last Dropped Turns
|
||||
{_bullets(last_dropped_turns, limit=8)}
|
||||
|
||||
## Critical Context
|
||||
Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}"""
|
||||
summary = self._with_summary_prefix(redact_sensitive_text(body.strip()))
|
||||
if len(summary) > _FALLBACK_SUMMARY_MAX_CHARS:
|
||||
summary = summary[: _FALLBACK_SUMMARY_MAX_CHARS - 42].rstrip() + "\n...[fallback summary truncated]"
|
||||
return summary
|
||||
|
||||
def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None:
|
||||
"""Switch from a separate ``summary_model`` back to the main model.
|
||||
|
||||
@@ -911,7 +1138,11 @@ class ContextCompressor(ContextEngine):
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately
|
||||
|
||||
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:
|
||||
def _generate_summary(
|
||||
self,
|
||||
turns_to_summarize: List[Dict[str, Any]],
|
||||
focus_topic: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Generate a structured summary of conversation turns.
|
||||
|
||||
Uses a structured template (Goal, Progress, Decisions, Resolved/Pending
|
||||
@@ -1608,9 +1839,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
# True → ABORT compression entirely. Return messages unchanged
|
||||
# and set _last_compress_aborted=True so callers can warn
|
||||
# the user and stop the auto-compress retry loop.
|
||||
# False → Fall through to the legacy fallback path below: insert
|
||||
# a static "summary unavailable" placeholder and drop the
|
||||
# middle window. Records _last_summary_fallback_used /
|
||||
# False → Fall through to the default fallback path below: insert
|
||||
# a deterministic "summary unavailable" handoff and drop
|
||||
# the middle window. Records _last_summary_fallback_used /
|
||||
# _last_summary_dropped_count for gateway hygiene to
|
||||
# surface a warning.
|
||||
# Default is False (historical behavior).
|
||||
@@ -1643,21 +1874,18 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
compressed.append(msg)
|
||||
|
||||
# Legacy fallback path: LLM summary failed and abort_on_summary_failure
|
||||
# is False (the default). Insert a static placeholder so the model
|
||||
# knows context was lost rather than silently dropping everything.
|
||||
# If LLM summary failed, insert a deterministic fallback so the model
|
||||
# gets at least locally recoverable continuity anchors instead of a
|
||||
# content-free "N messages were removed" marker.
|
||||
if not summary:
|
||||
if not self.quiet_mode:
|
||||
logger.warning("Summary generation failed — inserting static fallback context marker")
|
||||
logger.warning("Summary generation failed — inserting deterministic fallback context summary")
|
||||
n_dropped = compress_end - compress_start
|
||||
self._last_summary_dropped_count = n_dropped
|
||||
self._last_summary_fallback_used = True
|
||||
summary = (
|
||||
f"{SUMMARY_PREFIX}\n"
|
||||
f"Summary generation was unavailable. {n_dropped} message(s) were "
|
||||
f"removed to free context space but could not be summarized. The removed "
|
||||
f"messages contained earlier work in this session. Continue based on the "
|
||||
f"recent messages below and the current state of any files or resources."
|
||||
summary = self._build_static_fallback_summary(
|
||||
turns_to_summarize,
|
||||
reason=self._last_summary_error,
|
||||
)
|
||||
|
||||
_merge_summary_into_tail = False
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,13 +34,33 @@ import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
The pid+tid prefix lets ops tell crashed/abandoned holders apart from
|
||||
live ones (expiry-based recovery uses the timestamp, but ``holder``
|
||||
is what shows up in diagnostics + log lines). The agent instance id
|
||||
and a per-acquire uuid disambiguate two co-resident agents on the
|
||||
same thread (background_review forks run on a worker thread, but
|
||||
on machines where compression itself dispatches to a thread pool
|
||||
we want each acquire to be unique).
|
||||
"""
|
||||
import threading
|
||||
return (
|
||||
f"pid={os.getpid()}"
|
||||
f":tid={threading.get_ident()}"
|
||||
f":agent={id(agent):x}"
|
||||
f":nonce={uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
|
||||
|
||||
def check_compression_model_feasibility(agent: Any) -> None:
|
||||
"""Warn at session start if the auxiliary compression model's context
|
||||
window is smaller than the main model's compression threshold.
|
||||
@@ -305,6 +325,103 @@ def compress_context(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
# AIAgent instances that share the same session_id (most commonly the
|
||||
# parent-turn agent and its background-review fork — see
|
||||
# ``agent/background_review.py``: ``review_agent.session_id =
|
||||
# agent.session_id``) can each call compress() on overlapping
|
||||
# snapshots of the same conversation. Both succeed, both rotate
|
||||
# ``agent.session_id`` to a fresh id, both create child sessions in
|
||||
# state.db parented to the same old id. The gateway's SessionEntry
|
||||
# only catches one rotation, so the other child becomes an orphan
|
||||
# that silently accumulates writes — Damien's repro shape.
|
||||
#
|
||||
# Acquire keyed on the OLD session_id (the rotation target's parent),
|
||||
# because that's the id that competing paths see and read from
|
||||
# SessionEntry at the start of their own compression attempt.
|
||||
#
|
||||
# If we can't acquire the lock, another path is mid-compression on
|
||||
# this session. Aborting is correct: the messages are unchanged, the
|
||||
# other path's rotation will produce the canonical new session_id,
|
||||
# and our caller's auto-compress loop sees ``len(returned) == len(input)``
|
||||
# and stops retrying for this cycle. The session is NOT corrupted —
|
||||
# we just sit out this round and let the winner finish.
|
||||
_lock_db = getattr(agent, "_session_db", None)
|
||||
_lock_sid = agent.session_id or ""
|
||||
_lock_holder: Optional[str] = None
|
||||
# Probe whether the lock subsystem is actually available on this
|
||||
# SessionDB instance. A process running mismatched module versions
|
||||
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
|
||||
# long-lived ``hermes_state.SessionDB`` class still bound to the
|
||||
# pre-#34351 version in memory) has the call site but not the method.
|
||||
# In that case ``try_acquire_compression_lock`` raises AttributeError —
|
||||
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
|
||||
# runs and the exception propagates to the outer agent loop, which
|
||||
# prints the error and retries. Because compression never succeeds,
|
||||
# the token count never drops and the loop re-triggers compaction
|
||||
# forever (the "API call #47/#48/#49 ... has no attribute
|
||||
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
|
||||
# subsystem is missing or broken in any unexpected way, skip locking
|
||||
# and proceed with compression. Skipping the lock risks a rare
|
||||
# concurrent-compression session fork; an infinite no-progress loop
|
||||
# that never compresses at all is strictly worse.
|
||||
if _lock_db is not None and _lock_sid:
|
||||
_lock_holder = _compression_lock_holder(agent)
|
||||
try:
|
||||
_lock_acquired = _lock_db.try_acquire_compression_lock(
|
||||
_lock_sid, _lock_holder
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# Broken/absent lock subsystem (version skew, etc.). Log once
|
||||
# per session and proceed WITHOUT the lock rather than letting
|
||||
# the exception spin the outer loop.
|
||||
_lock_holder = None # we don't own anything to release
|
||||
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_error_sid = _lock_sid
|
||||
logger.warning(
|
||||
"compression lock subsystem unavailable for session=%s "
|
||||
"(%s: %s) — proceeding without lock. This usually means a "
|
||||
"stale in-memory module after an update; restart the "
|
||||
"process (or `hermes update`) to resync.",
|
||||
_lock_sid, type(_lock_err).__name__, _lock_err,
|
||||
)
|
||||
_lock_acquired = True # treat as acquired-but-unlocked; proceed
|
||||
if not _lock_acquired:
|
||||
try:
|
||||
existing = _lock_db.get_compression_lock_holder(_lock_sid)
|
||||
except Exception:
|
||||
existing = None
|
||||
logger.warning(
|
||||
"compression skipped: another path is compressing session=%s "
|
||||
"(holder=%s) — returning messages unchanged to avoid session fork",
|
||||
_lock_sid, existing,
|
||||
)
|
||||
_lock_holder = None # don't release a lock we don't own
|
||||
# Surface to the user once — quiet for downstream auto-compress loops
|
||||
if getattr(agent, "_last_compression_lock_warning_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_warning_sid = _lock_sid
|
||||
try:
|
||||
agent._emit_warning(
|
||||
"⚠ Skipping concurrent compression — another path "
|
||||
"is already compressing this session. Will retry "
|
||||
"after it finishes."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
return messages, _existing_sp
|
||||
|
||||
def _release_lock() -> None:
|
||||
"""Release the lock keyed on the OLD session_id (before rotation)."""
|
||||
if _lock_db is not None and _lock_sid and _lock_holder:
|
||||
try:
|
||||
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
|
||||
except Exception as _rel_err:
|
||||
logger.debug("compression lock release failed: %s", _rel_err)
|
||||
|
||||
# Notify external memory provider before compression discards context
|
||||
if agent._memory_manager:
|
||||
try:
|
||||
@@ -318,6 +435,11 @@ def compress_context(
|
||||
# Plugin context engine with strict signature that doesn't accept
|
||||
# focus_topic / force — fall back to calling without them.
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
|
||||
except BaseException:
|
||||
# ANY exception during compress() must release the lock so the
|
||||
# session isn't permanently blocked from future compression.
|
||||
_release_lock()
|
||||
raise
|
||||
|
||||
# If compression aborted (aux LLM failed to produce a usable summary)
|
||||
# the compressor returns the input messages unchanged. Surface the
|
||||
@@ -336,6 +458,7 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_release_lock() # compression aborted — no rotation will happen
|
||||
return messages, _existing_sp
|
||||
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
@@ -421,6 +544,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)
|
||||
@@ -479,6 +603,12 @@ def compress_context(
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
# Release the lock on the OLD session_id only AFTER rotation completed
|
||||
# and all post-rotation bookkeeping (memory manager, context engine,
|
||||
# file dedup) ran. A concurrent path that wakes up the moment we
|
||||
# release will see the NEW session_id in state.db / SessionEntry and
|
||||
# acquire on that — no race against our just-finished work.
|
||||
_release_lock()
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
|
||||
+406
-145
@@ -27,8 +27,6 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
from agent.auxiliary_client import set_runtime_main
|
||||
from agent.codex_responses_adapter import _summarize_user_message_for_log
|
||||
from agent.display import KawaiiSpinner
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
@@ -49,25 +47,17 @@ 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 (
|
||||
clear_nous_rate_limit,
|
||||
is_genuine_nous_rate_limit,
|
||||
nous_rate_limit_remaining,
|
||||
record_nous_rate_limit,
|
||||
)
|
||||
from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.trajectory import has_incomplete_scratchpad
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from hermes_constants import display_hermes_home as _dhh_fn, PARTIAL_STREAM_STUB_ID
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
from hermes_logging import set_session_context
|
||||
from tools.schema_sanitizer import strip_pattern_and_format
|
||||
from tools.skill_provenance import set_current_write_origin
|
||||
from utils import base_url_host_matches, env_var_enabled
|
||||
|
||||
@@ -127,6 +117,104 @@ 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.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=True,
|
||||
)
|
||||
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.
|
||||
|
||||
@@ -310,7 +398,6 @@ def run_conversation(
|
||||
|
||||
# Tag all log records on this thread with the session ID so
|
||||
# ``hermes logs --session <id>`` can filter a single conversation.
|
||||
from hermes_logging import set_session_context
|
||||
set_session_context(agent.session_id)
|
||||
|
||||
# Bind the skill write-origin ContextVar for this thread so tool
|
||||
@@ -319,7 +406,6 @@ def run_conversation(
|
||||
# a foreground user-directed turn. Set at the top of each call;
|
||||
# the review fork runs on its own thread with a fresh context,
|
||||
# so the foreground value here does not leak into it.
|
||||
from tools.skill_provenance import set_current_write_origin
|
||||
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
|
||||
|
||||
# If the previous turn activated fallback, restore the primary
|
||||
@@ -1017,6 +1103,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 +1137,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 +1170,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 +1371,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 +1387,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 +1449,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 +1478,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 +1705,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 +1746,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 +1768,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 +1898,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 +1929,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 +1987,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 +2206,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 +2320,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 +2347,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 +2361,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"
|
||||
@@ -2405,41 +2536,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 +2616,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 +2635,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 +2664,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 +2781,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 +2796,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 +2809,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 +2860,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 +2886,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 +2904,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 +2919,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 +2952,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 +2966,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 +3011,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 +3061,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 +3074,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 +3133,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 +3169,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 +3207,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 +3277,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 +3314,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 +3475,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 +3591,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 +3678,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 +3995,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 +4041,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 +4076,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 +4095,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 +4115,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 +4162,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"
|
||||
@@ -4059,36 +4301,54 @@ def run_conversation(
|
||||
)
|
||||
final_response = agent._handle_max_iterations(messages, api_call_count)
|
||||
|
||||
# If running as a kanban worker, block the task so the dispatcher
|
||||
# knows the worker could not complete (rather than treating it as a
|
||||
# If running as a kanban worker, signal the dispatcher that the
|
||||
# worker could not complete (rather than treating it as a
|
||||
# protocol violation). The agent loop strips tools before calling
|
||||
# _handle_max_iterations, so the model cannot call kanban_block
|
||||
# itself — we must do it on its behalf.
|
||||
#
|
||||
# We route through ``_record_task_failure(outcome="timed_out")``
|
||||
# rather than ``kanban_block`` so this counts toward the
|
||||
# ``consecutive_failures`` counter and the dispatcher's
|
||||
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
|
||||
# a task whose worker keeps exhausting its budget would block
|
||||
# silently each run, get auto-promoted by the operator (or never
|
||||
# surface), and re-block in an endless loop with no signal.
|
||||
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if _kanban_task:
|
||||
try:
|
||||
_ra().handle_function_call(
|
||||
"kanban_block",
|
||||
{
|
||||
"task_id": _kanban_task,
|
||||
"reason": (
|
||||
from hermes_cli import kanban_db as _kb
|
||||
_conn = _kb.connect()
|
||||
try:
|
||||
_kb._record_task_failure(
|
||||
_conn,
|
||||
_kanban_task,
|
||||
error=(
|
||||
f"Iteration budget exhausted "
|
||||
f"({api_call_count}/{agent.max_iterations}) — "
|
||||
"task could not complete within the allowed "
|
||||
"iterations"
|
||||
),
|
||||
},
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
logger.info(
|
||||
"kanban_block called for task %s after iteration "
|
||||
"exhaustion (%d/%d)",
|
||||
_kanban_task, api_call_count, agent.max_iterations,
|
||||
)
|
||||
outcome="timed_out",
|
||||
release_claim=True,
|
||||
end_run=True,
|
||||
event_payload_extra={
|
||||
"budget_used": api_call_count,
|
||||
"budget_max": agent.max_iterations,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"recorded budget-exhausted failure for task %s (%d/%d)",
|
||||
_kanban_task, api_call_count, agent.max_iterations,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to call kanban_block after iteration "
|
||||
"exhaustion for task %s",
|
||||
"Failed to record budget-exhausted failure for task %s",
|
||||
_kanban_task,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -4306,6 +4566,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
|
||||
|
||||
+141
-22
@@ -14,7 +14,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.config import get_env_value, load_env
|
||||
from hermes_cli.config import load_env
|
||||
from agent.credential_persistence import (
|
||||
is_borrowed_credential_source,
|
||||
sanitize_borrowed_credential_payload,
|
||||
@@ -22,7 +22,6 @@ from agent.credential_persistence import (
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
PROVIDER_REGISTRY,
|
||||
_auth_store_lock,
|
||||
_codex_access_token_is_expiring,
|
||||
@@ -55,6 +54,38 @@ def _load_config_safe() -> Optional[dict]:
|
||||
|
||||
STATUS_OK = "ok"
|
||||
STATUS_EXHAUSTED = "exhausted"
|
||||
# Terminal failure — the credential will never recover on its own. Used for
|
||||
# upstream-permanent OAuth states like ``token_invalidated`` / ``token_revoked``
|
||||
# where retrying after a TTL cooldown is guaranteed to fail. ``DEAD`` entries
|
||||
# are excluded from rotation unconditionally and only clear when an explicit
|
||||
# write-side sync (e.g. ``_save_codex_tokens`` after a fresh device-code
|
||||
# login) rewrites the tokens.
|
||||
STATUS_DEAD = "dead"
|
||||
|
||||
# OAuth error reasons that indicate the credential is permanently invalid
|
||||
# server-side and cannot be recovered by retry/refresh. Sourced from
|
||||
# OpenAI Codex Responses API, Anthropic, xAI, and Google OAuth spec.
|
||||
_TERMINAL_AUTH_REASONS = frozenset({
|
||||
"token_invalidated", # OpenAI Codex: "Your authentication token has been invalidated."
|
||||
"token_revoked", # OAuth 2.0 RFC 7009: token explicitly revoked
|
||||
"invalid_token", # RFC 6750: bearer token is malformed/expired/revoked
|
||||
"invalid_grant", # RFC 6749: refresh_token rejected during refresh
|
||||
"unauthorized_client", # RFC 6749: client no longer authorized
|
||||
"refresh_token_reused", # Single-use refresh token consumed by another process
|
||||
})
|
||||
|
||||
# How long a DEAD manual credential is preserved before being pruned.
|
||||
# Manual entries (``manual:*``) are independent credentials with no singleton
|
||||
# to re-seed from, so pruning them after a quiet window cleans up dead state
|
||||
# without losing recoverability — the user always has the option to re-add
|
||||
# via ``hermes auth add``.
|
||||
#
|
||||
# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)
|
||||
# are NOT pruned because ``_seed_from_singletons`` would just re-create them
|
||||
# on the next ``load_pool()`` with the same stale singleton tokens, defeating
|
||||
# the cleanup. They remain in the pool marked DEAD until an explicit re-auth
|
||||
# write-side sync (``_save_codex_tokens`` etc.) clears the status.
|
||||
DEAD_MANUAL_PRUNE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
||||
|
||||
AUTH_TYPE_OAUTH = "oauth"
|
||||
AUTH_TYPE_API_KEY = "api_key"
|
||||
@@ -171,8 +202,22 @@ class PooledCredential:
|
||||
def runtime_api_key(self) -> str:
|
||||
if self.provider == "nous":
|
||||
# Nous stores the runtime inference credential in agent_key for
|
||||
# compatibility. It may be a NAS invoke JWT or legacy opaque key.
|
||||
return str(self.agent_key or self.access_token or "")
|
||||
# compatibility. It must be a NAS invoke JWT.
|
||||
for token, expires_at in (
|
||||
(self.agent_key, self.agent_key_expires_at),
|
||||
(self.access_token, self.expires_at),
|
||||
):
|
||||
if (
|
||||
isinstance(token, str)
|
||||
and token.strip()
|
||||
and auth_mod._nous_invoke_jwt_is_usable(
|
||||
token,
|
||||
scope=getattr(self, "scope", None),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
):
|
||||
return token.strip()
|
||||
return ""
|
||||
return str(self.access_token or "")
|
||||
|
||||
@property
|
||||
@@ -438,6 +483,29 @@ class CredentialPool:
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
)
|
||||
|
||||
def _is_terminal_auth_failure(
|
||||
self,
|
||||
status_code: Optional[int],
|
||||
normalized_error: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Detect upstream-permanent OAuth failures that won't recover on TTL.
|
||||
|
||||
Only fires for 401 responses whose error code/reason matches a known
|
||||
terminal OAuth state (token_invalidated, token_revoked, invalid_grant,
|
||||
etc.). Distinguishes permanent failures from transient ones like
|
||||
token_expired (refreshable) or generic 401 without a specific reason
|
||||
(could be a server-side glitch worth retrying).
|
||||
|
||||
Returns False for non-401 status codes — 429 rate limits and 402
|
||||
billing failures are transient by nature and should keep TTL semantics.
|
||||
"""
|
||||
if status_code != 401:
|
||||
return False
|
||||
reason = normalized_error.get("reason")
|
||||
if not isinstance(reason, str):
|
||||
return False
|
||||
return reason.strip().lower() in _TERMINAL_AUTH_REASONS
|
||||
|
||||
def _mark_exhausted(
|
||||
self,
|
||||
entry: PooledCredential,
|
||||
@@ -445,9 +513,20 @@ class CredentialPool:
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
) -> PooledCredential:
|
||||
normalized_error = _normalize_error_context(error_context)
|
||||
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
|
||||
# transition to STATUS_DEAD instead of STATUS_EXHAUSTED. Without this,
|
||||
# a revoked credential gets a 1-hour TTL cooldown and then re-enters
|
||||
# rotation, failing immediately every hour until the user manually
|
||||
# removes it (issue #32849). DEAD entries are excluded from rotation
|
||||
# unconditionally and only clear via an explicit re-auth write-side
|
||||
# sync (``_save_codex_tokens`` after a fresh device-code login).
|
||||
if self._is_terminal_auth_failure(status_code, normalized_error):
|
||||
terminal_status = STATUS_DEAD
|
||||
else:
|
||||
terminal_status = STATUS_EXHAUSTED
|
||||
updated = replace(
|
||||
entry,
|
||||
last_status=STATUS_EXHAUSTED,
|
||||
last_status=terminal_status,
|
||||
last_status_at=time.time(),
|
||||
last_error_code=status_code,
|
||||
last_error_reason=normalized_error.get("reason"),
|
||||
@@ -852,12 +931,7 @@ class CredentialPool:
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
auth_mod.resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
inference_auth_mode=(
|
||||
auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
if force
|
||||
else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO
|
||||
),
|
||||
force_refresh=force,
|
||||
)
|
||||
updated = self._sync_nous_entry_from_auth_store(entry)
|
||||
else:
|
||||
@@ -1139,7 +1213,7 @@ class CredentialPool:
|
||||
auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
)
|
||||
if self.provider == "nous":
|
||||
# Nous refresh/mint can require network access and should happen when
|
||||
# Nous refresh can require network access and should happen when
|
||||
# runtime credentials are actually resolved, not merely when the pool
|
||||
# is enumerated for listing, migration, or selection.
|
||||
return False
|
||||
@@ -1158,13 +1232,14 @@ class CredentialPool:
|
||||
"""
|
||||
now = time.time()
|
||||
cleared_any = False
|
||||
entries_to_prune: List[str] = []
|
||||
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"
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
synced = self._sync_anthropic_entry_from_credentials_file(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1175,7 +1250,7 @@ class CredentialPool:
|
||||
# exhausted status stale.
|
||||
if (self.provider == "nous"
|
||||
and entry.source == "device_code"
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
synced = self._sync_nous_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1187,7 +1262,7 @@ class CredentialPool:
|
||||
# future for ChatGPT weekly windows).
|
||||
if (self.provider == "openai-codex"
|
||||
and entry.source == "device_code"
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
synced = self._sync_codex_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1198,11 +1273,41 @@ class CredentialPool:
|
||||
# xAI Grok OAuth login) has since rotated in auth.json.
|
||||
if (self.provider == "xai-oauth"
|
||||
and entry.source == "loopback_pkce"
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
cleared_any = True
|
||||
if entry.last_status == STATUS_DEAD:
|
||||
# Manual DEAD credentials get pruned after a 24h quiet window
|
||||
# so the pool doesn't accumulate dead entries forever. The
|
||||
# user can always re-add via ``hermes auth add``. Singleton-
|
||||
# seeded DEAD entries are kept so the audit trail (label,
|
||||
# last_error_reason, timestamps) stays visible — pruning them
|
||||
# would just be undone by ``_seed_from_singletons`` on the
|
||||
# next load anyway.
|
||||
if _is_manual_source(entry.source):
|
||||
dead_at = entry.last_status_at or 0
|
||||
if dead_at and now - dead_at > DEAD_MANUAL_PRUNE_TTL_SECONDS:
|
||||
_label = entry.label or entry.id[:8]
|
||||
logger.warning(
|
||||
"credential pool: pruning DEAD manual entry %s "
|
||||
"(reason=%s, age=%.1fh) — re-add via `hermes auth add %s`",
|
||||
_label,
|
||||
entry.last_error_reason or "unknown",
|
||||
(now - dead_at) / 3600.0,
|
||||
self.provider,
|
||||
)
|
||||
# Mark for removal after the loop completes; we can't
|
||||
# mutate self._entries while iterating.
|
||||
entries_to_prune.append(entry.id)
|
||||
cleared_any = True
|
||||
# Permanently failed credentials never re-enter rotation via
|
||||
# TTL. They only clear when a write-side re-auth sync rewrites
|
||||
# the tokens (e.g. ``_save_codex_tokens`` after a fresh
|
||||
# device-code login). The auth.json-sync paths below handle
|
||||
# the re-auth case for OAuth singletons.
|
||||
continue
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
@@ -1226,6 +1331,9 @@ class CredentialPool:
|
||||
continue
|
||||
entry = refreshed
|
||||
available.append(entry)
|
||||
if entries_to_prune:
|
||||
pruned_ids = set(entries_to_prune)
|
||||
self._entries = [e for e in self._entries if e.id not in pruned_ids]
|
||||
if cleared_any:
|
||||
self._persist()
|
||||
return available
|
||||
@@ -1293,11 +1401,22 @@ class CredentialPool:
|
||||
if entry is None:
|
||||
return None
|
||||
_label = entry.label or entry.id[:8]
|
||||
logger.info(
|
||||
"credential pool: marking %s exhausted (status=%s), rotating",
|
||||
_label, status_code,
|
||||
)
|
||||
self._mark_exhausted(entry, status_code, error_context)
|
||||
# Re-read the updated entry to log the correct terminal state.
|
||||
updated_entry = next(
|
||||
(e for e in self._entries if e.id == entry.id), entry,
|
||||
)
|
||||
if updated_entry.last_status == STATUS_DEAD:
|
||||
logger.warning(
|
||||
"credential pool: marking %s DEAD (status=%s, reason=%s) — "
|
||||
"permanently failed, will NOT re-enter rotation until re-auth",
|
||||
_label, status_code, updated_entry.last_error_reason or "unknown",
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"credential pool: marking %s exhausted (status=%s), rotating",
|
||||
_label, status_code,
|
||||
)
|
||||
self._current_id = None
|
||||
next_entry = self._select_unlocked()
|
||||
if next_entry:
|
||||
@@ -1637,9 +1756,9 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"inference_base_url": state.get("inference_base_url"),
|
||||
"agent_key": state.get("agent_key"),
|
||||
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
||||
# Carry the mint/refresh timestamps into the pool so
|
||||
# Carry the refresh timestamps into the pool so
|
||||
# freshness-sensitive consumers (self-heal hooks, pool
|
||||
# pruning by age) can distinguish just-minted credentials
|
||||
# pruning by age) can distinguish just-refreshed credentials
|
||||
# from stale ones. Without these, fresh device_code
|
||||
# entries get obtained_at=None and look older than they
|
||||
# are (#15099).
|
||||
|
||||
+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 "
|
||||
|
||||
@@ -39,12 +39,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -249,6 +249,10 @@ def get_read_block_error(path: str) -> Optional[str]:
|
||||
".env",
|
||||
"webhook_subscriptions.json",
|
||||
os.path.join("auth", "google_oauth.json"),
|
||||
# Bitwarden Secrets Manager disk cache: stores plaintext secret values
|
||||
# to avoid re-fetching across back-to-back CLI invocations. The file
|
||||
# was introduced by #31968 but not added to this guard.
|
||||
os.path.join("cache", "bws_cache.json"),
|
||||
)
|
||||
for hd in hermes_dirs:
|
||||
for name in credential_file_names:
|
||||
|
||||
@@ -31,7 +31,6 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -899,7 +899,15 @@ def start_oauth_flow(
|
||||
try:
|
||||
import webbrowser
|
||||
|
||||
webbrowser.open(auth_url, new=1, autoraise=True)
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
_can_open_graphical_browser as _can_open_gui,
|
||||
)
|
||||
except Exception:
|
||||
_can_open_gui = lambda: True # noqa: E731
|
||||
|
||||
if _can_open_gui():
|
||||
webbrowser.open(auth_url, new=1, autoraise=True)
|
||||
except Exception as exc:
|
||||
logger.debug("webbrowser.open failed: %s", exc)
|
||||
|
||||
|
||||
+134
-14
@@ -37,6 +37,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -46,6 +48,102 @@ logger = logging.getLogger(__name__)
|
||||
_VALID_MODES = frozenset({"auto", "native", "text"})
|
||||
|
||||
|
||||
# Image extensions used by extract_image_refs(). Kept tight on purpose — we
|
||||
# only auto-attach things the model can actually see. Documents/archives are
|
||||
# excluded because the gateway's broader extract_local_files() also routes
|
||||
# them differently (send_document), and we don't want to attach a PDF as a
|
||||
# vision part.
|
||||
_IMAGE_EXTS = (
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic",
|
||||
)
|
||||
_IMAGE_EXT_PATTERN = "|".join(e.lstrip(".") for e in _IMAGE_EXTS)
|
||||
|
||||
# Absolute / home-relative local image path. Matches the same shape gateway's
|
||||
# extract_local_files() uses: anchors to ``~/`` or ``/``, ignores matches inside
|
||||
# URLs (the ``(?<![/:\w.])`` lookbehind), and case-insensitive on the extension.
|
||||
_LOCAL_IMAGE_PATH_RE = re.compile(
|
||||
r"(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:" + _IMAGE_EXT_PATTERN + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# http(s) URL ending in an image extension (optionally followed by a
|
||||
# query string). Case-insensitive on the extension. Strict ``http(s)://``
|
||||
# scheme so we don't accidentally grab ``file://`` URLs or other shapes.
|
||||
_IMAGE_URL_RE = re.compile(
|
||||
r"https?://[^\s<>\"']+?\.(?:" + _IMAGE_EXT_PATTERN + r")(?:\?[^\s<>\"']*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_image_refs(text: str) -> Tuple[List[str], List[str]]:
|
||||
"""Scan free-form text for image references the model should see.
|
||||
|
||||
Returns ``(local_paths, urls)``:
|
||||
|
||||
* ``local_paths`` — absolute (``/``) or home-relative (``~/``) paths
|
||||
whose suffix is an image extension AND whose expanded form exists
|
||||
on disk as a file. Order-preserving, deduplicated.
|
||||
* ``urls`` — ``http(s)://…`` URLs whose path ends in an image
|
||||
extension (a ``?query`` is allowed after the extension).
|
||||
Order-preserving, deduplicated.
|
||||
|
||||
Matches inside fenced code blocks (``` ``` ```) and inline backticks
|
||||
(`` `…` ``) are skipped so that snippets pasted into a task body for
|
||||
reference aren't mistaken for live attachments. This mirrors the
|
||||
behaviour of ``gateway.platforms.base.BaseAdapter.extract_local_files``.
|
||||
|
||||
Local paths are validated against the filesystem; URLs are not
|
||||
(the provider fetches them at request time).
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return [], []
|
||||
|
||||
# Build spans covered by fenced code blocks and inline code so we can
|
||||
# ignore references the author embedded purely as example text.
|
||||
code_spans: list[tuple[int, int]] = []
|
||||
for m in re.finditer(r"```[^\n]*\n.*?```", text, re.DOTALL):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
for m in re.finditer(r"`[^`\n]+`", text):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
|
||||
def _in_code(pos: int) -> bool:
|
||||
return any(s <= pos < e for s, e in code_spans)
|
||||
|
||||
local_paths: list[str] = []
|
||||
seen_paths: set[str] = set()
|
||||
for match in _LOCAL_IMAGE_PATH_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
raw = match.group(0)
|
||||
expanded = os.path.expanduser(raw)
|
||||
try:
|
||||
if not os.path.isfile(expanded):
|
||||
continue
|
||||
except OSError:
|
||||
# ENAMETOOLONG / EINVAL on pathological inputs — skip rather than crash.
|
||||
continue
|
||||
if expanded in seen_paths:
|
||||
continue
|
||||
seen_paths.add(expanded)
|
||||
local_paths.append(expanded)
|
||||
|
||||
urls: list[str] = []
|
||||
seen_urls: set[str] = set()
|
||||
for match in _IMAGE_URL_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
url = match.group(0)
|
||||
# Strip trailing punctuation that's almost certainly prose, not part
|
||||
# of the URL (e.g. "see https://x.com/a.png." or "/a.png)").
|
||||
url = url.rstrip(".,;:!?)]>")
|
||||
if url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
urls.append(url)
|
||||
|
||||
return local_paths, urls
|
||||
|
||||
|
||||
# Strict YAML/JSON boolean coercion for capability overrides.
|
||||
#
|
||||
# ``bool("false")`` is True in Python because non-empty strings are truthy, so
|
||||
@@ -320,20 +418,29 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
def build_native_content_parts(
|
||||
user_text: str,
|
||||
image_paths: List[str],
|
||||
image_urls: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""Build an OpenAI-style ``content`` list for a user turn.
|
||||
|
||||
Shape:
|
||||
[{"type": "text", "text": "...\\n\\n[Image attached at: /local/path]"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
|
||||
...]
|
||||
|
||||
The local path of each successfully attached image is appended to the
|
||||
text part as ``[Image attached at: <path>]``. The model still sees the
|
||||
pixels via the ``image_url`` part (full native vision); the path note
|
||||
just gives it a string handle so MCP/skill tools that take an image
|
||||
path or URL argument can be invoked on the same image without an
|
||||
extra round-trip. This parallels the text-mode hint produced by
|
||||
Local paths are read from disk and embedded as base64 ``data:`` URLs.
|
||||
Remote URLs (``http(s)://``) are passed through verbatim — the provider
|
||||
fetches them server-side. The model still sees the pixels either way.
|
||||
|
||||
For each successfully attached image, a hint is appended to the text
|
||||
part:
|
||||
|
||||
* local path → ``[Image attached at: <path>]``
|
||||
* URL → ``[Image attached: <url>]``
|
||||
|
||||
The hint gives the model a string handle so MCP/skill tools that take
|
||||
an image path or URL argument can be invoked on the same image without
|
||||
an extra round-trip. This parallels the text-mode hint produced by
|
||||
``Runner._enrich_message_with_vision`` (``vision_analyze using image_url:
|
||||
<path>``) so behaviour is consistent across both image input modes.
|
||||
|
||||
@@ -342,12 +449,14 @@ def build_native_content_parts(
|
||||
ceiling), the agent's retry loop transparently shrinks and retries
|
||||
once — see ``run_agent._try_shrink_image_parts_in_messages``.
|
||||
|
||||
Returns (content_parts, skipped_paths). Skipped paths are files that
|
||||
couldn't be read from disk and are NOT advertised in the path hints.
|
||||
Returns (content_parts, skipped). Skipped entries are local paths
|
||||
that couldn't be read from disk; URLs are never skipped (they're
|
||||
not validated here).
|
||||
"""
|
||||
skipped: List[str] = []
|
||||
image_parts: List[Dict[str, Any]] = []
|
||||
attached_paths: List[str] = []
|
||||
attached_urls: List[str] = []
|
||||
|
||||
for raw_path in image_paths:
|
||||
p = Path(raw_path)
|
||||
@@ -364,16 +473,26 @@ def build_native_content_parts(
|
||||
})
|
||||
attached_paths.append(str(raw_path))
|
||||
|
||||
for url in image_urls or []:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
image_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
})
|
||||
attached_urls.append(url)
|
||||
|
||||
text = (user_text or "").strip()
|
||||
|
||||
# If at least one image attached, build a single text part that combines
|
||||
# the user's caption (or a neutral default) with one path hint per image.
|
||||
if attached_paths:
|
||||
# the user's caption (or a neutral default) with one hint per image.
|
||||
if attached_paths or attached_urls:
|
||||
base_text = text or "What do you see in this image?"
|
||||
path_hints = "\n".join(
|
||||
f"[Image attached at: {p}]" for p in attached_paths
|
||||
)
|
||||
combined_text = f"{base_text}\n\n{path_hints}"
|
||||
hint_lines: List[str] = []
|
||||
hint_lines.extend(f"[Image attached at: {p}]" for p in attached_paths)
|
||||
hint_lines.extend(f"[Image attached: {u}]" for u in attached_urls)
|
||||
combined_text = f"{base_text}\n\n" + "\n".join(hint_lines)
|
||||
parts: List[Dict[str, Any]] = [{"type": "text", "text": combined_text}]
|
||||
parts.extend(image_parts)
|
||||
return parts, skipped
|
||||
@@ -388,4 +507,5 @@ def build_native_content_parts(
|
||||
__all__ = [
|
||||
"decide_image_input_mode",
|
||||
"build_native_content_parts",
|
||||
"extract_image_refs",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
@@ -16,7 +16,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def register_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -249,7 +248,6 @@ def _cmd_restart() -> int:
|
||||
|
||||
def _cmd_which(server_id: str) -> int:
|
||||
from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
|
||||
import os
|
||||
import shutil as _shutil
|
||||
|
||||
recipe = INSTALL_RECIPES.get(server_id)
|
||||
|
||||
@@ -39,25 +39,20 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future as ConcurrentFuture
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.lsp import eventlog
|
||||
from agent.lsp.client import (
|
||||
DIAGNOSTICS_DOCUMENT_WAIT,
|
||||
LSPClient,
|
||||
file_uri,
|
||||
)
|
||||
from agent.lsp.servers import (
|
||||
ServerContext,
|
||||
ServerDef,
|
||||
SpawnSpec,
|
||||
find_server_for_file,
|
||||
language_id_for,
|
||||
)
|
||||
from agent.lsp.workspace import (
|
||||
clear_cache,
|
||||
is_inside_workspace,
|
||||
resolve_workspace_for_file,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from agent.lsp.workspace import nearest_root, normalize_path
|
||||
from agent.lsp.workspace import nearest_root
|
||||
|
||||
logger = logging.getLogger("agent.lsp.servers")
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+24
-1
@@ -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.
|
||||
|
||||
+57
-1
@@ -7,7 +7,6 @@ assemble pieces, then combines them with memory and ephemeral prompts.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
@@ -236,6 +235,11 @@ KANBAN_GUIDANCE = (
|
||||
"- Do not shell out to `hermes kanban <verb>` for board operations. Use "
|
||||
"the `kanban_*` tools — they work across all terminal backends.\n"
|
||||
"- Do not complete a task you didn't actually finish. Block it.\n"
|
||||
"- Do not call `clarify` to ask questions. You are running headless — "
|
||||
"there is no live user to answer. The call will time out and the task "
|
||||
"will sit silently in `running` with no signal to the operator. Instead: "
|
||||
"`kanban_comment` the context, then `kanban_block(reason=...)` so the "
|
||||
"task surfaces on the board as needing input.\n"
|
||||
"- Do not assign follow-up work to yourself. Assign it to the right "
|
||||
"specialist profile.\n"
|
||||
"- Do not call `delegate_task` as a board substitute. `delegate_task` is "
|
||||
@@ -262,6 +266,37 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
# Add new patterns here when a model family needs explicit steering.
|
||||
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek")
|
||||
|
||||
# Universal "finish the job" guidance — applied to ALL models, not gated
|
||||
# by model family. Addresses two cross-model failure modes:
|
||||
# 1. Stopping after a stub: writing a tiny file or running one command
|
||||
# and then ending the turn with a description of the plan instead
|
||||
# of the finished artifact. (Observed on Opus during a real
|
||||
# Sarasota real-estate build task: 3 API calls, 85-byte file,
|
||||
# one terminal command, finish_reason=stop.)
|
||||
# 2. Fabricating output when a real path is blocked. When `pip` or a
|
||||
# tool fails, some models will synthesize plausible-looking results
|
||||
# (fake addresses, fake JSON, fake numbers) instead of reporting
|
||||
# the blocker. (Observed on DeepSeek v4-flash on the same task:
|
||||
# pushed through PEP-668 wall, then returned fabricated listings.)
|
||||
#
|
||||
# Short on purpose. This block is shipped to every user, every session,
|
||||
# in the cached system prompt — token cost is paid once at install and
|
||||
# then amortised across all sessions via prefix caching. Keep it tight.
|
||||
TASK_COMPLETION_GUIDANCE = (
|
||||
"# Finishing the job\n"
|
||||
"When the user asks you to build, run, or verify something, the deliverable is "
|
||||
"a working artifact backed by real tool output — not a description of one. "
|
||||
"Do not stop after writing a stub, a plan, or a single command. Keep working "
|
||||
"until you have actually exercised the code or produced the requested result, "
|
||||
"then report what real execution returned.\n"
|
||||
"If a tool, install, or network call fails and blocks the real path, say so "
|
||||
"directly and try an alternative (different package manager, different "
|
||||
"approach, ask the user). NEVER substitute plausible-looking fabricated "
|
||||
"output (made-up data, invented file contents, synthesised API responses) "
|
||||
"for results you couldn't actually produce. Reporting a blocker honestly "
|
||||
"is always better than inventing a result."
|
||||
)
|
||||
|
||||
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
||||
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
||||
# hallucinate instead of using tools, and declare "done" without verification.
|
||||
@@ -813,6 +848,27 @@ def build_environment_hints() -> str:
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
|
||||
# Embedder-supplied environment description. Lets a host that wraps Hermes
|
||||
# (e.g. a sandbox runner / managed platform) explain the environment the
|
||||
# agent is running in — proxy, credential handling, mount layout — without
|
||||
# forking the identity slot (SOUL.md). Read once at prompt-build time, so
|
||||
# it's part of the stable, cache-safe system prompt. The env var is the
|
||||
# build-time/embedder mechanism (set in a container ENV); config.yaml
|
||||
# ``agent.environment_hint`` is the user-facing surface. Env var wins.
|
||||
extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip()
|
||||
if not extra:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
extra = str(
|
||||
(load_config().get("agent", {}) or {}).get("environment_hint", "")
|
||||
).strip()
|
||||
except Exception as e:
|
||||
logger.debug("Could not read agent.environment_hint from config: %s", e)
|
||||
if extra:
|
||||
hints.append(extra)
|
||||
|
||||
return "\n\n".join(hints)
|
||||
|
||||
|
||||
|
||||
+9
-14
@@ -331,7 +331,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
|
||||
Set force=True for safety boundaries that must never return raw secrets
|
||||
regardless of the user's global logging redaction preference.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -37,7 +37,6 @@ import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ from agent.prompt_builder import (
|
||||
PLATFORM_HINTS,
|
||||
SESSION_SEARCH_GUIDANCE,
|
||||
SKILLS_GUIDANCE,
|
||||
TASK_COMPLETION_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
)
|
||||
@@ -100,6 +101,15 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# Pointer to the hermes-agent skill + docs for user questions about Hermes itself.
|
||||
stable_parts.append(HERMES_AGENT_HELP_GUIDANCE)
|
||||
|
||||
# Universal task-completion / no-fabrication guidance. Applied to ALL
|
||||
# models regardless of tool_use_enforcement gating — the failure modes
|
||||
# this targets (stopping after a stub; fabricating output when a real
|
||||
# path is blocked) are not model-family specific. Gated only by
|
||||
# config.yaml ``agent.task_completion_guidance`` (default True) so
|
||||
# users who want a leaner prompt can turn it off.
|
||||
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
|
||||
stable_parts.append(TASK_COMPLETION_GUIDANCE)
|
||||
|
||||
# Tool-aware behavioral guidance: only inject when the tools are loaded
|
||||
tool_guidance = []
|
||||
if "memory" in agent.valid_tool_names:
|
||||
@@ -205,6 +215,23 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
if _env_hints:
|
||||
stable_parts.append(_env_hints)
|
||||
|
||||
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
|
||||
# something is non-default so the model can pick the right install
|
||||
# strategy without discovering by failure. Emits a single line; emits
|
||||
# NOTHING when the environment is clean (no token cost). Skipped
|
||||
# entirely for remote terminal backends (the host's Python state is
|
||||
# irrelevant when tools run inside docker/modal/ssh). Gated by
|
||||
# config.yaml ``agent.environment_probe`` (default True).
|
||||
if getattr(agent, "_environment_probe", True):
|
||||
try:
|
||||
from tools.env_probe import get_environment_probe_line
|
||||
_probe_line = get_environment_probe_line()
|
||||
if _probe_line:
|
||||
stable_parts.append(_probe_line)
|
||||
except Exception:
|
||||
# Probe failure must never block prompt build.
|
||||
pass
|
||||
|
||||
# Active-profile hint — names the Hermes profile the agent is running
|
||||
# under so it doesn't conflate ~/.hermes/skills/ (default profile) with
|
||||
# ~/.hermes/profiles/<active>/skills/ (this profile's). Deterministic
|
||||
|
||||
+146
-56
@@ -13,14 +13,13 @@ extracted functions reach back through the ``run_agent`` module via
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from agent.display import (
|
||||
KawaiiSpinner,
|
||||
@@ -38,12 +37,9 @@ from agent.tool_dispatch_helpers import (
|
||||
make_tool_result_message,
|
||||
)
|
||||
from tools.terminal_tool import (
|
||||
_get_approval_callback,
|
||||
_get_sudo_password_callback,
|
||||
set_approval_callback as _set_approval_callback,
|
||||
set_sudo_password_callback as _set_sudo_password_callback,
|
||||
get_active_env,
|
||||
)
|
||||
from tools.thread_context import propagate_context_to_thread
|
||||
from tools.tool_result_storage import (
|
||||
maybe_persist_tool_result,
|
||||
enforce_turn_budget,
|
||||
@@ -62,6 +58,55 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _tool_search_scoped_names(agent) -> frozenset:
|
||||
"""Return the deferrable tool names the session may invoke via tool_call.
|
||||
|
||||
The Tool Search unwrap dispatches the underlying tool directly, bypassing
|
||||
the bridge branch (and its scope check) in
|
||||
``model_tools.handle_function_call``. To keep a restricted-toolset session
|
||||
(subagent, kanban worker, curated gateway session) from reaching tools it
|
||||
was never granted, the unwrap validates the underlying name against this
|
||||
set: the deferrable subset of the session's own enabled/disabled toolset
|
||||
scope.
|
||||
|
||||
Result is cached on the agent and refreshed when the tool registry's
|
||||
generation changes (e.g. an MCP server reconnects), so the common case is
|
||||
a dict lookup, not a full tool-defs rebuild on every tool call.
|
||||
"""
|
||||
try:
|
||||
import model_tools
|
||||
from tools import tool_search as _ts
|
||||
from tools.registry import registry as _registry
|
||||
except Exception:
|
||||
return frozenset()
|
||||
|
||||
enabled = getattr(agent, "enabled_toolsets", None)
|
||||
disabled = getattr(agent, "disabled_toolsets", None)
|
||||
cache_key = (
|
||||
getattr(_registry, "_generation", 0),
|
||||
frozenset(enabled) if enabled is not None else None,
|
||||
frozenset(disabled) if disabled is not None else None,
|
||||
)
|
||||
cached = getattr(agent, "_tool_search_scope_cache", None)
|
||||
if cached is not None and cached[0] == cache_key:
|
||||
return cached[1]
|
||||
try:
|
||||
scoped_defs = model_tools.get_tool_definitions(
|
||||
enabled_toolsets=enabled,
|
||||
disabled_toolsets=disabled,
|
||||
quiet_mode=True,
|
||||
skip_tool_search_assembly=True,
|
||||
) or []
|
||||
names = _ts.scoped_deferrable_names(scoped_defs)
|
||||
except Exception:
|
||||
names = frozenset()
|
||||
try:
|
||||
agent._tool_search_scope_cache = (cache_key, names)
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
"""Execute multiple tool calls concurrently using a thread pool.
|
||||
|
||||
@@ -100,6 +145,41 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# ── Tool Search unwrap ────────────────────────────────────────
|
||||
# When the model invokes the tool_call bridge, peel it open so
|
||||
# every downstream check (checkpointing, guardrails, plugin
|
||||
# pre-tool-call hooks, the display/activity feed, the post-call
|
||||
# callback) sees the underlying tool — not the bridge. This is
|
||||
# the OpenClaw lesson: hooks must observe the real tool name.
|
||||
#
|
||||
# The original tool_call entry on ``tool_call.function`` is left
|
||||
# untouched so the conversation transcript and the matching
|
||||
# tool_call_id are preserved exactly as the model emitted them.
|
||||
#
|
||||
# Scope gate: the unwrap dispatches the underlying tool directly
|
||||
# (bypassing the bridge branch in handle_function_call and its
|
||||
# scope check), so we enforce session toolset scope HERE. A tool
|
||||
# the session was not granted is rejected before any checkpoint,
|
||||
# hook, or dispatch fires.
|
||||
_ts_scope_block = None
|
||||
try:
|
||||
from tools import tool_search as _ts
|
||||
if function_name == _ts.TOOL_CALL_NAME:
|
||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = json.dumps({
|
||||
"error": (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
@@ -124,21 +204,25 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
|
||||
block_result = None
|
||||
blocked_by_guardrail = False
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
|
||||
if block_message is not None:
|
||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
if _ts_scope_block is not None:
|
||||
# Out-of-scope tool_call: reject before hooks/guardrails/dispatch.
|
||||
block_result = _ts_scope_block
|
||||
else:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
if not guardrail_decision.allows_execution:
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
|
||||
if block_message is not None:
|
||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
else:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
if not guardrail_decision.allows_execution:
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
|
||||
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
||||
|
||||
@@ -186,14 +270,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
agent._current_tool = tool_names_str
|
||||
agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}")
|
||||
|
||||
# Capture CLI callbacks from the agent thread so worker threads can
|
||||
# register them locally. Without this, _get_approval_callback() in
|
||||
# terminal_tool returns None in ThreadPoolExecutor workers, causing
|
||||
# the dangerous-command prompt to fall back to input() — which
|
||||
# deadlocks against prompt_toolkit's raw terminal mode (#13617).
|
||||
_parent_approval_cb = _get_approval_callback()
|
||||
_parent_sudo_cb = _get_sudo_password_callback()
|
||||
|
||||
def _run_tool(index, tool_call, function_name, function_args):
|
||||
"""Worker function executed in a thread."""
|
||||
# Register this worker tid so the agent can fan out an interrupt
|
||||
@@ -220,18 +296,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
set_activity_callback(agent._touch_activity)
|
||||
except Exception:
|
||||
pass
|
||||
# Propagate approval/sudo callbacks to this worker thread.
|
||||
# Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr).
|
||||
if _parent_approval_cb is not None:
|
||||
try:
|
||||
_set_approval_callback(_parent_approval_cb)
|
||||
except Exception:
|
||||
pass
|
||||
if _parent_sudo_cb is not None:
|
||||
try:
|
||||
_set_sudo_password_callback(_parent_sudo_cb)
|
||||
except Exception:
|
||||
pass
|
||||
# Approval/sudo callbacks (thread-local) and the agent turn's
|
||||
# ContextVars are propagated by propagate_context_to_thread() at the
|
||||
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
|
||||
start = time.time()
|
||||
try:
|
||||
result = agent._invoke_tool(
|
||||
@@ -261,13 +328,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
except Exception:
|
||||
pass
|
||||
# Clear thread-local callbacks so a recycled worker thread
|
||||
# doesn't hold stale references to a disposed CLI instance.
|
||||
try:
|
||||
_set_approval_callback(None)
|
||||
_set_sudo_password_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start spinner for CLI mode (skip when TUI handles tool progress)
|
||||
spinner = None
|
||||
@@ -287,9 +347,12 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for i, tc, name, args in runnable_calls:
|
||||
# Propagate ContextVars (e.g. _approval_session_key); mirrors asyncio.to_thread.
|
||||
ctx = contextvars.copy_context()
|
||||
f = executor.submit(ctx.run, _run_tool, i, tc, name, args)
|
||||
# Propagate the agent turn's ContextVars (e.g.
|
||||
# _approval_session_key) AND thread-local approval/sudo
|
||||
# callbacks into the worker thread; clears callbacks on exit.
|
||||
f = executor.submit(
|
||||
propagate_context_to_thread(_run_tool), i, tc, name, args
|
||||
)
|
||||
futures.append(f)
|
||||
|
||||
# Wait for all to complete with periodic heartbeats so the
|
||||
@@ -497,16 +560,39 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# Check plugin hooks for a block directive before executing.
|
||||
_block_msg: Optional[str] = None
|
||||
# Tool Search unwrap — see execute_tool_calls_concurrent for full
|
||||
# rationale, including the scope gate (the unwrap dispatches the
|
||||
# underlying tool directly, so session toolset scope is enforced here).
|
||||
_ts_scope_block: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
from tools import tool_search as _ts
|
||||
if function_name == _ts.TOOL_CALL_NAME:
|
||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check plugin hooks for a block directive before executing.
|
||||
_block_msg: Optional[str] = None
|
||||
if _ts_scope_block is not None:
|
||||
_block_msg = _ts_scope_block
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_guardrail_block_decision: ToolGuardrailDecision | None = None
|
||||
if _block_msg is None:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
@@ -752,6 +838,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
_spinner_result = function_result
|
||||
except Exception as tool_error:
|
||||
@@ -772,6 +860,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
except Exception as tool_error:
|
||||
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
|
||||
@@ -10,7 +10,7 @@ reasoning configuration, temperature handling, and extra_body assembly.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools
|
||||
@@ -476,13 +476,17 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
ephemeral = params.get("ephemeral_max_output_tokens")
|
||||
user_max = params.get("max_tokens")
|
||||
anthropic_max = params.get("anthropic_max_output")
|
||||
# Per-model default cap — profiles override get_max_tokens() when
|
||||
# they front several backends with different completion-token limits
|
||||
# (e.g. opencode-go: mimo-v2.5-pro = 131072).
|
||||
profile_max = profile.get_max_tokens(model)
|
||||
|
||||
if ephemeral is not None and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(ephemeral))
|
||||
elif user_max is not None and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(user_max))
|
||||
elif profile.default_max_tokens and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(profile.default_max_tokens))
|
||||
elif profile_max and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(profile_max))
|
||||
elif anthropic_max is not None:
|
||||
api_kwargs["max_tokens"] = anthropic_max
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
# Default minimum codex version we test against. The PR sets this from the
|
||||
# `codex --version` parsed at install time; bumping is a one-line change here.
|
||||
|
||||
@@ -31,6 +31,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.codex_responses_adapter import _format_responses_error
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.transports.codex_app_server import (
|
||||
CodexAppServerClient,
|
||||
@@ -581,7 +582,7 @@ class CodexAppServerSession:
|
||||
(note.get("params") or {}).get("turn") or {}
|
||||
).get("error")
|
||||
if err_obj:
|
||||
err_msg = err_obj.get("message") or str(err_obj)
|
||||
err_msg = _format_responses_error(err_obj, str(turn_status))
|
||||
# If the turn failed for an auth/refresh reason,
|
||||
# rewrite the error into a re-auth hint AND mark
|
||||
# the session for retirement.
|
||||
|
||||
@@ -83,6 +83,34 @@ _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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
+19
-9
@@ -163,7 +163,7 @@ model:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Working directory behavior:
|
||||
# - CLI (`hermes` command): Uses "." (current directory where you run hermes)
|
||||
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
|
||||
# - Gateway/messaging/cron: Uses terminal.cwd here; legacy .env cwd values are deprecated
|
||||
terminal:
|
||||
backend: "local"
|
||||
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
|
||||
@@ -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).
|
||||
|
||||
@@ -74,10 +74,15 @@ except (ImportError, AttributeError):
|
||||
_STEADY_CURSOR = None
|
||||
|
||||
try:
|
||||
from hermes_cli.pt_input_extras import install_shift_enter_alias, install_ctrl_enter_alias
|
||||
from hermes_cli.pt_input_extras import (
|
||||
install_ctrl_enter_alias,
|
||||
install_ignored_terminal_sequences,
|
||||
install_shift_enter_alias,
|
||||
)
|
||||
install_shift_enter_alias()
|
||||
install_ctrl_enter_alias()
|
||||
del install_shift_enter_alias, install_ctrl_enter_alias
|
||||
install_ignored_terminal_sequences()
|
||||
del install_shift_enter_alias, install_ctrl_enter_alias, install_ignored_terminal_sequences
|
||||
except Exception:
|
||||
pass
|
||||
import threading
|
||||
@@ -168,7 +173,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'
|
||||
@@ -382,6 +387,10 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
|
||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||||
"engine": "auto", # Browser engine: auto (Chrome), lightpanda, chrome
|
||||
"camofox": {
|
||||
"rewrite_loopback_urls": False,
|
||||
"loopback_host_alias": "host.docker.internal",
|
||||
},
|
||||
},
|
||||
"compression": {
|
||||
"enabled": True, # Auto-compress when approaching context limit
|
||||
@@ -576,6 +585,8 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"docker_env": "TERMINAL_DOCKER_ENV",
|
||||
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
# Persistent shell (non-local backends)
|
||||
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
@@ -2475,8 +2486,9 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = (
|
||||
def _preserve_ctrl_enter_newline() -> bool:
|
||||
"""Detect environments where Ctrl+Enter must produce a newline, not submit.
|
||||
|
||||
Native Windows, WSL, SSH sessions, and Windows Terminal all send Ctrl+Enter
|
||||
as bare LF (c-j). On those terminals c-j must NOT be bound to submit;
|
||||
Windows Terminal, WSL, SSH sessions, Ghostty, and some modern terminals
|
||||
deliver Ctrl+Enter/Ctrl+J as bare LF (c-j). On those terminals c-j must
|
||||
NOT be bound to submit;
|
||||
binding it to submit makes Ctrl+Enter (intended as 'newline like Alt+Enter')
|
||||
submit instead. Local POSIX TTYs that deliver Enter as LF (docker exec,
|
||||
some thin PTYs without SSH) still need c-j bound to submit, so we keep
|
||||
@@ -2490,6 +2502,12 @@ def _preserve_ctrl_enter_newline() -> bool:
|
||||
return True
|
||||
if os.environ.get("WT_SESSION"):
|
||||
return True
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR") or os.environ.get("GHOSTTY_BIN_DIR"):
|
||||
return True
|
||||
if os.environ.get("TERM", "").lower() == "xterm-ghostty":
|
||||
return True
|
||||
if os.environ.get("TERM_PROGRAM", "").lower() == "ghostty":
|
||||
return True
|
||||
if "microsoft" in os.environ.get("WSL_DISTRO_NAME", "").lower():
|
||||
return True
|
||||
# WSL detection — env vars can be scrubbed under sudo, also peek /proc.
|
||||
@@ -2510,7 +2528,7 @@ def _bind_prompt_submit_keys(kb, handler) -> None:
|
||||
some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF
|
||||
instead of CR — without this, Enter appears dead on those terminals.
|
||||
|
||||
Exception: on Windows, WSL, SSH sessions, and Windows Terminal,
|
||||
Exception: on Windows, WSL, SSH sessions, Windows Terminal, and Ghostty,
|
||||
c-j is the wire encoding of Ctrl+Enter (a distinct keystroke from
|
||||
plain Enter / c-m). We leave c-j unbound there so the c-j newline
|
||||
handler registered separately can fire — giving the user an
|
||||
@@ -3747,7 +3765,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 +3826,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 = [
|
||||
@@ -6907,6 +6925,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 +7173,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 +7200,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 +7267,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 +7605,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 +7656,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 +9638,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 +10770,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)"
|
||||
)
|
||||
@@ -11728,6 +11860,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 +11918,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 +12057,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
|
||||
|
||||
@@ -12563,7 +12722,21 @@ class HermesCLI:
|
||||
|
||||
# Key bindings for the input area
|
||||
kb = KeyBindings()
|
||||
|
||||
|
||||
from prompt_toolkit.keys import Keys as _IgnoreKeys
|
||||
|
||||
@kb.add(_IgnoreKeys.Ignore, eager=True)
|
||||
def handle_ignored_terminal_sequence(event):
|
||||
"""Consume parser-level ignored terminal sequences before self-insert.
|
||||
|
||||
install_ignored_terminal_sequences() in hermes_cli.pt_input_extras
|
||||
registers focus reports (CSI I / CSI O) as Keys.Ignore at the
|
||||
VT100 parser level. Without this no-op binding the default
|
||||
self-insert path would still fire and the bytes would land in
|
||||
the buffer.
|
||||
"""
|
||||
return None
|
||||
|
||||
def handle_enter(event):
|
||||
"""Handle Enter key - submit input.
|
||||
|
||||
@@ -13806,7 +13979,12 @@ class HermesCLI:
|
||||
reserved_below = 6
|
||||
|
||||
available = max(0, term_rows - reserved_below)
|
||||
mandatory_full = chrome_full + len(choice_wrapped) + len(other_wrapped)
|
||||
# The compact decision must reserve room for at least one question
|
||||
# row on top of the choices, otherwise full chrome (3 blank
|
||||
# separators) gets kept when there is no room for it and the panel
|
||||
# overflows the viewport — HSplit then clips the panel's tail,
|
||||
# silently dropping the choices (the reported bug).
|
||||
mandatory_full = chrome_full + 1 + len(choice_wrapped) + len(other_wrapped)
|
||||
|
||||
use_compact_chrome = mandatory_full > available
|
||||
chrome_rows = chrome_tight if use_compact_chrome else chrome_full
|
||||
@@ -13814,9 +13992,24 @@ class HermesCLI:
|
||||
max_question_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(other_wrapped))
|
||||
max_question_rows = min(max_question_rows, 12) # soft cap on huge terminals
|
||||
|
||||
# When the choices alone (plus compact chrome) already exceed the
|
||||
# viewport, drop the question entirely — the choices are the only
|
||||
# thing the user must see to make a selection. Without this the
|
||||
# question would still claim its 1-row floor above and push the
|
||||
# tail of the choices off-screen (HSplit clips the overflow).
|
||||
choices_overflow = chrome_rows + len(choice_wrapped) + len(other_wrapped) >= available
|
||||
if choices_overflow:
|
||||
max_question_rows = 0
|
||||
|
||||
question_wrapped = _wrap_panel_text(question, inner_text_width)
|
||||
if len(question_wrapped) > max_question_rows:
|
||||
keep = max(1, max_question_rows - 1)
|
||||
if max_question_rows <= 0:
|
||||
question_wrapped = []
|
||||
elif len(question_wrapped) > max_question_rows:
|
||||
# The truncation marker is itself a row, so it must count
|
||||
# against the budget. With a 1-row budget there is no room for
|
||||
# both a question line and the marker — show the marker alone
|
||||
# so the rendered question never exceeds max_question_rows.
|
||||
keep = max(0, max_question_rows - 1)
|
||||
question_wrapped = question_wrapped[:keep] + ["… (question truncated)"]
|
||||
|
||||
lines = []
|
||||
@@ -14939,6 +15132,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
|
||||
@@ -14951,13 +15177,50 @@ def main(
|
||||
# Handle single query mode
|
||||
if query or image:
|
||||
query, single_query_images = _collect_query_images(query, image)
|
||||
# Kanban workers spawn with ``hermes chat -q "work kanban task <id>"``;
|
||||
# the actual task description lives in the task body. Mirror the
|
||||
# gateway/CLI behaviour for inbound images by scanning the body for
|
||||
# local image paths and http(s) image URLs and attaching them to the
|
||||
# worker's first turn. Without this, users who paste a screenshot
|
||||
# path or URL into a kanban task body never get it routed to the
|
||||
# model's vision input.
|
||||
single_query_image_urls: list[str] = []
|
||||
_kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "").strip()
|
||||
if _kanban_task_id:
|
||||
try:
|
||||
from hermes_cli import kanban_db as _kb
|
||||
from agent.image_routing import extract_image_refs as _extract_refs
|
||||
|
||||
_conn = _kb.connect()
|
||||
try:
|
||||
_task = _kb.get_task(_conn, _kanban_task_id)
|
||||
finally:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_body = getattr(_task, "body", "") if _task is not None else ""
|
||||
if _body:
|
||||
_kb_paths, _kb_urls = _extract_refs(_body)
|
||||
if _kb_paths:
|
||||
# Dedupe against any --image the user already passed.
|
||||
_seen = {str(p) for p in single_query_images}
|
||||
for _p in _kb_paths:
|
||||
if _p not in _seen:
|
||||
_seen.add(_p)
|
||||
single_query_images.append(Path(_p))
|
||||
if _kb_urls:
|
||||
single_query_image_urls.extend(_kb_urls)
|
||||
except Exception as _exc:
|
||||
# Best-effort enrichment; never block worker startup on it.
|
||||
logger.debug("kanban image-ref extraction failed: %s", _exc)
|
||||
if quiet:
|
||||
# Quiet mode: suppress banner, spinner, tool previews.
|
||||
# Only print the final response and parseable session info.
|
||||
cli.tool_progress_mode = "off"
|
||||
if cli._ensure_runtime_credentials():
|
||||
effective_query: Any = query
|
||||
if single_query_images:
|
||||
if single_query_images or single_query_image_urls:
|
||||
# Honour the same image-routing decision used by the
|
||||
# interactive path. With a vision-capable model (incl.
|
||||
# custom-provider models declared via
|
||||
@@ -14986,19 +15249,26 @@ def main(
|
||||
_parts, _skipped = _build_parts(
|
||||
query if isinstance(query, str) else "",
|
||||
[str(p) for p in single_query_images],
|
||||
image_urls=list(single_query_image_urls) or None,
|
||||
)
|
||||
if any(p.get("type") == "image_url" for p in _parts):
|
||||
effective_query = _parts
|
||||
else:
|
||||
# All images unreadable — text fallback.
|
||||
# ``_preprocess_images_with_vision`` only knows
|
||||
# about local files; URLs would be lost there,
|
||||
# so keep the original query text intact when
|
||||
# only URLs were supplied.
|
||||
if single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
except Exception:
|
||||
if single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
except Exception:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
else:
|
||||
elif single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query,
|
||||
single_query_images,
|
||||
|
||||
@@ -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
|
||||
@@ -26,13 +30,21 @@ cd /opt/data
|
||||
dash_host="${HERMES_DASHBOARD_HOST:-0.0.0.0}"
|
||||
dash_port="${HERMES_DASHBOARD_PORT:-9119}"
|
||||
|
||||
# Binding to anything other than localhost requires --insecure — the
|
||||
# dashboard refuses otherwise because it exposes API keys. Inside a
|
||||
# container this is the expected deployment.
|
||||
# `--insecure` is opt-in via HERMES_DASHBOARD_INSECURE. The dashboard's
|
||||
# OAuth auth gate engages automatically on non-loopback binds when a
|
||||
# DashboardAuthProvider is registered (e.g. the bundled dashboard_auth/nous
|
||||
# provider, which auto-registers when HERMES_DASHBOARD_OAUTH_CLIENT_ID is
|
||||
# set). If no provider is registered, start_server fails closed with a
|
||||
# specific operator-facing error.
|
||||
#
|
||||
# This used to derive --insecure from the bind host ("anything non-loopback
|
||||
# implies insecure"), but that predates the OAuth gate and silently
|
||||
# disabled it on every container-deployed dashboard. The gate is now the
|
||||
# authority; operators on trusted LANs / behind a reverse proxy without
|
||||
# the OAuth contract opt in explicitly.
|
||||
insecure=""
|
||||
case "$dash_host" in
|
||||
127.0.0.1|localhost) ;;
|
||||
*) insecure="--insecure" ;;
|
||||
case "${HERMES_DASHBOARD_INSECURE:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes) insecure="--insecure" ;;
|
||||
esac
|
||||
|
||||
# shellcheck disable=SC2086 # word-splitting of $insecure is intentional
|
||||
|
||||
@@ -33,6 +33,15 @@ INSTALL_DIR="/opt/hermes"
|
||||
mkdir -p "$HERMES_HOME"
|
||||
|
||||
# --- UID/GID remap ---
|
||||
# Accept PUID/PGID as aliases for HERMES_UID/HERMES_GID. NAS users (UGOS,
|
||||
# Synology, unRAID) expect the LinuxServer.io PUID/PGID convention and
|
||||
# bind-mount /opt/data from a host directory owned by their own UID; without
|
||||
# this alias those vars are silently ignored and the s6-setuidgid drop to
|
||||
# UID 10000 leaves the runtime unable to read the volume. HERMES_UID/
|
||||
# HERMES_GID still win when both are set. See #15290, salvages #25872.
|
||||
HERMES_UID="${HERMES_UID:-${PUID:-}}"
|
||||
HERMES_GID="${HERMES_GID:-${PGID:-}}"
|
||||
|
||||
if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
|
||||
echo "[stage2] Changing hermes UID to $HERMES_UID"
|
||||
usermod -u "$HERMES_UID" hermes
|
||||
@@ -44,6 +53,62 @@ if [ -n "${HERMES_GID:-}" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
|
||||
groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --- Docker socket group membership (docker-in-docker / DooD) ---
|
||||
# When the user bind-mounts the host Docker daemon socket
|
||||
# (`-v /var/run/docker.sock:/var/run/docker.sock`) to use the `docker`
|
||||
# terminal backend from inside the container, the socket is owned by the
|
||||
# host's `docker` group (or root). The supervised hermes user (UID 10000)
|
||||
# is not a member of any group that matches the socket's GID, so every
|
||||
# `docker` invocation EACCES'es and `check_terminal_requirements()` fails.
|
||||
# See #16703.
|
||||
#
|
||||
# Granting the supp group via `docker run --group-add <gid>` alone is
|
||||
# NOT sufficient with our s6-setuidgid privilege drop: s6-setuidgid (and
|
||||
# gosu, the older shim) calls initgroups() for the target user, which
|
||||
# rebuilds the supplementary group list from /etc/group. Without an
|
||||
# /etc/group entry whose GID matches the socket, the kernel-granted
|
||||
# supp group is silently wiped between PID 1 and the dropped process.
|
||||
# Confirmed empirically: `--group-add 998` alone leaves the dropped
|
||||
# hermes process with `Groups: 10000` (998 gone); after this hook adds
|
||||
# the entry, the dropped process has `Groups: 998 10000` as expected.
|
||||
#
|
||||
# Fix: detect the socket's GID at boot and ensure /etc/group has a
|
||||
# matching entry that includes hermes. Idempotent across container
|
||||
# restarts. Skipped silently when no socket is bind-mounted.
|
||||
#
|
||||
# Handles the awkward corner cases:
|
||||
# - socket owned by GID 0 (root) — some Podman setups; usermod -aG root
|
||||
# - socket GID already used by a known container group (e.g. tty=5):
|
||||
# reuse that group's name rather than creating a duplicate
|
||||
# - hermes is already a member of the right group (idempotent restart)
|
||||
# - chown/groupadd failures under rootless containers — non-fatal
|
||||
for sock in /var/run/docker.sock /run/docker.sock; do
|
||||
[ -S "$sock" ] || continue
|
||||
sock_gid=$(stat -c '%g' "$sock" 2>/dev/null) || continue
|
||||
[ -n "$sock_gid" ] || continue
|
||||
# Already a member? Nothing to do.
|
||||
if id -G hermes 2>/dev/null | tr ' ' '\n' | grep -qx "$sock_gid"; then
|
||||
echo "[stage2] hermes already in group $sock_gid for $sock"
|
||||
break
|
||||
fi
|
||||
# Resolve or create a group name for this GID.
|
||||
sock_group=$(getent group "$sock_gid" 2>/dev/null | cut -d: -f1)
|
||||
if [ -z "$sock_group" ]; then
|
||||
sock_group="hostdocker"
|
||||
if ! groupadd -g "$sock_gid" "$sock_group" 2>/dev/null; then
|
||||
echo "[stage2] Warning: groupadd -g $sock_gid $sock_group failed; skipping docker socket group setup"
|
||||
break
|
||||
fi
|
||||
echo "[stage2] Created group $sock_group (GID $sock_gid) for Docker socket"
|
||||
fi
|
||||
if usermod -aG "$sock_group" hermes 2>/dev/null; then
|
||||
echo "[stage2] Added hermes to group $sock_group (GID $sock_gid) for $sock"
|
||||
else
|
||||
echo "[stage2] Warning: usermod -aG $sock_group hermes failed; docker backend may fail with EACCES"
|
||||
fi
|
||||
break
|
||||
done
|
||||
|
||||
# --- Fix ownership of data volume ---
|
||||
# When HERMES_UID is remapped or the top-level $HERMES_HOME isn't owned by
|
||||
# the runtime hermes UID, restore ownership to hermes — but ONLY for the
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Network Egress Isolation for Docker Deployments
|
||||
|
||||
When running Hermes inside Docker, the default `network_mode: host` gives the
|
||||
agent process unrestricted outbound network access. This guide shows how to
|
||||
segment traffic so the agent core can only reach the services it needs, while
|
||||
blocking arbitrary outbound connections.
|
||||
|
||||
This is primarily a defense against prompt injection attacks that attempt to
|
||||
exfiltrate data via `curl`, `wget`, or raw HTTP from tool-generated shell
|
||||
commands.
|
||||
|
||||
## Threat Model
|
||||
|
||||
The Hermes [SECURITY.md](../../SECURITY.md) §2 defines the trust model. The
|
||||
terminal backend is the primary execution boundary. However, when running with
|
||||
`network_mode: host`, any command the agent executes can reach any endpoint on
|
||||
the network, including external ones.
|
||||
|
||||
Network egress isolation adds a second layer: even if a malicious command
|
||||
executes inside the container, it cannot reach endpoints outside the
|
||||
explicitly allowlisted set.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Docker Network: internal (no internet) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ hermes-agent │ │ hermes-dashboard │ │
|
||||
│ └──────┬───────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ ┌──────────────┐ │ │
|
||||
│ │ hermes-gtw │◄───────────┘ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
└──────────┼───────────────────────────────────┘
|
||||
│
|
||||
┌──────────┼───────────────────────────────────┐
|
||||
│ Docker Network: egress (internet-capable) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ egress-proxy │──► allowlisted hosts │
|
||||
│ │ (squid / envoy) │ │
|
||||
│ └─────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Two Docker networks:
|
||||
|
||||
- **`internal`** — no default route, no internet access. The agent, dashboard,
|
||||
and gateway run here.
|
||||
- **`egress`** — has internet access. Only services that need to reach external
|
||||
APIs are attached to this network.
|
||||
|
||||
The gateway service is dual-homed (attached to both networks) so it can
|
||||
receive inbound messages from Telegram/Slack/etc. and forward them to the
|
||||
agent on the internal network.
|
||||
|
||||
## Compose Configuration
|
||||
|
||||
Override the default `docker-compose.yml` with a
|
||||
`docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
# Network egress isolation for production deployments.
|
||||
#
|
||||
# Usage:
|
||||
# HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d
|
||||
#
|
||||
# This overrides network_mode: host with isolated Docker networks.
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
internal: true # no default route, no internet
|
||||
egress:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
gateway:
|
||||
network_mode: "" # clear the host-mode default
|
||||
networks:
|
||||
- internal
|
||||
- egress # needs outbound for Telegram, LLM APIs
|
||||
ports:
|
||||
- "127.0.0.1:9119:9119" # dashboard proxy, localhost only
|
||||
|
||||
dashboard:
|
||||
network_mode: ""
|
||||
networks:
|
||||
- internal # internal only, no egress needed
|
||||
```
|
||||
|
||||
### With an Egress Proxy (Recommended)
|
||||
|
||||
For tighter control, route all outbound traffic through an HTTP proxy with
|
||||
an explicit allowlist:
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml (with egress proxy)
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
egress:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
gateway:
|
||||
network_mode: ""
|
||||
networks:
|
||||
- internal
|
||||
- egress
|
||||
environment:
|
||||
- HTTP_PROXY=http://egress-proxy:3128
|
||||
- HTTPS_PROXY=http://egress-proxy:3128
|
||||
- NO_PROXY=hermes,hermes-dashboard,localhost
|
||||
|
||||
dashboard:
|
||||
network_mode: ""
|
||||
networks:
|
||||
- internal
|
||||
|
||||
egress-proxy:
|
||||
image: ubuntu/squid:6.10-24.04_edge
|
||||
networks:
|
||||
- egress
|
||||
volumes:
|
||||
- ./config/squid-allowlist.conf:/etc/squid/conf.d/allowlist.conf:ro
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Example `config/squid-allowlist.conf`:
|
||||
|
||||
```
|
||||
# Only allow HTTPS CONNECT to these hosts
|
||||
acl allowed_hosts dstdomain api.openai.com
|
||||
acl allowed_hosts dstdomain api.anthropic.com
|
||||
acl allowed_hosts dstdomain openrouter.ai
|
||||
acl allowed_hosts dstdomain generativelanguage.googleapis.com
|
||||
acl allowed_hosts dstdomain api.telegram.org
|
||||
acl allowed_hosts dstdomain api.github.com
|
||||
acl allowed_hosts dstdomain discord.com
|
||||
|
||||
http_access allow CONNECT allowed_hosts
|
||||
http_access deny all
|
||||
```
|
||||
|
||||
Adjust the allowlist to match your LLM provider and messaging platform.
|
||||
|
||||
## Validating the Setup
|
||||
|
||||
After bringing up the stack, verify isolation:
|
||||
|
||||
```bash
|
||||
# From the agent container: this should FAIL (no egress)
|
||||
docker compose exec gateway \
|
||||
curl -sf --max-time 5 https://example.com && echo "FAIL: egress not blocked" || echo "OK: egress blocked"
|
||||
|
||||
# From the agent container: this should SUCCEED (internal network)
|
||||
docker compose exec gateway \
|
||||
curl -sf --max-time 5 http://hermes-dashboard:9119/health && echo "OK: internal reachable" || echo "FAIL"
|
||||
|
||||
# If using egress proxy: this should SUCCEED (allowlisted)
|
||||
docker compose exec gateway \
|
||||
curl -sf --max-time 5 --proxy http://egress-proxy:3128 https://api.openai.com/v1/models && echo "OK" || echo "FAIL"
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- **DNS resolution:** The `internal` network can still resolve external DNS
|
||||
names unless you also run a local DNS resolver that blocks external queries.
|
||||
For most threat models this is acceptable since DNS resolution alone does not
|
||||
exfiltrate meaningful data.
|
||||
|
||||
- **Not a substitute for sandbox backends:** This guide isolates the agent
|
||||
*container's* network. If you use the default local terminal backend, tool
|
||||
commands execute inside the same container. For stronger isolation, combine
|
||||
network segmentation with a sandboxed terminal backend (Docker, Modal,
|
||||
Daytona).
|
||||
|
||||
- **Platform adapters need egress:** The gateway service needs outbound access
|
||||
to reach messaging platform APIs. If you add new platform adapters, add their
|
||||
API endpoints to the proxy allowlist.
|
||||
|
||||
## Related
|
||||
|
||||
- [SECURITY.md](../../SECURITY.md) — Hermes trust model and vulnerability reporting
|
||||
- [Terminal backends](../../README.md) — sandboxed execution targets
|
||||
- [docker-compose.yml](../../docker-compose.yml) — default compose configuration
|
||||
@@ -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 "):
|
||||
@@ -1604,6 +1605,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
)
|
||||
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
||||
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
|
||||
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
|
||||
await queue.put(_event_payload("assistant.completed", {
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
@@ -1616,6 +1618,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
"completed": True,
|
||||
"messages": turn_messages,
|
||||
"usage": usage,
|
||||
}))
|
||||
except Exception as exc:
|
||||
@@ -3328,6 +3331,44 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return len(prior)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _turn_transcript_messages(
|
||||
cls,
|
||||
conversation_history: List[Dict[str, Any]],
|
||||
user_message: Any,
|
||||
result: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return this turn's assistant/tool messages in client-safe shape.
|
||||
|
||||
The streaming SSE contract delivers all assistant text as
|
||||
``assistant.delta`` events under one ``message_id`` interleaved with
|
||||
``tool.*`` events, and a single ``assistant.completed`` carrying only
|
||||
the final reply. A client that accumulates deltas into one buffer
|
||||
cannot reconstruct *intermediate* assistant text segments that preceded
|
||||
tool calls — so when the page is re-opened mid/post-stream those
|
||||
segments appear lost, even though state.db persisted them correctly.
|
||||
|
||||
Emitting the authoritative per-turn transcript on ``run.completed`` lets
|
||||
any SSE consumer reconcile its live view against ground truth without a
|
||||
separate ``GET /messages`` round-trip. Purely additive: clients that
|
||||
ignore the field are unaffected. Refs #34703.
|
||||
"""
|
||||
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
||||
if not isinstance(agent_messages, list) or not agent_messages:
|
||||
return []
|
||||
start = cls._response_messages_turn_start_index(
|
||||
conversation_history, user_message, result
|
||||
)
|
||||
turn = agent_messages[start:]
|
||||
out: List[Dict[str, Any]] = []
|
||||
for msg in turn:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") not in {"assistant", "tool"}:
|
||||
continue
|
||||
out.append(cls._message_response(msg))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -4099,11 +4140,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 +4184,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,
|
||||
|
||||
+235
-37
@@ -472,6 +472,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non
|
||||
return False
|
||||
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -829,6 +830,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,
|
||||
@@ -840,6 +848,13 @@ MEDIA_DELIVERY_SAFE_ROOTS = (
|
||||
_HERMES_HOME / "video_cache",
|
||||
_HERMES_HOME / "document_cache",
|
||||
_HERMES_HOME / "browser_screenshots",
|
||||
# Canonical cache layout — listed alongside the legacy *_cache dirs so
|
||||
# generated artifacts deliver on installs that have both (#31733).
|
||||
_HERMES_HOME / "cache" / "images",
|
||||
_HERMES_HOME / "cache" / "audio",
|
||||
_HERMES_HOME / "cache" / "videos",
|
||||
_HERMES_HOME / "cache" / "documents",
|
||||
_HERMES_HOME / "cache" / "screenshots",
|
||||
)
|
||||
|
||||
# Default recency window for trusting freshly-produced files (seconds).
|
||||
@@ -918,6 +933,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 +1002,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
|
||||
@@ -987,7 +1029,11 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
expanded = Path(os.path.expanduser(candidate))
|
||||
try:
|
||||
expanded = Path(os.path.expanduser(candidate))
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
# expanduser raises ValueError("embedded null byte") for a ~\x00 path.
|
||||
return None
|
||||
if not expanded.is_absolute():
|
||||
return None
|
||||
|
||||
@@ -999,6 +1045,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 +1055,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()
|
||||
@@ -1020,6 +1077,17 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
# Neutralise control chars and the Unicode line separators (NEL, LS, PS) that
|
||||
# str.splitlines() / log aggregators treat as breaks, so a model-emitted path
|
||||
# can't forge a second log line. Truncated to keep records bounded.
|
||||
_LOG_UNSAFE_CHARS = re.compile(r"[\x00-\x1f\x7f\x85\u2028\u2029]")
|
||||
|
||||
|
||||
def _log_safe_path(path: str) -> str:
|
||||
"""Return a single-line, length-bounded path for log output."""
|
||||
return _LOG_UNSAFE_CHARS.sub("?", str(path))[:200]
|
||||
|
||||
|
||||
SUPPORTED_DOCUMENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
".md": "text/markdown",
|
||||
@@ -1063,6 +1131,75 @@ SUPPORTED_IMAGE_DOCUMENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Media-delivery extension allowlist — SINGLE SOURCE OF TRUTH
|
||||
#
|
||||
# Both extractors that turn response text into native attachments derive their
|
||||
# extension set from this tuple:
|
||||
# * ``extract_media()`` — explicit ``MEDIA:<path>`` tags
|
||||
# * ``extract_local_files()`` — bare absolute/home paths the agent mentions
|
||||
#
|
||||
# Historically these two carried independently-maintained extension lists.
|
||||
# ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while
|
||||
# ``extract_local_files`` had a broad one. Combined with the unconditional
|
||||
# ``MEDIA:\\s*\\S+`` cleanup at the dispatch sites, that mismatch created a
|
||||
# silent black hole: a ``MEDIA:/report.md`` tag failed the narrow extract_media
|
||||
# match, got stripped from the body by the loose cleanup regex, and was then
|
||||
# invisible to extract_local_files — the file was never delivered (issue
|
||||
# #34517). Keeping one list eliminates the drift; building the cleanup regexes
|
||||
# from the same set means a tag is only stripped when its extension is one we
|
||||
# can actually deliver, so an unknown-extension path survives in the body
|
||||
# instead of vanishing.
|
||||
#
|
||||
# Covers images (inline), video (inline where supported), audio (voice/audio),
|
||||
# documents/spreadsheets/presentations (send_document), archives, and rendered
|
||||
# web output. The dispatch partition (image vs video vs document) lives in
|
||||
# ``gateway/run.py``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
|
||||
# Images (embed inline)
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg",
|
||||
# Video (embed inline where supported)
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm",
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
|
||||
# Documents (uploaded as file attachments)
|
||||
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
|
||||
# Spreadsheets / data
|
||||
".xlsx", ".xls", ".ods", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
|
||||
# Presentations
|
||||
".pptx", ".ppt", ".odp", ".key",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa",
|
||||
# Web / rendered output
|
||||
".html", ".htm",
|
||||
)
|
||||
|
||||
# Regex alternation fragment of bare extensions (no leading dot), e.g.
|
||||
# ``png|jpe?g|...``. ``jpe?g`` collapses jpg/jpeg into one branch. Sorted
|
||||
# longest-first so the alternation never matches a shorter ext as a prefix of
|
||||
# a longer one (e.g. ``.tar`` before ``.tar.gz`` components).
|
||||
_MEDIA_EXT_ALTERNATION = "|".join(
|
||||
sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)
|
||||
)
|
||||
|
||||
# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose
|
||||
# ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known
|
||||
# deliverable extension (optionally quoted/backticked). A ``MEDIA:`` tag with
|
||||
# an unknown extension is left in the text so it can still be picked up by the
|
||||
# bare-path detector (extract_local_files) downstream rather than silently
|
||||
# deleted. Shared by the non-streaming dispatch path and the streaming
|
||||
# consumer so both behave identically.
|
||||
MEDIA_TAG_CLEANUP_RE = re.compile(
|
||||
r'''[`"']?MEDIA:\s*'''
|
||||
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
|
||||
r'''(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def get_document_cache_dir() -> Path:
|
||||
"""Return the document cache directory, creating it if it doesn't exist."""
|
||||
DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1516,6 +1653,10 @@ class BasePlatformAdapter(ABC):
|
||||
self.config = config
|
||||
self.platform = platform
|
||||
self._message_handler: Optional[MessageHandler] = None
|
||||
# Optional hook (e.g. Telegram DM topic recovery) that rewrites
|
||||
# ``event.source.thread_id`` before session keying. Returns the
|
||||
# corrected thread_id or None to leave the source untouched.
|
||||
self._topic_recovery_fn: Optional[Callable[[Any], Optional[str]]] = None
|
||||
self._running = False
|
||||
self._fatal_error_code: Optional[str] = None
|
||||
self._fatal_error_message: Optional[str] = None
|
||||
@@ -1583,6 +1724,29 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return len
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Whether this adapter gates inbound access before dispatch.
|
||||
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
|
||||
config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` — and
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check runs *after* the adapter, so for
|
||||
these platforms a message arriving at ``_is_user_authorized`` has, by
|
||||
definition, already been authorized by the adapter. Without this flag the
|
||||
gateway would then deny it again (no env allowlist → default deny),
|
||||
silently breaking ``dm_policy: open`` and config-only allowlists.
|
||||
|
||||
Adapters that own their access policy override this to return ``True``.
|
||||
The gateway treats that as "already authorized at intake" and skips the
|
||||
env-allowlist default-deny. Adapters that delegate access control to the
|
||||
gateway leave it ``False`` (the default).
|
||||
"""
|
||||
return False
|
||||
|
||||
def supports_draft_streaming(
|
||||
self,
|
||||
chat_type: Optional[str] = None,
|
||||
@@ -1771,6 +1935,40 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
self._message_handler = handler
|
||||
|
||||
def set_topic_recovery_fn(
|
||||
self,
|
||||
fn: Optional[Callable[[Any], Optional[str]]],
|
||||
) -> None:
|
||||
"""Install a thread_id-recovery hook (Telegram DM topic mode).
|
||||
|
||||
The hook is called with ``event.source`` before session keying;
|
||||
a non-None return value replaces ``source.thread_id``. Pass
|
||||
``None`` to clear the hook.
|
||||
"""
|
||||
# Guard against subclasses that initialize via ``object.__new__`` in
|
||||
# tests and never run ``BasePlatformAdapter.__init__``.
|
||||
self._topic_recovery_fn = fn # type: ignore[attr-defined]
|
||||
|
||||
def _apply_topic_recovery(self, event: MessageEvent) -> None:
|
||||
"""Rewrite ``event.source.thread_id`` in place if the hook returns one."""
|
||||
recover = getattr(self, "_topic_recovery_fn", None)
|
||||
if recover is None:
|
||||
return
|
||||
source = getattr(event, "source", None)
|
||||
if source is None:
|
||||
return
|
||||
try:
|
||||
recovered = recover(source)
|
||||
except Exception:
|
||||
logger.debug("topic recovery hook failed", exc_info=True)
|
||||
return
|
||||
if recovered is None or str(recovered) == str(source.thread_id or ""):
|
||||
return
|
||||
try:
|
||||
event.source = dataclasses.replace(source, thread_id=str(recovered))
|
||||
except Exception:
|
||||
logger.debug("topic recovery rewrite failed", exc_info=True)
|
||||
|
||||
def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None:
|
||||
"""Set an optional handler for messages arriving during active sessions."""
|
||||
self._busy_session_handler = handler
|
||||
@@ -2354,11 +2552,12 @@ class BasePlatformAdapter(ABC):
|
||||
"""Drop unsafe MEDIA paths and normalize accepted paths."""
|
||||
safe_media: List[Tuple[str, bool]] = []
|
||||
for media_path, is_voice in media_files or []:
|
||||
safe_path = validate_media_delivery_path(str(media_path))
|
||||
raw = str(media_path)
|
||||
safe_path = validate_media_delivery_path(raw)
|
||||
if safe_path:
|
||||
safe_media.append((safe_path, bool(is_voice)))
|
||||
else:
|
||||
logger.warning("Skipping unsafe MEDIA directive path outside allowed roots")
|
||||
logger.warning("Skipping unsafe MEDIA directive path: %s", _log_safe_path(raw))
|
||||
return safe_media
|
||||
|
||||
@staticmethod
|
||||
@@ -2366,11 +2565,12 @@ class BasePlatformAdapter(ABC):
|
||||
"""Drop unsafe bare local file paths and normalize accepted paths."""
|
||||
safe_paths: List[str] = []
|
||||
for file_path in file_paths or []:
|
||||
safe_path = validate_media_delivery_path(str(file_path))
|
||||
raw = str(file_path)
|
||||
safe_path = validate_media_delivery_path(raw)
|
||||
if safe_path:
|
||||
safe_paths.append(safe_path)
|
||||
else:
|
||||
logger.warning("Skipping unsafe local file path outside allowed roots")
|
||||
logger.warning("Skipping unsafe local file path: %s", _log_safe_path(raw))
|
||||
return safe_paths
|
||||
|
||||
@staticmethod
|
||||
@@ -2411,17 +2611,22 @@ class BasePlatformAdapter(ABC):
|
||||
cleaned = cleaned.replace("[[as_document]]", "")
|
||||
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
media_pattern = re.compile(
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?'''
|
||||
)
|
||||
# and quoted/backticked paths for LLM-formatted outputs. The extension
|
||||
# set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
|
||||
# MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
|
||||
media_pattern = MEDIA_TAG_CLEANUP_RE
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
|
||||
path = path[1:-1].strip()
|
||||
path = path.lstrip("`\"'").rstrip("`\"',.;:)}]")
|
||||
if path:
|
||||
media.append((os.path.expanduser(path), has_voice_tag))
|
||||
try:
|
||||
media.append((os.path.expanduser(path), has_voice_tag))
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
# Skip a crafted ~\x00 path rather than aborting extraction
|
||||
# and dropping every other attachment in the response.
|
||||
continue
|
||||
|
||||
# Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
|
||||
if media:
|
||||
@@ -2455,24 +2660,7 @@ class BasePlatformAdapter(ABC):
|
||||
Tuple of (list of expanded file paths, cleaned text with the
|
||||
raw path strings removed).
|
||||
"""
|
||||
_LOCAL_MEDIA_EXTS = (
|
||||
# Images (embed inline)
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg',
|
||||
# Video (embed inline where supported)
|
||||
'.mp4', '.mov', '.avi', '.mkv', '.webm',
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
'.mp3', '.wav', '.ogg', '.m4a', '.flac',
|
||||
# Documents (uploaded as file attachments)
|
||||
'.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md',
|
||||
# Spreadsheets / data
|
||||
'.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
|
||||
# Presentations
|
||||
'.pptx', '.ppt', '.odp', '.key',
|
||||
# Archives
|
||||
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
|
||||
# Web / rendered output
|
||||
'.html', '.htm',
|
||||
)
|
||||
_LOCAL_MEDIA_EXTS = MEDIA_DELIVERY_EXTS
|
||||
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
@@ -3287,7 +3475,12 @@ class BasePlatformAdapter(ABC):
|
||||
return
|
||||
|
||||
coerce_plaintext_gateway_command(event)
|
||||
|
||||
|
||||
# Rewrite ``event.source.thread_id`` via the installed recovery hook
|
||||
# (Telegram DM topic mode) so the session key, guard checks, and
|
||||
# downstream delivery all agree on the same lane.
|
||||
self._apply_topic_recovery(event)
|
||||
|
||||
session_key = build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
@@ -3588,7 +3781,12 @@ class BasePlatformAdapter(ABC):
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = text_content.replace("[[as_document]]", "").strip()
|
||||
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
|
||||
# Strip only MEDIA: tags whose path has a deliverable extension
|
||||
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
|
||||
# extension is intentionally left in the body so extract_local_files
|
||||
# below can still pick up the bare path — otherwise the file would
|
||||
# be silently dropped (issue #34517).
|
||||
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ user is seen through different apps in the future.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import hashlib
|
||||
import hmac
|
||||
import itertools
|
||||
@@ -1408,6 +1409,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
"""Feishu/Lark bot adapter."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
|
||||
CHAT_LOCK_MAX_SIZE: int = 1000
|
||||
# Threshold for detecting Feishu client-side message splits.
|
||||
# When a chunk is near the ~4096-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1445,7 +1448,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._pending_inbound_lock = threading.Lock()
|
||||
self._pending_drain_scheduled = False
|
||||
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
|
||||
self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
|
||||
self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
|
||||
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
|
||||
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
|
||||
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -2835,11 +2838,28 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# =========================================================================
|
||||
|
||||
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
|
||||
|
||||
Bounded with LRU eviction so a long-running gateway that sees many
|
||||
distinct chats does not grow ``_chat_locks`` without limit. Locks that
|
||||
are currently held are never evicted; if every entry is locked we fall
|
||||
back to dropping the least-recently-used one.
|
||||
"""
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
if lock is not None:
|
||||
self._chat_locks.move_to_end(chat_id)
|
||||
return lock
|
||||
if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
|
||||
evicted = False
|
||||
for key in list(self._chat_locks):
|
||||
if not self._chat_locks[key].locked():
|
||||
self._chat_locks.pop(key)
|
||||
evicted = True
|
||||
break
|
||||
if not evicted:
|
||||
self._chat_locks.pop(next(iter(self._chat_locks)))
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
|
||||
|
||||
@@ -2236,7 +2236,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if prompt and not prompt.resolved:
|
||||
if room_id != prompt.chat_id:
|
||||
return
|
||||
if self._allowed_user_ids and sender not in self._allowed_user_ids:
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids):
|
||||
logger.info(
|
||||
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
|
||||
sender, reacts_to,
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -126,7 +126,6 @@ from gateway.platforms.qqbot.chunked_upload import (
|
||||
)
|
||||
from gateway.platforms.qqbot.keyboards import (
|
||||
ApprovalRequest,
|
||||
ApprovalSender,
|
||||
InlineKeyboard,
|
||||
InteractionEvent,
|
||||
build_approval_keyboard,
|
||||
@@ -270,6 +269,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
def name(self) -> str:
|
||||
return "QQBot"
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""QQBot gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -37,7 +37,7 @@ import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
@@ -1690,7 +1690,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
BotCommandScopeAllPrivateChats,
|
||||
BotCommandScopeAllGroupChats,
|
||||
BotCommandScopeDefault,
|
||||
BotCommandScopeChat,
|
||||
)
|
||||
from hermes_cli.commands import telegram_menu_commands
|
||||
# Telegram allows up to 100 commands but has an undocumented
|
||||
@@ -5027,8 +5026,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
"""Session-scoped key for text message batching.
|
||||
|
||||
Applies the installed topic-recovery hook first so DM-topic batches
|
||||
coalesce on (and dispatch to) the recovered lane rather than the
|
||||
raw inbound ``message_thread_id`` Telegram may have attached.
|
||||
"""
|
||||
from gateway.session import build_session_key
|
||||
self._apply_topic_recovery(event)
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
|
||||
@@ -847,6 +847,11 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
# Policy helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""WeCom gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
return False
|
||||
|
||||
@@ -658,52 +658,6 @@ def _split_table_row(line: str) -> List[str]:
|
||||
return [cell.strip() for cell in row.split("|")]
|
||||
|
||||
|
||||
def _rewrite_headers_for_weixin(line: str) -> str:
|
||||
match = _HEADER_RE.match(line)
|
||||
if not match:
|
||||
return line.rstrip()
|
||||
level = len(match.group(1))
|
||||
title = match.group(2).strip()
|
||||
if level == 1:
|
||||
return f"【{title}】"
|
||||
return f"**{title}**"
|
||||
|
||||
|
||||
def _rewrite_table_block_for_weixin(lines: List[str]) -> str:
|
||||
if len(lines) < 2:
|
||||
return "\n".join(lines)
|
||||
headers = _split_table_row(lines[0])
|
||||
body_rows = [_split_table_row(line) for line in lines[2:] if line.strip()]
|
||||
if not headers or not body_rows:
|
||||
return "\n".join(lines)
|
||||
|
||||
formatted_rows: List[str] = []
|
||||
for row in body_rows:
|
||||
pairs = []
|
||||
for idx, header in enumerate(headers):
|
||||
if idx >= len(row):
|
||||
break
|
||||
label = header or f"Column {idx + 1}"
|
||||
value = row[idx].strip()
|
||||
if value:
|
||||
pairs.append((label, value))
|
||||
if not pairs:
|
||||
continue
|
||||
if len(pairs) == 1:
|
||||
label, value = pairs[0]
|
||||
formatted_rows.append(f"- {label}: {value}")
|
||||
continue
|
||||
if len(pairs) == 2:
|
||||
label, value = pairs[0]
|
||||
other_label, other_value = pairs[1]
|
||||
formatted_rows.append(f"- {label}: {value}")
|
||||
formatted_rows.append(f" {other_label}: {other_value}")
|
||||
continue
|
||||
summary = " | ".join(f"{label}: {value}" for label, value in pairs)
|
||||
formatted_rows.append(f"- {summary}")
|
||||
return "\n".join(formatted_rows) if formatted_rows else "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_markdown_blocks(content: str) -> str:
|
||||
lines = content.splitlines()
|
||||
result: List[str] = []
|
||||
@@ -1443,6 +1397,11 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
|
||||
await self.handle_message(event)
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
return False
|
||||
|
||||
@@ -2230,6 +2230,45 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
|
||||
name = "media-resolve"
|
||||
|
||||
# --- Resource download cache (keyed by resourceId) ---
|
||||
# Avoids redundant downloads of the same resource within the TTL window.
|
||||
# The same resourceId can be referenced multiple times in a session (own
|
||||
# attachment, then quoted again, then observed in a group backfill); each
|
||||
# reference otherwise triggers a fresh token exchange + download.
|
||||
_resource_cache: ClassVar[Dict[str, Tuple[str, str, float]]] = {} # rid -> (local_path, mime, ts)
|
||||
_RESOURCE_CACHE_TTL_S: ClassVar[int] = 24 * 60 * 60 # 24 hours
|
||||
_RESOURCE_CACHE_MAX_SIZE: ClassVar[int] = 256
|
||||
|
||||
@classmethod
|
||||
def _get_cached_resource(cls, resource_id: str) -> Optional[Tuple[str, str]]:
|
||||
"""Return cached ``(local_path, mime)`` if still valid and file exists, else None."""
|
||||
if not resource_id:
|
||||
return None
|
||||
entry = cls._resource_cache.get(resource_id)
|
||||
if entry is None:
|
||||
return None
|
||||
local_path, mime, ts = entry
|
||||
if time.time() - ts > cls._RESOURCE_CACHE_TTL_S:
|
||||
cls._resource_cache.pop(resource_id, None)
|
||||
return None
|
||||
# Verify the cached file still exists on disk (cache dir may be swept).
|
||||
if not os.path.isfile(local_path):
|
||||
cls._resource_cache.pop(resource_id, None)
|
||||
return None
|
||||
return local_path, mime
|
||||
|
||||
@classmethod
|
||||
def _put_cached_resource(cls, resource_id: str, local_path: str, mime: str) -> None:
|
||||
"""Store download result in cache. Evicts oldest entries when over capacity."""
|
||||
if not resource_id:
|
||||
return
|
||||
if len(cls._resource_cache) >= cls._RESOURCE_CACHE_MAX_SIZE:
|
||||
# Drop the oldest 25% of entries by timestamp.
|
||||
sorted_keys = sorted(cls._resource_cache, key=lambda k: cls._resource_cache[k][2])
|
||||
for k in sorted_keys[: cls._RESOURCE_CACHE_MAX_SIZE // 4]:
|
||||
cls._resource_cache.pop(k, None)
|
||||
cls._resource_cache[resource_id] = (local_path, mime, time.time())
|
||||
|
||||
@staticmethod
|
||||
def _guess_image_ext_from_url(url: str) -> str:
|
||||
"""Guess image extension from URL path."""
|
||||
@@ -2327,8 +2366,23 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
async def _download_and_cache(
|
||||
cls, adapter, *, fetch_url: str, kind: str,
|
||||
file_name: Optional[str] = None, log_tag: str = "",
|
||||
resource_id: str = "",
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``."""
|
||||
"""Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``.
|
||||
|
||||
When *resource_id* is provided, an in-memory cache keyed by resourceId
|
||||
is consulted first to skip redundant downloads of the same resource
|
||||
within the TTL window.
|
||||
"""
|
||||
if resource_id:
|
||||
hit = cls._get_cached_resource(resource_id)
|
||||
if hit is not None:
|
||||
logger.debug(
|
||||
"[%s] resource cache hit: rid=%s path=%s",
|
||||
adapter.name, resource_id, hit[0],
|
||||
)
|
||||
return hit
|
||||
|
||||
try:
|
||||
file_bytes, content_type = await media_download_url(
|
||||
fetch_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB,
|
||||
@@ -2353,6 +2407,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
mime = guess_mime_type(f"image{ext}")
|
||||
if not mime.startswith("image/"):
|
||||
mime = content_type if content_type.startswith("image/") else "image/jpeg"
|
||||
cls._put_cached_resource(resource_id, local_path, mime)
|
||||
return local_path, mime
|
||||
|
||||
# kind == "file"
|
||||
@@ -2368,6 +2423,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
)
|
||||
return None
|
||||
mime = guess_mime_type(file_name) or content_type or "application/octet-stream"
|
||||
cls._put_cached_resource(resource_id, local_path, mime)
|
||||
return local_path, mime
|
||||
|
||||
@classmethod
|
||||
@@ -2393,6 +2449,9 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
|
||||
continue
|
||||
|
||||
# Extract resourceId from the placeholder URL for cache dedup.
|
||||
rid = ExtractContentMiddleware._parse_resource_id(url)
|
||||
|
||||
try:
|
||||
fetch_url = await cls._resolve_download_url(adapter, url)
|
||||
except Exception as exc:
|
||||
@@ -2408,6 +2467,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
kind=kind,
|
||||
file_name=str(ref.get("name") or "").strip() or None,
|
||||
log_tag=f"placeholder_url={url[:80]}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
@@ -2480,6 +2540,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
@@ -2563,6 +2624,7 @@ class DispatchMiddleware(InboundMiddleware):
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"quote rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
@@ -4629,6 +4691,11 @@ class YuanbaoAdapter(BasePlatformAdapter):
|
||||
# Abstract method implementations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Yuanbao gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Yuanbao WS gateway and authenticate.
|
||||
|
||||
|
||||
+519
-47
@@ -751,7 +751,7 @@ _hermes_home = get_hermes_home()
|
||||
|
||||
# Load environment variables from ~/.hermes/.env first.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol
|
||||
from dotenv import load_dotenv # noqa: F401 # backward-compat for tests that monkeypatch this symbol
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
_env_path = _hermes_home / '.env'
|
||||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
|
||||
@@ -831,6 +831,8 @@ if _config_path.exists():
|
||||
"docker_env": "TERMINAL_DOCKER_ENV",
|
||||
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
}
|
||||
@@ -932,9 +934,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):
|
||||
@@ -1800,7 +1807,34 @@ class GatewayRunner:
|
||||
ensure_installed(log_failures=False)
|
||||
except Exception:
|
||||
pass # Non-fatal — fail-open at scan time if unavailable
|
||||
|
||||
|
||||
# Startup heads-up (#30882): a gateway in manual approval mode with no
|
||||
# automated risk assessor (tirith disabled AND no auxiliary.approval
|
||||
# model) can only gate dangerous commands / execute_code scripts via
|
||||
# live in-chat approval. With approval routing fixed, those actions now
|
||||
# fail closed (block) rather than silently auto-running — surface that
|
||||
# so operators knowingly enable tirith or configure auxiliary.approval
|
||||
# for unattended gateways.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
_appr_cfg = _load_full_config()
|
||||
_appr_mode = str(
|
||||
cfg_get(_appr_cfg, "approvals", "mode", default="manual") or "manual"
|
||||
).strip().lower()
|
||||
_tirith_on = bool(cfg_get(_appr_cfg, "security", "tirith_enabled", default=True))
|
||||
_aux_approval = cfg_get(_appr_cfg, "auxiliary", "approval", default=None)
|
||||
if _appr_mode == "manual" and not _tirith_on and not _aux_approval:
|
||||
logger.warning(
|
||||
"Gateway approvals.mode=manual with no automated risk "
|
||||
"assessor (security.tirith_enabled is false and "
|
||||
"auxiliary.approval is unset): dangerous commands and "
|
||||
"execute_code scripts will BLOCK until a human approves "
|
||||
"them in chat. Enable security.tirith_enabled or configure "
|
||||
"auxiliary.approval for unattended operation."
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("approvals.mode startup check skipped", exc_info=True)
|
||||
|
||||
# Initialize session database for session_search tool support
|
||||
self._session_db = None
|
||||
try:
|
||||
@@ -2296,6 +2330,32 @@ class GatewayRunner:
|
||||
session_id=session_entry.session_id,
|
||||
)
|
||||
|
||||
def _sync_telegram_topic_binding(
|
||||
self,
|
||||
source: SessionSource,
|
||||
session_entry,
|
||||
*,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Update the topic binding to point at ``session_entry.session_id``.
|
||||
|
||||
Telegram topic lanes persist a (chat_id, thread_id) -> session_id row
|
||||
so reopening a topic in a fresh process resumes the right Hermes
|
||||
session. When compression rotates ``session_entry.session_id`` mid-turn,
|
||||
the binding goes stale and the next inbound message in that topic
|
||||
reloads the oversized parent transcript instead of the compressed
|
||||
child, retriggering preflight compression — sometimes in a loop
|
||||
(#20470, #29712, #33414).
|
||||
"""
|
||||
if not self._is_telegram_topic_lane(source):
|
||||
return
|
||||
try:
|
||||
self._record_telegram_topic_binding(source, session_entry)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"telegram topic binding refresh failed (%s)", reason, exc_info=True,
|
||||
)
|
||||
|
||||
def _recover_telegram_topic_thread_id(
|
||||
self,
|
||||
source: SessionSource,
|
||||
@@ -4152,6 +4212,7 @@ class GatewayRunner:
|
||||
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
|
||||
adapter.set_session_store(self.session_store)
|
||||
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
|
||||
adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
|
||||
adapter._busy_text_mode = self._busy_text_mode
|
||||
|
||||
# Try to connect
|
||||
@@ -5413,6 +5474,49 @@ class GatewayRunner:
|
||||
)
|
||||
stale_timeout_seconds = 0
|
||||
|
||||
# Read kanban.default_assignee — fallback profile for tasks
|
||||
# created without an explicit assignee (e.g. via the dashboard).
|
||||
# When set, the dispatcher applies it to unassigned ready tasks
|
||||
# instead of skipping them indefinitely (#27145). Empty string
|
||||
# (the schema default) means "no fallback, keep skipping" —
|
||||
# backward-compatible with existing installs.
|
||||
default_assignee = (kanban_cfg.get("default_assignee") or "").strip() or None
|
||||
if default_assignee:
|
||||
logger.info(
|
||||
"kanban dispatcher: default_assignee=%r (unassigned ready tasks "
|
||||
"will route to this profile)",
|
||||
default_assignee,
|
||||
)
|
||||
|
||||
# Read kanban.max_in_progress_per_profile — per-profile concurrency
|
||||
# cap (#21582). When set, no single profile gets more than N
|
||||
# workers running at once, even if the global max_in_progress
|
||||
# would allow it. Prevents one profile's local model / API quota
|
||||
# / browser pool from being overwhelmed by a fan-out.
|
||||
raw_per_profile = kanban_cfg.get("max_in_progress_per_profile", None)
|
||||
max_in_progress_per_profile = None
|
||||
if raw_per_profile is not None:
|
||||
try:
|
||||
max_in_progress_per_profile = int(raw_per_profile)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"kanban dispatcher: invalid kanban.max_in_progress_per_profile=%r; ignoring",
|
||||
raw_per_profile,
|
||||
)
|
||||
max_in_progress_per_profile = None
|
||||
else:
|
||||
if max_in_progress_per_profile < 1:
|
||||
logger.warning(
|
||||
"kanban dispatcher: kanban.max_in_progress_per_profile=%r is below 1; ignoring",
|
||||
raw_per_profile,
|
||||
)
|
||||
max_in_progress_per_profile = None
|
||||
else:
|
||||
logger.info(
|
||||
"kanban dispatcher: max_in_progress_per_profile=%d",
|
||||
max_in_progress_per_profile,
|
||||
)
|
||||
|
||||
# Initial delay so the gateway finishes wiring adapters before the
|
||||
# dispatcher spawns workers (those workers may hit gateway notify
|
||||
# subscriptions etc.). Matches the notifier watcher's delay.
|
||||
@@ -5424,7 +5528,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 +5549,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 +5571,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)
|
||||
@@ -5482,23 +5608,37 @@ class GatewayRunner:
|
||||
max_in_progress=max_in_progress,
|
||||
failure_limit=failure_limit,
|
||||
stale_timeout_seconds=stale_timeout_seconds,
|
||||
default_assignee=default_assignee,
|
||||
max_in_progress_per_profile=max_in_progress_per_profile,
|
||||
)
|
||||
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 +5797,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)
|
||||
@@ -5765,6 +5918,7 @@ class GatewayRunner:
|
||||
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
|
||||
adapter.set_session_store(self.session_store)
|
||||
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
|
||||
adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
|
||||
adapter._busy_text_mode = self._busy_text_mode
|
||||
|
||||
success = await self._connect_adapter_with_timeout(adapter, platform)
|
||||
@@ -6388,6 +6542,31 @@ class GatewayRunner:
|
||||
return YuanbaoAdapter(config)
|
||||
|
||||
return None
|
||||
|
||||
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool:
|
||||
"""Whether the adapter for *platform* gates access at intake itself.
|
||||
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented
|
||||
``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
the flag.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
# Some test helpers build a bare GatewayRunner via object.__new__ and
|
||||
# never set ``adapters``; treat a missing/empty map as "no adapter"
|
||||
# rather than raising (see pitfalls.md #17).
|
||||
adapters = getattr(self, "adapters", None)
|
||||
if not adapters:
|
||||
return False
|
||||
adapter = adapters.get(platform)
|
||||
if adapter is None:
|
||||
return False
|
||||
return bool(getattr(adapter, "enforces_own_access_policy", False))
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
@@ -6527,6 +6706,15 @@ class GatewayRunner:
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
|
||||
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
|
||||
# No env allowlists configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# allow_from / group_allow_from) already gated this message at
|
||||
# intake — it would not have reached the gateway otherwise — so
|
||||
# honor that decision instead of falling through to the
|
||||
# env-only default-deny below, which would silently break
|
||||
# `dm_policy: open` and config-only allowlists. (#34515)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
@@ -6634,6 +6822,20 @@ class GatewayRunner:
|
||||
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
|
||||
return config.unauthorized_dm_behavior
|
||||
|
||||
# Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An
|
||||
# allowlist or disabled DM policy means the operator restricted access,
|
||||
# so unauthorized DMs should be dropped silently rather than answered
|
||||
# with a pairing code. An explicit pairing policy opts back into codes.
|
||||
if platform and config and hasattr(config, "platforms"):
|
||||
platform_cfg = config.platforms.get(platform)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
dm_policy = str(extra.get("dm_policy") or "").strip().lower()
|
||||
if dm_policy == "pairing":
|
||||
return "pair"
|
||||
if dm_policy in {"allowlist", "disabled"}:
|
||||
return "ignore"
|
||||
|
||||
# No explicit override. Fall back to allowlist-aware default:
|
||||
# if any allowlist is configured for this platform, silently drop
|
||||
# unauthorized messages instead of sending pairing codes.
|
||||
@@ -7970,7 +8172,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."
|
||||
)
|
||||
@@ -8177,6 +8380,28 @@ class GatewayRunner:
|
||||
binding = None
|
||||
if binding:
|
||||
bound_session_id = str(binding.get("session_id") or "")
|
||||
# Heal bindings that point at a pre-compression parent: walk
|
||||
# the compression-continuation chain forward to its tip so the
|
||||
# next message resumes the compressed child instead of
|
||||
# reloading the oversized parent transcript (#20470/#29712/
|
||||
# #33414). Returns the input unchanged when the session isn't
|
||||
# a compression parent, so this is cheap and safe.
|
||||
if bound_session_id and self._session_db is not None:
|
||||
try:
|
||||
canonical_session_id = self._session_db.get_compression_tip(
|
||||
bound_session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"compression-tip lookup failed for %s",
|
||||
bound_session_id, exc_info=True,
|
||||
)
|
||||
canonical_session_id = bound_session_id
|
||||
if (
|
||||
canonical_session_id
|
||||
and canonical_session_id != bound_session_id
|
||||
):
|
||||
bound_session_id = canonical_session_id
|
||||
if bound_session_id and bound_session_id != session_entry.session_id:
|
||||
# Route the override through SessionStore so the session_key
|
||||
# → session_id mapping is persisted to disk and the previous
|
||||
@@ -8186,6 +8411,15 @@ class GatewayRunner:
|
||||
switched = self.session_store.switch_session(session_key, bound_session_id)
|
||||
if switched is not None:
|
||||
session_entry = switched
|
||||
# If the stored binding pointed at a parent, rewrite it to the
|
||||
# canonical descendant now that we've followed the chain.
|
||||
if (
|
||||
bound_session_id
|
||||
and bound_session_id != str(binding.get("session_id") or "")
|
||||
):
|
||||
self._sync_telegram_topic_binding(
|
||||
source, session_entry, reason="compression-tip-walk",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
self._record_telegram_topic_binding(source, session_entry)
|
||||
@@ -8562,6 +8796,10 @@ class GatewayRunner:
|
||||
if _hyg_new_sid != session_entry.session_id:
|
||||
session_entry.session_id = _hyg_new_sid
|
||||
self.session_store._save()
|
||||
self._sync_telegram_topic_binding(
|
||||
source, session_entry,
|
||||
reason="hygiene-compression",
|
||||
)
|
||||
|
||||
self.session_store.rewrite_transcript(
|
||||
session_entry.session_id, _compressed
|
||||
@@ -8827,6 +9065,9 @@ class GatewayRunner:
|
||||
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
|
||||
session_entry.session_id = agent_result["session_id"]
|
||||
self.session_store._save()
|
||||
self._sync_telegram_topic_binding(
|
||||
source, session_entry, reason="agent-result-compression",
|
||||
)
|
||||
|
||||
# Prepend reasoning/thinking if display is enabled (per-platform)
|
||||
try:
|
||||
@@ -10193,8 +10434,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 = ""
|
||||
@@ -11494,9 +11743,16 @@ class GatewayRunner:
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||
|
||||
media_files, _ = adapter.extract_media(response)
|
||||
media_files, cleaned = adapter.extract_media(response)
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
_, cleaned = adapter.extract_images(response)
|
||||
# Chain the cleaned text through each extractor (extract_media →
|
||||
# extract_images → extract_local_files) so MEDIA: tags and image URLs
|
||||
# are removed before the bare-path auto-detect runs. Previously the
|
||||
# cleaned text from extract_media was dropped (``_``) and
|
||||
# extract_local_files scanned text that still contained MEDIA: tags,
|
||||
# producing false-positive bare-path matches with the MEDIA: prefix
|
||||
# glued on. This matches the chain order in gateway/platforms/base.py.
|
||||
_, cleaned = adapter.extract_images(cleaned)
|
||||
local_files, _ = adapter.extract_local_files(cleaned)
|
||||
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
|
||||
|
||||
@@ -11768,6 +12024,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,
|
||||
@@ -12262,6 +12519,9 @@ class GatewayRunner:
|
||||
if new_session_id != session_entry.session_id:
|
||||
session_entry.session_id = new_session_id
|
||||
self.session_store._save()
|
||||
self._sync_telegram_topic_binding(
|
||||
source, session_entry, reason="compress-command",
|
||||
)
|
||||
|
||||
self.session_store.rewrite_transcript(new_session_id, compressed)
|
||||
# Reset stored token count — transcript changed, old value is stale
|
||||
@@ -15070,6 +15330,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 +15362,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 +15377,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 +15415,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 +16215,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 +16958,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 +17003,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,
|
||||
@@ -17181,13 +17485,33 @@ class GatewayRunner:
|
||||
# append any that aren't already present in the final response, so the
|
||||
# adapter's extract_media() can find and deliver the files exactly once.
|
||||
#
|
||||
# Uses path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) instead of index slicing. This is safe even
|
||||
# when context compression shrinks the message list. (Fixes #160)
|
||||
# Scope the scan to THIS turn's tool results only. ``agent_history``
|
||||
# was passed into run_conversation as ``conversation_history``, so the
|
||||
# agent's returned ``messages`` list is ``agent_history`` followed by
|
||||
# the messages produced this turn. Slicing at ``len(agent_history)``
|
||||
# isolates the current turn precisely, so a stale MEDIA: path emitted
|
||||
# by a tool several turns earlier (still present in the full message
|
||||
# list) can never leak onto a later text-only reply. (Fixes #34608)
|
||||
#
|
||||
# Path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) is retained as a secondary guard. It is
|
||||
# also the sole guard on the fallback branch taken when mid-run
|
||||
# context compression shrinks the message list below the original
|
||||
# history length, preserving the compression-safe behaviour of #160.
|
||||
if "MEDIA:" not in final_response:
|
||||
media_tags = []
|
||||
has_voice_directive = False
|
||||
for msg in result.get("messages", []):
|
||||
_all_msgs = result.get("messages", [])
|
||||
_history_len = len(agent_history)
|
||||
# Only trust the slice boundary when the message list still
|
||||
# contains the full history prefix. Mid-run compression can
|
||||
# rewrite/shrink the list; in that case fall back to scanning
|
||||
# everything and rely on _history_media_paths for dedup.
|
||||
if _history_len and len(_all_msgs) >= _history_len:
|
||||
_scan_msgs = _all_msgs[_history_len:]
|
||||
else:
|
||||
_scan_msgs = _all_msgs
|
||||
for msg in _scan_msgs:
|
||||
if msg.get("role") in {"tool", "function"}:
|
||||
content = msg.get("content", "")
|
||||
if "MEDIA:" in content:
|
||||
@@ -17455,35 +17779,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 +18424,95 @@ 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,
|
||||
planned_stop_marker_targets_self,
|
||||
)
|
||||
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)
|
||||
):
|
||||
# A marker existing is NOT sufficient — it may have been
|
||||
# written for a PREVIOUS gateway instance (different PID)
|
||||
# and left behind because that process exited before the
|
||||
# CLI's stop() could clean it up. Firing the handler on a
|
||||
# stale/foreign marker drives the gateway into shutdown,
|
||||
# then consume_planned_stop_marker_for_self() correctly
|
||||
# reports a PID mismatch — but by then we're already
|
||||
# stopping, so it's logged as an unexpected "UNKNOWN" exit
|
||||
# and the watchdog crash-loops the gateway (issue #34597,
|
||||
# a regression from PR #33798 which added this watcher
|
||||
# without the PID check).
|
||||
#
|
||||
# Only fire when the marker actually targets us. The probe
|
||||
# is non-destructive on a match (the handler does the
|
||||
# authoritative consume on the loop thread) and self-heals
|
||||
# by unlinking stale/malformed markers so they cannot wedge
|
||||
# a freshly booted gateway.
|
||||
if not planned_stop_marker_targets_self():
|
||||
stop_event.wait(poll_interval)
|
||||
continue
|
||||
# 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 +18917,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 +19016,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
|
||||
|
||||
@@ -26,7 +26,6 @@ piecemeal, the footer is sent as a separate trailing message via
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
_DEFAULT_FIELDS: tuple[str, ...] = ("model", "context_pct", "cwd")
|
||||
|
||||
+80
-6
@@ -816,12 +816,24 @@ def _consume_pid_marker_for_self(
|
||||
|
||||
our_pid = os.getpid()
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
matches = (
|
||||
target_pid == our_pid
|
||||
and target_start_time is not None
|
||||
and our_start_time is not None
|
||||
and target_start_time == our_start_time
|
||||
)
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform the planned-stop watcher exists for). Requiring a non-None
|
||||
# match there would make every consume return False, so a legitimate
|
||||
# ``hermes gateway stop`` on Windows would be misclassified as an
|
||||
# unexpected ``UNKNOWN`` exit (exit 1) and revived by the service
|
||||
# manager. So: when both start_times are known they must match; when
|
||||
# either is unknown, fall back to PID equality alone (bounded by the
|
||||
# marker's short TTL). This mirrors ``planned_stop_marker_targets_self``
|
||||
# so the watcher's non-destructive probe and this authoritative
|
||||
# consume agree on every platform (issue #34597).
|
||||
if target_pid != our_pid:
|
||||
matches = False
|
||||
elif target_start_time is not None and our_start_time is not None:
|
||||
matches = target_start_time == our_start_time
|
||||
else:
|
||||
matches = True
|
||||
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -914,6 +926,68 @@ def consume_planned_stop_marker_for_self() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def planned_stop_marker_targets_self() -> bool:
|
||||
"""Return True only when a live planned-stop marker names the current process.
|
||||
|
||||
This is a **non-destructive** probe used by the watcher thread
|
||||
(``gateway/run.py:_run_planned_stop_watcher``) to decide whether to
|
||||
trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`,
|
||||
it never unlinks a marker that matches us — the shutdown handler does
|
||||
the authoritative consume on its own thread.
|
||||
|
||||
It *does* clean up markers that can never apply to this process:
|
||||
malformed markers and markers older than the TTL are unlinked so a
|
||||
stale file left behind by a previous gateway instance cannot wedge
|
||||
the new one. Markers naming a different PID/start_time are left in
|
||||
place (they may still be consumed legitimately by the process they
|
||||
name) but report False here.
|
||||
|
||||
Returns False (without raising) on any read/parse error.
|
||||
"""
|
||||
path = _get_planned_stop_marker_path()
|
||||
record = _read_json_file(path)
|
||||
if not record:
|
||||
return False
|
||||
|
||||
try:
|
||||
target_pid = int(record["target_pid"])
|
||||
target_start_time = record.get("target_start_time")
|
||||
written_at = record.get("written_at") or ""
|
||||
except (KeyError, TypeError, ValueError):
|
||||
# Malformed marker can never match anyone — drop it.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S):
|
||||
# A marker this old is past its useful life regardless of target —
|
||||
# clean it up so it cannot crash-loop a freshly booted gateway.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
our_pid = os.getpid()
|
||||
if target_pid != our_pid:
|
||||
return False
|
||||
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform this watcher exists for). Requiring a non-None match there
|
||||
# would make the watcher never fire and re-break the #33778 Windows
|
||||
# session-resume path. So: when both start_times are known they must
|
||||
# match; when either is unknown, fall back to PID equality alone
|
||||
# (the marker is short-lived under a 60s TTL, bounding reuse risk).
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
if target_start_time is not None and our_start_time is not None:
|
||||
return target_start_time == our_start_time
|
||||
return True
|
||||
|
||||
|
||||
def clear_planned_stop_marker() -> None:
|
||||
"""Remove the planned-stop marker unconditionally."""
|
||||
try:
|
||||
|
||||
+22
-13
@@ -26,6 +26,7 @@ from typing import Any, Callable, Optional
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
|
||||
from gateway.platforms.base import _custom_unit_to_cp
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
from gateway.config import (
|
||||
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
|
||||
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
|
||||
@@ -552,11 +553,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 +569,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 +577,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,13 +642,17 @@ 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)
|
||||
|
||||
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
|
||||
# Matches the simple cleanup regex used by the non-streaming path in
|
||||
# gateway/platforms/base.py for post-processing.
|
||||
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
|
||||
# Strip MEDIA:<path> tags before display. Uses the shared anchored
|
||||
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
|
||||
# path ends in a deliverable extension are removed, so an unknown-extension
|
||||
# path stays visible instead of being silently dropped (issue #34517).
|
||||
# Streaming and non-streaming paths share the same regex, so a tag is
|
||||
# treated identically whichever path delivered the text.
|
||||
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
|
||||
|
||||
@staticmethod
|
||||
def _clean_for_display(text: str) -> str:
|
||||
@@ -778,6 +783,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 +820,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 +864,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.1"
|
||||
__release_date__ = "2026.5.29"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
@@ -27,11 +27,9 @@ guarantee.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Sequence
|
||||
from typing import Sequence
|
||||
|
||||
__all__ = [
|
||||
"IS_WINDOWS",
|
||||
|
||||
+401
-570
File diff suppressed because it is too large
Load Diff
@@ -272,9 +272,6 @@ def auth_add_command(args) -> None:
|
||||
print("Rehydrating Nous session from shared credentials...")
|
||||
rehydrated = auth_mod._try_import_shared_nous_state(
|
||||
timeout_seconds=getattr(args, "timeout", None) or 15.0,
|
||||
min_key_ttl_seconds=max(
|
||||
60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))
|
||||
),
|
||||
)
|
||||
if rehydrated is not None:
|
||||
custom_label = (getattr(args, "label", None) or "").strip() or None
|
||||
@@ -297,7 +294,6 @@ def auth_add_command(args) -> None:
|
||||
timeout_seconds=getattr(args, "timeout", None) or 15.0,
|
||||
insecure=bool(getattr(args, "insecure", False)),
|
||||
ca_bundle=getattr(args, "ca_bundle", None),
|
||||
min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))),
|
||||
)
|
||||
# Honor `--label <name>` so nous matches other providers' UX. The
|
||||
# helper embeds this into providers.nous so that label_from_token
|
||||
|
||||
+104
-2
@@ -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
|
||||
@@ -667,6 +670,105 @@ def restore_quick_snapshot(
|
||||
return restored > 0
|
||||
|
||||
|
||||
# Relative path of the cron job database inside HERMES_HOME. Kept in sync with
|
||||
# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.
|
||||
_CRON_JOBS_REL = "cron/jobs.json"
|
||||
|
||||
|
||||
def _count_cron_jobs(path: Path) -> Optional[int]:
|
||||
"""Return the number of cron jobs stored in ``path``.
|
||||
|
||||
The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``).
|
||||
A legacy bare-list shape (``[...]``) is also honoured.
|
||||
|
||||
Returns:
|
||||
The job count for any *valid, readable* JSON document, or ``None`` if
|
||||
the file is missing or cannot be parsed. ``None`` means "unknown" —
|
||||
callers must not treat it as "zero jobs", because acting on an
|
||||
unreadable file could mask a real corruption the user needs to see.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
return len(jobs) if isinstance(jobs, list) else None
|
||||
if isinstance(data, list):
|
||||
return len(data)
|
||||
return None
|
||||
|
||||
|
||||
def restore_cron_jobs_if_emptied(
|
||||
snapshot_id: str,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Safety net for silent cron-job loss across ``hermes update``.
|
||||
|
||||
Config-version migrations have been observed to leave ``cron/jobs.json``
|
||||
valid-but-empty after an update, silently dropping every scheduled job
|
||||
(issue #34600). The existing malformed-shape guards in ``cron/jobs.py``
|
||||
don't catch this case because ``{"jobs": []}`` is perfectly valid JSON.
|
||||
|
||||
This compares the *current* job count against the pre-update snapshot. If
|
||||
the live file now has **zero** jobs while the snapshot captured **one or
|
||||
more**, the snapshot copy of ``cron/jobs.json`` is restored in place.
|
||||
|
||||
The check is deliberately conservative — it only ever restores when there
|
||||
is unambiguous evidence of loss (snapshot had jobs, live file has none),
|
||||
so a user who genuinely deleted all their jobs during/after the update is
|
||||
never second-guessed, and an unreadable live file (count ``None``) is left
|
||||
untouched so real corruption still surfaces.
|
||||
|
||||
Args:
|
||||
snapshot_id: The pre-update quick-snapshot id (from
|
||||
:func:`create_quick_snapshot`).
|
||||
hermes_home: Override for the Hermes home directory (tests).
|
||||
|
||||
Returns:
|
||||
``None`` when no action was taken (the common, healthy path). On a
|
||||
successful restore, a dict ``{"restored": True, "job_count": N,
|
||||
"snapshot_id": ...}`` so the caller can warn the user.
|
||||
"""
|
||||
if not snapshot_id:
|
||||
return None
|
||||
|
||||
home = hermes_home or get_hermes_home()
|
||||
live_path = home / _CRON_JOBS_REL
|
||||
|
||||
live_count = _count_cron_jobs(live_path)
|
||||
# Only act when the live file is readable AND empty. ``None`` (missing or
|
||||
# unparseable) is intentionally left alone — that's a different failure
|
||||
# mode the user should see rather than have papered over.
|
||||
if live_count is None or live_count > 0:
|
||||
return None
|
||||
|
||||
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
|
||||
snap_count = _count_cron_jobs(snap_path)
|
||||
if not snap_count: # None or 0 — nothing worth restoring
|
||||
return None
|
||||
|
||||
try:
|
||||
live_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(snap_path, live_path)
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.error(
|
||||
"Cron jobs were emptied during update but auto-restore failed: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Restored %d cron job(s) from pre-update snapshot %s "
|
||||
"(cron/jobs.json was emptied during migration)",
|
||||
snap_count,
|
||||
snapshot_id,
|
||||
)
|
||||
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
|
||||
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
|
||||
if not root.exists():
|
||||
|
||||
+29
-12
@@ -50,17 +50,6 @@ def _skin_color(key: str, fallback: str) -> str:
|
||||
return get_active_skin().get_color(key, fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _skin_branding(key: str, fallback: str) -> str:
|
||||
"""Get a branding string from the active skin, or return fallback."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
return get_active_skin().get_branding(key, fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# ASCII Art & Branding
|
||||
# =========================================================================
|
||||
@@ -300,14 +289,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
|
||||
@@ -15,7 +15,7 @@ Subcommands:
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
@@ -25,7 +25,7 @@ import argparse
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
|
||||
@@ -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]"),
|
||||
|
||||
+153
-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"
|
||||
@@ -617,6 +669,27 @@ DEFAULT_CONFIG = {
|
||||
# (force on/off for all models), or a list of model-name substrings
|
||||
# to match (e.g. ["gpt", "codex", "gemini", "qwen"]).
|
||||
"tool_use_enforcement": "auto",
|
||||
# Universal "finish the job" guidance — short prompt block applied to
|
||||
# all models that targets two cross-family failure modes: (1) stopping
|
||||
# after a stub instead of finishing the artifact, (2) fabricating
|
||||
# plausible-looking output when a real path is blocked. Costs ~80
|
||||
# tokens in the cached system prompt. Set False to disable globally.
|
||||
"task_completion_guidance": True,
|
||||
# Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668
|
||||
# state in the system prompt when something non-default is detected
|
||||
# (e.g. python3 has no pip module, pip→python version mismatch, PEP
|
||||
# 668 enforcement without uv). Costs zero tokens when the env is
|
||||
# clean (probe emits nothing). Skipped for remote terminal backends
|
||||
# (docker/modal/ssh — they have their own probe). Set False to
|
||||
# disable entirely.
|
||||
"environment_probe": True,
|
||||
# Embedder-supplied environment description appended to the system
|
||||
# prompt's environment-hints block. Lets a host that wraps Hermes
|
||||
# (sandbox runner, managed platform) explain the runtime environment
|
||||
# — proxy, credential handling, mount layout — without editing the
|
||||
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
|
||||
# env var overrides this (build-time/container mechanism).
|
||||
"environment_hint": "",
|
||||
# Staged inactivity warning: send a warning to the user at this
|
||||
# threshold before escalating to a full timeout. The warning fires
|
||||
# once per run and does not interrupt the agent. 0 = disable warning.
|
||||
@@ -784,6 +857,11 @@ DEFAULT_CONFIG = {
|
||||
"session_key": "",
|
||||
# Rehydrate tab_id from Camofox before creating a new tab.
|
||||
"adopt_existing_tab": False,
|
||||
# Docker Camofox opens page URLs from inside the container. Enable
|
||||
# this to rewrite loopback page URLs (localhost/127.0.0.1/::1) to a
|
||||
# host alias while leaving CAMOFOX_URL itself unchanged.
|
||||
"rewrite_loopback_urls": False,
|
||||
"loopback_host_alias": "host.docker.internal",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1674,6 +1752,15 @@ DEFAULT_CONFIG = {
|
||||
# assignee to any installed profile. When unset, falls back to the
|
||||
# default profile. A task never ends up with assignee=None.
|
||||
"default_assignee": "",
|
||||
# Per-profile concurrency cap (#21582). When set to a positive int,
|
||||
# no single profile can have more than N workers running at once,
|
||||
# even if the global max_in_progress / max_spawn caps would allow
|
||||
# it. Tasks blocked this way defer to the next dispatcher tick.
|
||||
# Unset (None) means "no per-profile cap" — backward-compatible
|
||||
# with existing installs. Useful for fan-out workflows that would
|
||||
# otherwise saturate one profile's local model / API quota /
|
||||
# browser pool while leaving other profiles idle.
|
||||
"max_in_progress_per_profile": None,
|
||||
# When true, the kanban dispatcher auto-runs the decomposer on
|
||||
# tasks that land in Triage (every dispatcher tick). When false,
|
||||
# decomposition is manual via `hermes kanban decompose <id>` or
|
||||
@@ -1705,6 +1792,38 @@ DEFAULT_CONFIG = {
|
||||
"mode": "project",
|
||||
},
|
||||
|
||||
# Tool Search (progressive disclosure for large tool surfaces).
|
||||
# When the model is connected to many MCP servers or non-core plugin
|
||||
# tools, their JSON schemas can consume a substantial fraction of the
|
||||
# context window on every turn. When enabled, those tools are replaced
|
||||
# in the model-facing tools array with three bridge tools —
|
||||
# tool_search / tool_describe / tool_call — and surfaced on demand.
|
||||
#
|
||||
# Core Hermes tools (terminal, read_file, write_file, patch,
|
||||
# search_files, todo, memory, browser_*, etc.) are NEVER deferred.
|
||||
# See tools/tool_search.py for full design notes and the
|
||||
# openclaw-tool-search-report PDF in this PR for the rationale.
|
||||
"tools": {
|
||||
"tool_search": {
|
||||
# "auto" (default) — activate only when deferrable tool schemas
|
||||
# exceed ``threshold_pct`` of the active model's context length,
|
||||
# so small toolsets pay no overhead.
|
||||
# "on" — always activate when there is at least one deferrable
|
||||
# tool. Use when you have many MCP servers and want maximum
|
||||
# token reduction unconditionally.
|
||||
# "off" — disable entirely. Tools-array assembly is a pass-through.
|
||||
"enabled": "auto",
|
||||
# Percentage of context length at which "auto" mode kicks in.
|
||||
# 10 matches the Claude Code default. Range 0..100.
|
||||
"threshold_pct": 10,
|
||||
# When the model calls tool_search without a ``limit`` argument,
|
||||
# how many hits to return. Range 1..max_search_limit.
|
||||
"search_default_limit": 5,
|
||||
# Hard upper bound the model can request via ``limit``. Range 1..50.
|
||||
"max_search_limit": 20,
|
||||
},
|
||||
},
|
||||
|
||||
# Logging — controls file logging to ~/.hermes/logs/.
|
||||
# agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.
|
||||
"logging": {
|
||||
@@ -1754,6 +1873,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 +1895,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 +1903,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 +2589,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 +2668,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 +3076,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 +3092,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,
|
||||
@@ -5474,6 +5618,8 @@ def set_config_value(key: str, value: str):
|
||||
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
|
||||
# terminal.cwd intentionally excluded — CLI resolves at runtime,
|
||||
# gateway bridges it in gateway/run.py. Persisting to .env causes
|
||||
|
||||
@@ -26,10 +26,15 @@ from hermes_cli.dashboard_auth import list_providers
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import ProviderError
|
||||
from hermes_cli.dashboard_auth.cookies import read_session_cookies
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Paths that bypass the auth gate. Order matters: prefix match.
|
||||
# Prefixes that bypass the auth gate. Match via ``path == prefix`` or
|
||||
# ``path.startswith(prefix)`` — so ``/assets/`` (with trailing slash)
|
||||
# matches ``/assets/foo.css`` but not ``/assetsleak``. Auth-bootstrap
|
||||
# (login page, OAuth round trip, provider listing) and static asset
|
||||
# mounts go here.
|
||||
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/auth/login",
|
||||
"/auth/callback",
|
||||
@@ -45,6 +50,20 @@ _GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
|
||||
|
||||
def _path_is_public(path: str) -> bool:
|
||||
"""True if ``path`` bypasses the OAuth auth gate.
|
||||
|
||||
Two sources of public-ness:
|
||||
|
||||
* :data:`PUBLIC_API_PATHS` — the shared ``/api/*`` allowlist that
|
||||
the legacy ``_SESSION_TOKEN`` middleware also honours. Matched
|
||||
exactly (no prefix expansion) so adding ``/api/status`` doesn't
|
||||
accidentally expose ``/api/status/secret-extension``.
|
||||
* :data:`_GATE_PUBLIC_PREFIXES` — auth-bootstrap routes and static
|
||||
mounts. Prefix-matched so ``/assets/foo.css`` lights up via
|
||||
``/assets/``.
|
||||
"""
|
||||
if path in PUBLIC_API_PATHS:
|
||||
return True
|
||||
return any(
|
||||
path == prefix or path.startswith(prefix)
|
||||
for prefix in _GATE_PUBLIC_PREFIXES
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared allowlist of ``/api/*`` paths that bypass dashboard auth.
|
||||
|
||||
Two middlewares enforce dashboard auth and previously kept independent
|
||||
copies of this list:
|
||||
|
||||
* ``hermes_cli.web_server.auth_middleware`` — loopback / ``--insecure``
|
||||
mode, gates on the ephemeral ``_SESSION_TOKEN``.
|
||||
* ``hermes_cli.dashboard_auth.middleware.gated_auth_middleware`` —
|
||||
non-loopback mode, gates on the OAuth session cookie.
|
||||
|
||||
When the lists drifted, ``/api/status`` ended up public under the legacy
|
||||
gate but 401'd under the OAuth gate. That broke the portal's wildcard
|
||||
liveness probe (``nous-account-service`` ``fly-provider.ts``
|
||||
``getInstanceRuntimeStatus``), which fetches ``/api/status`` without a
|
||||
cookie as its sole signal of "agent dashboard is alive": every healthy
|
||||
wildcard-subdomain agent surfaced as STARTING/down in the portal UI even
|
||||
though the dashboard was serving correctly.
|
||||
|
||||
Centralising the allowlist here so both middlewares import the same
|
||||
frozenset prevents the next drift. Keep this list minimal — only truly
|
||||
non-sensitive, read-only endpoints belong here. As a sanity check, every
|
||||
entry should be safe to expose to:
|
||||
|
||||
* external uptime probes (Pingdom, Better Stack, NAS),
|
||||
* the dashboard SPA before the user has logged in,
|
||||
* anyone who happens to ``curl`` the hostname.
|
||||
|
||||
If a new endpoint doesn't pass all three tests, it should be gated and
|
||||
the SPA should bootstrap it after login instead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_API_PATHS: frozenset[str] = frozenset({
|
||||
# Liveness probe target. Returns version, gateway state, active
|
||||
# session count, and the dashboard auth-gate shape. No bodies, no
|
||||
# session content, no secrets. Documented as the portal's wildcard
|
||||
# liveness probe in
|
||||
# ``docs/agent-dashboard-public-url-contract.md`` (NAS side).
|
||||
"/api/status",
|
||||
# Read-only config-defaults / schema feeds for the SPA's Config page.
|
||||
"/api/config/defaults",
|
||||
"/api/config/schema",
|
||||
# Read-only model metadata (context windows, etc.) — same shape as
|
||||
# provider catalogs already exposed on the public internet.
|
||||
"/api/model/info",
|
||||
# Read-only theme + plugin manifests for the dashboard skin engine.
|
||||
"/api/dashboard/themes",
|
||||
"/api/dashboard/plugins",
|
||||
})
|
||||
@@ -17,8 +17,6 @@ import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -260,15 +258,6 @@ def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SEC
|
||||
_record_pending(urls, delay_seconds=delay_seconds)
|
||||
|
||||
|
||||
def _delete_hint(url: str) -> str:
|
||||
"""Return a one-liner delete command for the given paste URL."""
|
||||
paste_id = _extract_paste_id(url)
|
||||
if paste_id:
|
||||
return f"hermes debug delete {url}"
|
||||
# dpaste.com — no API delete, expires on its own.
|
||||
return "(auto-expires per dpaste.com policy)"
|
||||
|
||||
|
||||
def _upload_paste_rs(content: str) -> str:
|
||||
"""Upload to paste.rs. Returns the paste URL.
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
|
||||
+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)"
|
||||
|
||||
|
||||
|
||||
+109
-21
@@ -2161,9 +2161,37 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _stable_service_working_dir() -> str:
|
||||
"""Return a WorkingDirectory that will not disappear out from under systemd.
|
||||
|
||||
The gateway does NOT need its cwd to be the source checkout — ``ExecStart``
|
||||
uses an absolute python interpreter and ``-m hermes_cli.main``, so module
|
||||
resolution does not depend on cwd. Pinning ``WorkingDirectory`` to
|
||||
``PROJECT_ROOT`` (``Path(__file__).parent.parent``) is actively harmful:
|
||||
when the unit is generated from a transient checkout — a ``.worktrees/``
|
||||
dir, or a clone that ``hermes update`` later relocates/removes — the path
|
||||
rots. systemd then fails the start at the CHDIR step (``status=200/CHDIR``,
|
||||
"Changing to the requested working directory failed") *before* Python
|
||||
loads, so the on-boot ``refresh_systemd_unit_if_needed()`` self-heal never
|
||||
runs and ``Restart=always`` crash-loops forever on a dead directory.
|
||||
|
||||
``HERMES_HOME`` is the stable anchor: it is where config/state/logs live,
|
||||
it never moves, and it is guaranteed to exist whenever the gateway is
|
||||
meaningfully installed. Fall back to ``PROJECT_ROOT`` only if HERMES_HOME
|
||||
cannot be resolved (it always can in practice).
|
||||
"""
|
||||
try:
|
||||
home = get_hermes_home()
|
||||
if home and Path(home).is_dir():
|
||||
return str(Path(home).resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return str(PROJECT_ROOT)
|
||||
|
||||
|
||||
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
working_dir = _stable_service_working_dir()
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
|
||||
@@ -2192,7 +2220,10 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
# (e.g. /root/) to the target user's home so the service can
|
||||
# actually access them.
|
||||
python_path = _remap_path_for_user(python_path, home_dir)
|
||||
working_dir = _remap_path_for_user(working_dir, home_dir)
|
||||
# Anchor cwd to the target user's HERMES_HOME (stable, always exists)
|
||||
# rather than a remapped source-checkout path that can rot. See
|
||||
# _stable_service_working_dir() for the full rationale.
|
||||
working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir)
|
||||
venv_dir = _remap_path_for_user(venv_dir, home_dir)
|
||||
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
|
||||
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
|
||||
@@ -2804,7 +2835,10 @@ def _launchd_domain() -> str:
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
# Stable cwd anchor — never the volatile source checkout. See
|
||||
# _stable_service_working_dir() for the rationale (same rot risk applies
|
||||
# to launchd's WorkingDirectory as to systemd's).
|
||||
working_dir = _stable_service_working_dir()
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -3960,18 +3994,6 @@ def _setup_whatsapp():
|
||||
cmd_whatsapp(argparse.Namespace())
|
||||
|
||||
|
||||
def _setup_email():
|
||||
"""Configure Email via the standard platform setup."""
|
||||
email_platform = next(p for p in _PLATFORMS if p["key"] == "email")
|
||||
_setup_standard_platform(email_platform)
|
||||
|
||||
|
||||
def _setup_sms():
|
||||
"""Configure SMS (Twilio) via the standard platform setup."""
|
||||
sms_platform = next(p for p in _PLATFORMS if p["key"] == "sms")
|
||||
_setup_standard_platform(sms_platform)
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk — QR scan (recommended) or manual credential entry."""
|
||||
from hermes_cli.setup import (
|
||||
@@ -4144,12 +4166,6 @@ def _setup_wecom():
|
||||
print_success("💬 WeCom configured!")
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via the standard platform setup."""
|
||||
yuanbao_platform = next(p for p in _PLATFORMS if p["key"] == "yuanbao")
|
||||
_setup_standard_platform(yuanbao_platform)
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if supports_systemd_services():
|
||||
@@ -5150,11 +5166,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")
|
||||
|
||||
|
||||
+103
-37
@@ -548,6 +548,11 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
help="Additional task ids to schedule with the same reason (bulk mode)")
|
||||
|
||||
p_unblock = sub.add_parser("unblock", help="Return one or more blocked/scheduled tasks to ready")
|
||||
p_unblock.add_argument(
|
||||
"--reason",
|
||||
default=None,
|
||||
help="Optional reason/note — recorded as a comment before unblocking. Quote multi-word reasons.",
|
||||
)
|
||||
p_unblock.add_argument("task_ids", nargs="+")
|
||||
|
||||
p_promote = sub.add_parser(
|
||||
@@ -1021,7 +1026,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 +1269,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 +1284,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 +1325,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 +1374,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 +1400,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 +1449,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 +1615,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 +1625,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 +1642,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 +1672,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 +1795,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 +1812,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 +1843,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 +1890,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 +1917,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 +1939,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 +1961,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}")
|
||||
@@ -1978,14 +1983,20 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
|
||||
if not ids:
|
||||
print("at least one task_id is required", file=sys.stderr)
|
||||
return 1
|
||||
reason = getattr(args, "reason", None)
|
||||
if reason is not None:
|
||||
reason = reason.strip() or None
|
||||
author = _profile_author() if reason else None
|
||||
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"UNBLOCK: {reason}")
|
||||
if not kb.unblock_task(conn, tid):
|
||||
failed.append(tid)
|
||||
print(f"cannot unblock {tid} (not blocked/scheduled?)", file=sys.stderr)
|
||||
else:
|
||||
print(f"Unblocked {tid}")
|
||||
print(f"Unblocked {tid}" + (f": {reason}" if reason else ""))
|
||||
return 0 if not failed else 1
|
||||
|
||||
|
||||
@@ -2003,7 +2014,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 +2061,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 +2084,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,12 +2098,52 @@ def _cmd_tail(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
# Honour kanban.default_assignee as the fallback for unassigned ready
|
||||
# tasks (#27145), kanban.max_in_progress as the global concurrency cap
|
||||
# (#33488), kanban.max_in_progress_per_profile as the per-profile
|
||||
# cap (#21582), and kanban.max_spawn as the per-tick spawn limit
|
||||
# (#28805). Same semantics as the gateway dispatch path so behavior
|
||||
# matches whether the user runs the CLI directly or relies on the
|
||||
# gateway-embedded dispatcher.
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
_kanban_cfg = _cfg.get("kanban", {}) if isinstance(_cfg, dict) else {}
|
||||
default_assignee = (_kanban_cfg.get("default_assignee") or "").strip() or None
|
||||
|
||||
def _coerce_positive_int(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
ival = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return ival if ival >= 1 else None
|
||||
|
||||
max_in_progress_per_profile = _coerce_positive_int(
|
||||
_kanban_cfg.get("max_in_progress_per_profile")
|
||||
)
|
||||
max_in_progress = _coerce_positive_int(_kanban_cfg.get("max_in_progress"))
|
||||
# CLI --max overrides config kanban.max_spawn when both are present;
|
||||
# CLI is the more explicit signal so it wins.
|
||||
cli_max = getattr(args, "max", None)
|
||||
max_spawn = cli_max if cli_max is not None else _coerce_positive_int(
|
||||
_kanban_cfg.get("max_spawn")
|
||||
)
|
||||
except Exception:
|
||||
default_assignee = None
|
||||
max_in_progress_per_profile = None
|
||||
max_in_progress = None
|
||||
max_spawn = getattr(args, "max", None)
|
||||
with kb.connect_closing() as conn:
|
||||
res = kb.dispatch_once(
|
||||
conn,
|
||||
dry_run=args.dry_run,
|
||||
max_spawn=args.max,
|
||||
max_spawn=max_spawn,
|
||||
max_in_progress=max_in_progress,
|
||||
failure_limit=getattr(args, "failure_limit", kb.DEFAULT_SPAWN_FAILURE_LIMIT),
|
||||
default_assignee=default_assignee,
|
||||
max_in_progress_per_profile=max_in_progress_per_profile,
|
||||
)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps({
|
||||
@@ -2108,6 +2159,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
],
|
||||
"skipped_unassigned": res.skipped_unassigned,
|
||||
"skipped_nonspawnable": res.skipped_nonspawnable,
|
||||
"skipped_per_profile_capped": [
|
||||
{"task_id": tid, "assignee": who, "current": current}
|
||||
for (tid, who, current) in res.skipped_per_profile_capped
|
||||
],
|
||||
"auto_assigned_default": res.auto_assigned_default,
|
||||
}, indent=2))
|
||||
return 0
|
||||
print(f"Reclaimed: {res.reclaimed}")
|
||||
@@ -2128,8 +2184,18 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
for tid, who, ws in res.spawned:
|
||||
tag = " (dry)" if args.dry_run else ""
|
||||
print(f" - {tid} -> {who} @ {ws or '-'}{tag}")
|
||||
if res.auto_assigned_default:
|
||||
print(
|
||||
f"Auto-assigned to kanban.default_assignee={default_assignee!r}: "
|
||||
f"{', '.join(res.auto_assigned_default)}"
|
||||
)
|
||||
if res.skipped_unassigned:
|
||||
print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}")
|
||||
if res.skipped_per_profile_capped:
|
||||
for tid, who, current in res.skipped_per_profile_capped:
|
||||
print(
|
||||
f"Deferred ({who} at per-profile cap, {current} running): {tid}"
|
||||
)
|
||||
if res.skipped_nonspawnable:
|
||||
print(
|
||||
f"Skipped (non-spawnable assignee — terminal lane, OK): "
|
||||
@@ -2257,7 +2323,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 +2354,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 +2362,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 +2395,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 +2415,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 +2432,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 +2449,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 +2483,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 +2522,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 +2688,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 +2711,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,
|
||||
)
|
||||
|
||||
+476
-130
@@ -71,6 +71,7 @@ new locking.
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -83,7 +84,6 @@ import threading
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
@@ -110,6 +110,16 @@ _IS_WINDOWS = sys.platform == "win32"
|
||||
# long single-call MCP workflows.
|
||||
DEFAULT_CLAIM_TTL_SECONDS = 15 * 60
|
||||
|
||||
# If a worker's PID is still alive but its ``last_heartbeat_at`` is
|
||||
# older than this when ``release_stale_claims`` runs, treat the worker
|
||||
# as wedged and reclaim regardless of PID liveness (#29747 gap 3).
|
||||
# This catches the logic-loop case where the process is technically
|
||||
# running but not making observable progress. ``_touch_activity``
|
||||
# bridges chunk-level liveness into ``last_heartbeat_at`` via #31752,
|
||||
# so any genuinely active worker keeps its heartbeat fresh as a side
|
||||
# effect of normal API traffic.
|
||||
DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS = 60 * 60
|
||||
|
||||
|
||||
def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int:
|
||||
"""Return the effective claim TTL, honoring the kanban env override.
|
||||
@@ -134,6 +144,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 +992,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 +1148,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 +1170,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 +1241,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 +1287,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 +1638,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 +1684,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2526,9 +2750,19 @@ def release_stale_claims(
|
||||
then-immediately-reclaim loop seen on slow models that spend longer
|
||||
than ``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM
|
||||
call (#23025): no tool calls means no ``kanban_heartbeat``, even
|
||||
though the subprocess is healthy. ``enforce_max_runtime`` and
|
||||
``detect_crashed_workers`` remain the upper bounds for genuinely
|
||||
wedged or dead workers.
|
||||
though the subprocess is healthy.
|
||||
|
||||
Backstop (#29747 gap 3): if the worker's PID is still alive but its
|
||||
``last_heartbeat_at`` is stale by more than
|
||||
``DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS`` (1h), the worker has
|
||||
been making no observable progress and we reclaim anyway — even if
|
||||
``_pid_alive`` is still true. This catches the wedged-in-a-logic-loop
|
||||
case where the process is technically running but accomplishing
|
||||
nothing. ``_touch_activity`` (run_agent.py) bridges chunk-level
|
||||
liveness into ``last_heartbeat_at`` via #31752, so any genuinely
|
||||
active worker keeps its heartbeat fresh as a side effect of normal
|
||||
API traffic. ``enforce_max_runtime`` and ``detect_crashed_workers``
|
||||
remain the upper bounds for genuinely wedged or dead workers.
|
||||
|
||||
Returns the number of stale claims actually reclaimed (live-pid
|
||||
extensions don't count). Safe to call often.
|
||||
@@ -2546,7 +2780,21 @@ def release_stale_claims(
|
||||
for row in stale:
|
||||
lock = row["claim_lock"] or ""
|
||||
host_local = lock.startswith(host_prefix)
|
||||
if host_local and row["worker_pid"] and _pid_alive(row["worker_pid"]):
|
||||
hb = row["last_heartbeat_at"]
|
||||
# Heartbeat staleness backstop: if we have a heartbeat at all
|
||||
# and it's older than the max-stale threshold, the worker is
|
||||
# not making observable progress. Reclaim instead of extending,
|
||||
# even if the PID is still alive (it's likely in a logic loop).
|
||||
heartbeat_stale = (
|
||||
hb is not None
|
||||
and (now - int(hb)) > DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS
|
||||
)
|
||||
if (
|
||||
host_local
|
||||
and row["worker_pid"]
|
||||
and _pid_alive(row["worker_pid"])
|
||||
and not heartbeat_stale
|
||||
):
|
||||
new_expires = now + _resolve_claim_ttl_seconds()
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
@@ -2615,6 +2863,7 @@ def release_stale_claims(
|
||||
),
|
||||
"now": now,
|
||||
"host_local": host_local,
|
||||
"heartbeat_stale": bool(heartbeat_stale),
|
||||
}
|
||||
payload.update(termination)
|
||||
_append_event(
|
||||
@@ -4074,6 +4323,12 @@ class DispatchResult:
|
||||
skipped_unassigned: list[str] = field(default_factory=list)
|
||||
"""Ready task ids skipped because they have no assignee at all.
|
||||
Operator-actionable — usually a misfiled task waiting for routing."""
|
||||
auto_assigned_default: list[str] = field(default_factory=list)
|
||||
"""Task ids that were unassigned in the DB and had
|
||||
``kanban.default_assignee`` applied this tick before spawning (#27145).
|
||||
Surfaces the auto-assignment to telemetry / CLI / dashboard so the
|
||||
operator can see when the dispatcher is acting on the fallback rule
|
||||
rather than on explicit per-task assignments."""
|
||||
skipped_nonspawnable: list[str] = field(default_factory=list)
|
||||
"""Ready task ids skipped because their assignee names a control-plane
|
||||
lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes
|
||||
@@ -4081,6 +4336,14 @@ class DispatchResult:
|
||||
operator-actionable failure. Tracked separately so health telemetry
|
||||
can distinguish "real stuck" (nothing spawned but spawnable work
|
||||
available) from "correctly idle" (nothing spawnable in the queue)."""
|
||||
skipped_per_profile_capped: list[tuple[str, str, int]] = field(default_factory=list)
|
||||
"""Tasks deferred this tick because their assignee is already at
|
||||
``kanban.max_in_progress_per_profile`` (#21582). Each entry is
|
||||
``(task_id, assignee, current_running_count)``. NOT an
|
||||
operator-actionable failure — the task will be picked up on a
|
||||
subsequent tick when the assignee has capacity. Separate bucket so
|
||||
telemetry / dashboards can show "this profile is busy" vs
|
||||
"task is genuinely stuck"."""
|
||||
crashed: list[str] = field(default_factory=list)
|
||||
"""Task ids reclaimed because their worker PID disappeared."""
|
||||
auto_blocked: list[str] = field(default_factory=list)
|
||||
@@ -4169,6 +4432,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.
|
||||
|
||||
@@ -4491,7 +4777,6 @@ def detect_stale_running(
|
||||
if stale_timeout_seconds <= 0:
|
||||
return []
|
||||
|
||||
import signal as _signal_mod
|
||||
|
||||
now = int(time.time())
|
||||
host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
|
||||
@@ -4580,21 +4865,6 @@ def detect_stale_running(
|
||||
return reclaimed
|
||||
|
||||
|
||||
def set_max_runtime(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
seconds: Optional[int],
|
||||
) -> bool:
|
||||
"""Set or clear the per-task max_runtime_seconds. Returns True on
|
||||
success."""
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET max_runtime_seconds = ? WHERE id = ?",
|
||||
(int(seconds) if seconds is not None else None, task_id),
|
||||
)
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def _error_fingerprint(error_text: str) -> str:
|
||||
"""Normalize an error message for grouping identical failures.
|
||||
|
||||
@@ -4635,7 +4905,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 +4914,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
|
||||
|
||||
@@ -5096,6 +5374,8 @@ def dispatch_once(
|
||||
failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
|
||||
stale_timeout_seconds: int = 0,
|
||||
board: Optional[str] = None,
|
||||
default_assignee: Optional[str] = None,
|
||||
max_in_progress_per_profile: Optional[int] = None,
|
||||
) -> DispatchResult:
|
||||
"""Run one dispatcher tick.
|
||||
|
||||
@@ -5125,38 +5405,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)
|
||||
@@ -5210,12 +5461,89 @@ def dispatch_once(
|
||||
if max_spawn is None or max_spawn > remaining:
|
||||
max_spawn = remaining
|
||||
spawned = 0
|
||||
# Per-profile concurrency cap (#21582): when set, track how many
|
||||
# workers each assignee already has in flight, and refuse to spawn
|
||||
# when this would push that assignee past the cap. Prevents
|
||||
# fan-out workloads from melting a single profile's local model /
|
||||
# API quota / browser pool while leaving other profiles idle.
|
||||
# Tasks blocked this way go to skipped_per_profile_capped (not
|
||||
# skipped_unassigned — the operator-actionable signal is different:
|
||||
# "this profile is busy, try again later" not "this needs routing").
|
||||
_per_profile_cap = max_in_progress_per_profile if (
|
||||
isinstance(max_in_progress_per_profile, int)
|
||||
and max_in_progress_per_profile > 0
|
||||
) else None
|
||||
_per_profile_running: dict[str, int] = {}
|
||||
if _per_profile_cap is not None:
|
||||
for prow in conn.execute(
|
||||
"SELECT assignee, COUNT(*) AS n FROM tasks "
|
||||
"WHERE status = 'running' AND assignee IS NOT NULL "
|
||||
"GROUP BY assignee"
|
||||
):
|
||||
_per_profile_running[prow["assignee"]] = int(prow["n"])
|
||||
# Normalize default_assignee once: empty/whitespace string → None so the
|
||||
# rest of the loop can use ``if default_assignee:`` as a single check.
|
||||
# We also resolve profile_exists once here for the same reason.
|
||||
_default_assignee = (default_assignee or "").strip() or None
|
||||
_default_assignee_resolved = False
|
||||
if _default_assignee:
|
||||
try:
|
||||
from hermes_cli.profiles import profile_exists as _pe
|
||||
_default_assignee_resolved = bool(_pe(_default_assignee))
|
||||
except Exception:
|
||||
# Profiles module not importable (test stubs, exotic envs).
|
||||
# Trust the operator's config and try the assignment; the
|
||||
# downstream profile_exists check on the assigned row will
|
||||
# bucket it as nonspawnable if the profile genuinely isn't
|
||||
# there, with the existing diagnostic.
|
||||
_default_assignee_resolved = True
|
||||
for row in ready_rows:
|
||||
if max_spawn is not None and running_count + spawned >= max_spawn:
|
||||
break
|
||||
if not row["assignee"]:
|
||||
result.skipped_unassigned.append(row["id"])
|
||||
continue
|
||||
row_assignee = row["assignee"]
|
||||
if not row_assignee:
|
||||
# Honour kanban.default_assignee: when the dispatcher hits an
|
||||
# unassigned ready task and an operator-configured fallback
|
||||
# exists, persist the assignment and proceed. This removes the
|
||||
# dashboard footgun where a task created without an assignee
|
||||
# parks in 'ready' forever even though the operator's intent
|
||||
# ("default") was perfectly clear (#27145). Mutating the row
|
||||
# (not just the in-memory view) keeps diagnostics and the
|
||||
# board state consistent: the task is now legitimately owned
|
||||
# by ``kanban.default_assignee``, not "unassigned but secretly
|
||||
# routed".
|
||||
if _default_assignee and _default_assignee_resolved:
|
||||
# Dry-run: show what WOULD happen (auto-assign + spawn) without
|
||||
# mutating the DB. Real run: mutate the row + emit the
|
||||
# 'assigned' event so the board state matches what just happened.
|
||||
if not dry_run:
|
||||
try:
|
||||
with write_txn(conn):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET assignee = ? WHERE id = ? "
|
||||
"AND (assignee IS NULL OR assignee = '')",
|
||||
(_default_assignee, row["id"]),
|
||||
)
|
||||
_append_event(
|
||||
conn, row["id"], "assigned",
|
||||
{
|
||||
"assignee": _default_assignee,
|
||||
"source": "kanban.default_assignee",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
_log.debug(
|
||||
"kanban dispatch: failed to apply default_assignee=%r "
|
||||
"to task %s",
|
||||
_default_assignee, row["id"], exc_info=True,
|
||||
)
|
||||
result.skipped_unassigned.append(row["id"])
|
||||
continue
|
||||
row_assignee = _default_assignee
|
||||
result.auto_assigned_default.append(row["id"])
|
||||
else:
|
||||
result.skipped_unassigned.append(row["id"])
|
||||
continue
|
||||
# Skip ready tasks whose assignee is not a real Hermes profile.
|
||||
# `_default_spawn` invokes ``hermes -p <assignee>`` which fails
|
||||
# with "Profile 'X' does not exist" when the assignee names a
|
||||
@@ -5230,7 +5558,7 @@ def dispatch_once(
|
||||
from hermes_cli.profiles import profile_exists # local import: avoids cycle
|
||||
except Exception:
|
||||
profile_exists = None # type: ignore[assignment]
|
||||
if profile_exists is not None and not profile_exists(row["assignee"]):
|
||||
if profile_exists is not None and not profile_exists(row_assignee):
|
||||
# Bucket separately from skipped_unassigned: the operator
|
||||
# cannot fix this by assigning a profile (the assignee IS the
|
||||
# intended owner — a terminal lane). Health telemetry uses
|
||||
@@ -5239,6 +5567,19 @@ def dispatch_once(
|
||||
# of human-pulled work.
|
||||
result.skipped_nonspawnable.append(row["id"])
|
||||
continue
|
||||
# Per-profile concurrency cap (#21582): even if there's global
|
||||
# headroom, refuse to spawn for an assignee that's already at
|
||||
# its in-flight cap. Prevents one profile's local model / API
|
||||
# quota / browser pool from being overwhelmed by a fan-out
|
||||
# while the global max_in_progress / max_spawn caps still allow
|
||||
# work on OTHER profiles.
|
||||
if _per_profile_cap is not None:
|
||||
current = _per_profile_running.get(row_assignee, 0)
|
||||
if current >= _per_profile_cap:
|
||||
result.skipped_per_profile_capped.append(
|
||||
(row["id"], row_assignee, current)
|
||||
)
|
||||
continue
|
||||
# Respawn guard: refuse to re-spawn when useful work is already
|
||||
# in-flight/recent, or when the last failure is a deterministic
|
||||
# blocker (quota / auth). The guard defers the spawn this tick so
|
||||
@@ -5261,7 +5602,15 @@ def dispatch_once(
|
||||
)
|
||||
continue
|
||||
if dry_run:
|
||||
result.spawned.append((row["id"], row["assignee"], ""))
|
||||
result.spawned.append((row["id"], row_assignee, ""))
|
||||
# Increment per-profile counter even in dry_run so the cap
|
||||
# check sees the would-be spawn on subsequent iterations.
|
||||
# Without this, dry_run reports every task as spawnable and
|
||||
# under-reports the capped subset (#21582).
|
||||
if _per_profile_cap is not None and row_assignee:
|
||||
_per_profile_running[row_assignee] = (
|
||||
_per_profile_running.get(row_assignee, 0) + 1
|
||||
)
|
||||
continue
|
||||
claimed = claim_task(conn, row["id"], ttl_seconds=ttl_seconds)
|
||||
if claimed is None:
|
||||
@@ -5304,6 +5653,13 @@ def dispatch_once(
|
||||
# complete_task).
|
||||
result.spawned.append((claimed.id, claimed.assignee or "", str(workspace)))
|
||||
spawned += 1
|
||||
# Track the new in-flight count for this profile so later
|
||||
# iterations in this same tick respect the per-profile cap
|
||||
# (#21582). Subsequent ticks re-query from the DB.
|
||||
if _per_profile_cap is not None and claimed.assignee:
|
||||
_per_profile_running[claimed.assignee] = (
|
||||
_per_profile_running.get(claimed.assignee, 0) + 1
|
||||
)
|
||||
except Exception as exc:
|
||||
auto = _record_spawn_failure(
|
||||
conn, claimed.id, str(exc),
|
||||
@@ -6145,7 +6501,7 @@ def _to_epoch(val) -> Optional[int]:
|
||||
pass
|
||||
# ISO-8601 fallback (e.g. '2026-05-10T15:00:00Z')
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
return int(dt.timestamp())
|
||||
except (ValueError, OSError):
|
||||
@@ -6596,16 +6952,6 @@ def get_run(conn: sqlite3.Connection, run_id: int) -> Optional[Run]:
|
||||
return Run.from_row(row) if row else None
|
||||
|
||||
|
||||
def active_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
|
||||
"""Return the currently-open run for ``task_id`` (``ended_at IS NULL``)."""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NULL "
|
||||
"ORDER BY started_at DESC LIMIT 1",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return Run.from_row(row) if row else None
|
||||
|
||||
|
||||
def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
|
||||
"""Return the most recent run regardless of outcome (active or closed)."""
|
||||
row = conn.execute(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user