Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/package-lock.json # apps/dashboard/package.json # apps/dashboard/src/components/BottomPickSheet.tsx # apps/dashboard/src/hooks/useBelowBreakpoint.ts # gateway/platforms/telegram.py # hermes_cli/gateway.py # hermes_cli/web_server.py # nix/web.nix # scripts/install.ps1 # tests/gateway/test_telegram_thread_fallback.py # tui_gateway/server.py
This commit is contained in:
@@ -339,6 +339,7 @@ BROWSER_INACTIVITY_TIMEOUT=120
|
||||
# TELEGRAM_ALLOWED_USERS= # Comma-separated user IDs
|
||||
# TELEGRAM_HOME_CHANNEL= # Default chat for cron delivery
|
||||
# TELEGRAM_HOME_CHANNEL_NAME= # Display name for home channel
|
||||
# TELEGRAM_CRON_THREAD_ID= # Forum topic ID for cron deliveries; overrides TELEGRAM_HOME_CHANNEL_THREAD_ID for cron so replies work in topic mode
|
||||
|
||||
# Webhook mode (optional — for cloud deployments like Fly.io/Railway)
|
||||
# Default is long polling. Setting TELEGRAM_WEBHOOK_URL switches to webhook mode.
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
check-attribution:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (amd64, smoke test)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
- name: Push amd64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (arm64, smoke test)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
- name: Push arm64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
@@ -332,7 +332,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -445,7 +445,7 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
@@ -453,7 +453,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need full history for merge-base + worktree
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
@@ -191,10 +191,10 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -194,7 +194,7 @@ jobs:
|
||||
|
||||
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
@@ -56,7 +56,7 @@ permissions:
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan lockfiles
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5 # v2.3.5
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
|
||||
@@ -20,9 +20,9 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# Only deploy on schedule or manual trigger (not on every push to the script)
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ concurrency:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Run tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto
|
||||
python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto --timeout=30 --timeout-method=signal
|
||||
env:
|
||||
# Ensure tests don't accidentally call real APIs
|
||||
OPENROUTER_API_KEY: ""
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# On workflow_dispatch, check out the confirmed tag.
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
|
||||
- name: Sign with Sigstore
|
||||
if: env.skip_sign != 'true'
|
||||
uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
@@ -855,10 +855,11 @@ kanban task.
|
||||
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
|
||||
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
|
||||
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
|
||||
- **Worker toolset:** `tools/kanban_tools.py` exposes `kanban_show`,
|
||||
`kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`,
|
||||
`kanban_create`, `kanban_link` — gated by `HERMES_KANBAN_TASK` so
|
||||
the schema only appears for processes actually running as a worker.
|
||||
- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
|
||||
`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
|
||||
`kanban_comment`, `kanban_create`, `kanban_link`; profiles that
|
||||
explicitly enable the `kanban` toolset outside a dispatcher-spawned
|
||||
task also get `kanban_list` and `kanban_unblock` for board routing.
|
||||
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
|
||||
stale claims, promotes ready tasks, atomically claims, and spawns
|
||||
assigned profiles. Runs **inside the gateway** by default via
|
||||
@@ -874,8 +875,9 @@ Isolation model:
|
||||
- **Tenant** is a soft namespace *within* a board — one specialist
|
||||
fleet can serve multiple businesses with workspace-path + memory-key
|
||||
isolation.
|
||||
- After ~5 consecutive spawn failures on the same task the dispatcher
|
||||
auto-blocks it to prevent spin loops.
|
||||
- After `kanban.failure_limit` consecutive non-success attempts on the
|
||||
same task (default: 2), the dispatcher auto-blocks it to prevent spin
|
||||
loops.
|
||||
|
||||
Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
|
||||
Run this in PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
|
||||
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
|
||||
```
|
||||
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
|
||||
+13
-2
@@ -9,13 +9,24 @@ TERMINAL_SETUP_AUTH_METHOD_ID = "hermes-setup"
|
||||
|
||||
|
||||
def detect_provider() -> Optional[str]:
|
||||
"""Resolve the active Hermes runtime provider, or None if unavailable."""
|
||||
"""Resolve the active Hermes runtime provider, or None if unavailable.
|
||||
|
||||
Treats a ``Callable`` ``api_key`` (Azure Foundry Entra ID bearer
|
||||
token provider — see :mod:`agent.azure_identity_adapter`) as a valid
|
||||
credential. Without this, ACP sessions for Entra-configured Foundry
|
||||
deployments silently default to ``"openrouter"`` and the ACP auth
|
||||
handshake rejects the legitimate provider.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
runtime = resolve_runtime_provider()
|
||||
api_key = runtime.get("api_key")
|
||||
provider = runtime.get("provider")
|
||||
if isinstance(api_key, str) and api_key.strip() and isinstance(provider, str) and provider.strip():
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
return None
|
||||
is_string_key = isinstance(api_key, str) and api_key.strip()
|
||||
is_callable_provider = callable(api_key) and not isinstance(api_key, str)
|
||||
if is_string_key or is_callable_provider:
|
||||
return provider.strip().lower()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
@@ -158,8 +159,15 @@ def should_auto_approve_edit(proposal: EditProposal, policy: str, cwd: str | Non
|
||||
if policy == AUTO_APPROVE_SESSION:
|
||||
return True
|
||||
if policy == AUTO_APPROVE_WORKSPACE:
|
||||
if str(path).startswith("/tmp/"):
|
||||
# `/tmp` is the POSIX path but tempfile.gettempdir() is the real one on
|
||||
# every platform: `/private/tmp` on macOS (because `/tmp` is a symlink
|
||||
# and Path.resolve() follows it) and the per-user Temp dir on Windows.
|
||||
tmp_root = Path(tempfile.gettempdir()).resolve(strict=False)
|
||||
try:
|
||||
path.relative_to(tmp_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
if cwd:
|
||||
root = Path(cwd).expanduser().resolve(strict=False)
|
||||
try:
|
||||
|
||||
@@ -23,11 +23,21 @@ _OPTION_ID_TO_HERMES = {
|
||||
"allow_session": "session",
|
||||
"allow_always": "always",
|
||||
"deny": "deny",
|
||||
"deny_always": "deny",
|
||||
}
|
||||
|
||||
_PERMISSION_REQUEST_IDS = count(1)
|
||||
|
||||
|
||||
def _permission_option_supports_kind(kind: str) -> bool:
|
||||
"""Return whether the installed ACP SDK accepts a permission option kind."""
|
||||
try:
|
||||
PermissionOption(option_id="__probe__", kind=kind, name="probe")
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
|
||||
"""Return ACP options that match Hermes approval semantics."""
|
||||
options = [
|
||||
@@ -49,6 +59,14 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption
|
||||
),
|
||||
)
|
||||
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
|
||||
if _permission_option_supports_kind("reject_always"):
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="deny_always",
|
||||
kind="reject_always",
|
||||
name="Deny always",
|
||||
),
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
@@ -62,12 +80,14 @@ def _build_permission_tool_call(command: str, description: str):
|
||||
import acp as _acp
|
||||
|
||||
tool_call_id = f"perm-check-{next(_PERMISSION_REQUEST_IDS)}"
|
||||
title = f"{description}: {command}" if description else command
|
||||
content_text = f"{description}\n$ {command}" if description else f"$ {command}"
|
||||
return _acp.update_tool_call(
|
||||
tool_call_id,
|
||||
title=description,
|
||||
title=title,
|
||||
kind="execute",
|
||||
status="pending",
|
||||
content=[_acp.tool_content(_acp.text_block(f"$ {command}"))],
|
||||
content=[_acp.tool_content(_acp.text_block(content_text))],
|
||||
raw_input={"command": command, "description": description},
|
||||
)
|
||||
|
||||
|
||||
+55
-1
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import base64
|
||||
import contextvars
|
||||
import json
|
||||
@@ -46,6 +47,7 @@ from acp.schema import (
|
||||
ResourceContentBlock,
|
||||
SessionCapabilities,
|
||||
SessionForkCapabilities,
|
||||
SessionInfoUpdate,
|
||||
SessionListCapabilities,
|
||||
SessionMode,
|
||||
SessionModeState,
|
||||
@@ -707,6 +709,37 @@ class HermesACPAgent(acp.Agent):
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _send_session_info_update(self, session_id: str) -> None:
|
||||
"""Send ACP native session metadata after Hermes changes it."""
|
||||
if not self._conn:
|
||||
return
|
||||
try:
|
||||
row = self.session_manager._get_db().get_session(session_id)
|
||||
except Exception:
|
||||
logger.debug("Could not read ACP session info for %s", session_id, exc_info=True)
|
||||
return
|
||||
if not row:
|
||||
return
|
||||
|
||||
title = row.get("title")
|
||||
# The `sessions` table does not have an `updated_at` column (see
|
||||
# hermes_state.py schema — only started_at/ended_at). Use "now" as
|
||||
# the updated_at since we're emitting this notification precisely
|
||||
# because the title was just refreshed.
|
||||
updated_at = datetime.now(timezone.utc).isoformat()
|
||||
update = SessionInfoUpdate(
|
||||
session_update="session_info_update",
|
||||
title=title if isinstance(title, str) and title.strip() else None,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
try:
|
||||
await self._conn.session_update(
|
||||
session_id=session_id,
|
||||
update=update,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not send ACP session info update for %s", session_id, exc_info=True)
|
||||
|
||||
def _schedule_usage_update(self, state: SessionState) -> None:
|
||||
"""Schedule native context indicator refresh after ACP responses."""
|
||||
if not self._conn:
|
||||
@@ -1374,9 +1407,10 @@ class HermesACPAgent(acp.Agent):
|
||||
previous_approval_cb = None
|
||||
previous_interactive = None
|
||||
edit_approval_token = None
|
||||
previous_session_id = None
|
||||
|
||||
def _run_agent() -> dict:
|
||||
nonlocal previous_approval_cb, previous_interactive, edit_approval_token
|
||||
nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id
|
||||
# Bind HERMES_SESSION_KEY for this session so per-session caches
|
||||
# (e.g. the interactive sudo password cache in tools.terminal_tool)
|
||||
# scope to the ACP session rather than leaking across sessions
|
||||
@@ -1411,6 +1445,13 @@ class HermesACPAgent(acp.Agent):
|
||||
# and the non-interactive auto-approve path must not fire.
|
||||
previous_interactive = os.environ.get("HERMES_INTERACTIVE")
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
# Propagate the originating ACP session id to tools that want to
|
||||
# tag side-effects with it (e.g. ``kanban_create`` stamps it on
|
||||
# the new task so clients can render a per-session board). Save
|
||||
# and restore around the agent call so a re-used executor thread
|
||||
# never leaks one session's id into the next session's tools.
|
||||
previous_session_id = os.environ.get("HERMES_SESSION_ID")
|
||||
os.environ["HERMES_SESSION_ID"] = session_id
|
||||
try:
|
||||
result = agent.run_conversation(
|
||||
user_message=user_content,
|
||||
@@ -1428,6 +1469,11 @@ class HermesACPAgent(acp.Agent):
|
||||
os.environ.pop("HERMES_INTERACTIVE", None)
|
||||
else:
|
||||
os.environ["HERMES_INTERACTIVE"] = previous_interactive
|
||||
# Restore HERMES_SESSION_ID symmetrically.
|
||||
if previous_session_id is None:
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
else:
|
||||
os.environ["HERMES_SESSION_ID"] = previous_session_id
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
@@ -1471,12 +1517,20 @@ class HermesACPAgent(acp.Agent):
|
||||
try:
|
||||
from agent.title_generator import maybe_auto_title
|
||||
|
||||
def _notify_title_update(_title: str) -> None:
|
||||
if conn:
|
||||
loop.call_soon_threadsafe(
|
||||
asyncio.create_task,
|
||||
self._send_session_info_update(session_id),
|
||||
)
|
||||
|
||||
maybe_auto_title(
|
||||
self.session_manager._get_db(),
|
||||
session_id,
|
||||
user_text,
|
||||
final_response,
|
||||
state.history,
|
||||
title_callback=_notify_title_update,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True)
|
||||
|
||||
+178
-13
@@ -202,6 +202,44 @@ def _json_loads_maybe(value: Optional[str]) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _tool_result_failed(result: Optional[str], tool_name: str | None = None) -> bool:
|
||||
"""Return True when a structured Hermes tool result clearly failed.
|
||||
|
||||
Keep this deliberately conservative. Plain text can contain words like
|
||||
"error" because tests failed or a command printed diagnostics; Zed should
|
||||
only receive ACP failed status for structured tool-level failures.
|
||||
"""
|
||||
# Raised exceptions from the agent's tool executor get wrapped in a
|
||||
# canonical "Error executing tool '<name>': ..." prefix (see
|
||||
# agent/tool_executor.py around the try/except). That prefix is uniquely
|
||||
# produced by the wrapper itself — it cannot legitimately appear in
|
||||
# well-behaved tool output. Catch it so a tool that blew up shows as
|
||||
# failed in Zed instead of misleadingly green.
|
||||
if isinstance(result, str) and result.startswith("Error executing tool '"):
|
||||
return True
|
||||
|
||||
data = _json_loads_maybe(result)
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
|
||||
for key in ("success", "ok"):
|
||||
if data.get(key) is False:
|
||||
return True
|
||||
|
||||
exit_code = data.get("exit_code", data.get("returncode"))
|
||||
if isinstance(exit_code, int) and exit_code != 0:
|
||||
return True
|
||||
|
||||
# Hermes core/polished tools commonly report tool-level failures as a
|
||||
# structured {"error": "..."} payload without an explicit success flag.
|
||||
# Keep generic plugin/unknown tool payloads conservative to avoid marking
|
||||
# optional diagnostic messages as failed.
|
||||
if tool_name in _POLISHED_TOOLS and data.get("error") and not data.get("content"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _truncate_text(text: str, limit: int = 5000) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
@@ -278,6 +316,26 @@ def _format_search_files_result(result: Optional[str]) -> Optional[str]:
|
||||
data = _json_loads_maybe(result)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
files = data.get("files")
|
||||
if isinstance(files, list):
|
||||
total = data.get("total_count", len(files))
|
||||
shown = min(len(files), 20)
|
||||
truncated = bool(data.get("truncated")) or len(files) > shown
|
||||
lines = [
|
||||
"File search results",
|
||||
f"Found {total} file{'s' if total != 1 else ''}; showing {shown}.",
|
||||
"",
|
||||
]
|
||||
for path in files[:shown]:
|
||||
lines.append(f"- {path}")
|
||||
if truncated:
|
||||
lines.extend([
|
||||
"",
|
||||
"Results truncated. Narrow the search, add path/file_glob, or use offset to page.",
|
||||
])
|
||||
return _truncate_text("\n".join(lines), limit=7000)
|
||||
|
||||
matches = data.get("matches")
|
||||
if not isinstance(matches, list):
|
||||
return None
|
||||
@@ -668,14 +726,114 @@ def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optio
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> Optional[str]:
|
||||
def _format_structured_value(
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
indent: int = 0,
|
||||
max_depth: int = 3,
|
||||
max_items: int = 8,
|
||||
) -> List[str]:
|
||||
"""Render nested JSON-ish values as compact Markdown bullets, not inline blobs."""
|
||||
prefix = " " * indent
|
||||
bullet = f"{prefix}- "
|
||||
label = f"**{key}:**" if key else ""
|
||||
|
||||
if value in (None, "", [], {}):
|
||||
return []
|
||||
|
||||
if max_depth <= 0:
|
||||
if isinstance(value, (dict, list)):
|
||||
preview = json.dumps(value, ensure_ascii=False, default=str)
|
||||
else:
|
||||
preview = str(value)
|
||||
return [f"{bullet}{label} {_truncate_text(preview, limit=240)}" if label else f"{bullet}{_truncate_text(preview, limit=240)}"]
|
||||
|
||||
if isinstance(value, dict):
|
||||
lines = [f"{bullet}{label}" if label else f"{bullet}{len(value)} fields"]
|
||||
shown = 0
|
||||
for child_key, child_value in value.items():
|
||||
if child_value in (None, "", [], {}):
|
||||
continue
|
||||
lines.extend(
|
||||
_format_structured_value(
|
||||
str(child_key),
|
||||
child_value,
|
||||
indent=indent + 1,
|
||||
max_depth=max_depth - 1,
|
||||
max_items=max_items,
|
||||
)
|
||||
)
|
||||
shown += 1
|
||||
if shown >= max_items:
|
||||
remaining = max(0, len(value) - shown)
|
||||
if remaining:
|
||||
lines.append(f"{' ' * (indent + 1)}- ... {remaining} more fields")
|
||||
break
|
||||
return lines
|
||||
|
||||
if isinstance(value, list):
|
||||
lines = [f"{bullet}{label} {len(value)} item{'s' if len(value) != 1 else ''}" if label else f"{bullet}{len(value)} item{'s' if len(value) != 1 else ''}"]
|
||||
for idx, item in enumerate(value[:max_items], 1):
|
||||
if isinstance(item, dict):
|
||||
headline = str(item.get("content") or item.get("message") or item.get("title") or item.get("name") or item.get("id") or "").strip()
|
||||
if headline:
|
||||
lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(headline, limit=220)}")
|
||||
for child_key in ("id", "status", "type", "scope", "quality_score", "score", "path", "url"):
|
||||
child_value = item.get(child_key)
|
||||
if child_value not in (None, "", [], {}):
|
||||
lines.append(f"{' ' * (indent + 2)}- **{child_key}:** {_truncate_text(str(child_value), limit=180)}")
|
||||
else:
|
||||
lines.append(f"{' ' * (indent + 1)}{idx}.")
|
||||
for child_key, child_value in list(item.items())[:max_items]:
|
||||
lines.extend(
|
||||
_format_structured_value(
|
||||
str(child_key),
|
||||
child_value,
|
||||
indent=indent + 2,
|
||||
max_depth=max_depth - 1,
|
||||
max_items=max_items,
|
||||
)
|
||||
)
|
||||
elif isinstance(item, list):
|
||||
lines.append(f"{' ' * (indent + 1)}{idx}. {len(item)} items")
|
||||
for nested in item[:max_items]:
|
||||
lines.extend(
|
||||
_format_structured_value(
|
||||
"",
|
||||
nested,
|
||||
indent=indent + 2,
|
||||
max_depth=max_depth - 1,
|
||||
max_items=max_items,
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(str(item), limit=240)}")
|
||||
if len(value) > max_items:
|
||||
lines.append(f"{' ' * (indent + 1)}... {len(value) - max_items} more items")
|
||||
return lines
|
||||
|
||||
return [f"{bullet}{label} {_truncate_text(str(value), limit=500)}" if label else f"{bullet}{_truncate_text(str(value), limit=500)}"]
|
||||
|
||||
|
||||
def _format_generic_structured_result(
|
||||
tool_name: str,
|
||||
result: Optional[str],
|
||||
*,
|
||||
fallback_to_text: bool = True,
|
||||
) -> Optional[str]:
|
||||
data = _json_loads_maybe(result)
|
||||
if not isinstance(data, (dict, list)):
|
||||
return result if isinstance(result, str) and result.strip() else None
|
||||
return result if fallback_to_text and isinstance(result, str) and result.strip() else None
|
||||
if isinstance(data, list):
|
||||
lines = [f"{tool_name}: {len(data)} item{'s' if len(data) != 1 else ''}"]
|
||||
for item in data[:12]:
|
||||
lines.append(f"- {_truncate_text(str(item), limit=240)}")
|
||||
if isinstance(item, (dict, list)):
|
||||
lines.extend(_format_structured_value("", item, indent=0, max_depth=2, max_items=6))
|
||||
else:
|
||||
lines.append(f"- {_truncate_text(str(item), limit=240)}")
|
||||
if len(data) > 12:
|
||||
lines.append(f"... {len(data) - 12} more items")
|
||||
return _truncate_text("\n".join(lines), limit=5000)
|
||||
|
||||
if data.get("success") is False or data.get("error"):
|
||||
@@ -699,12 +857,9 @@ def _format_generic_structured_result(tool_name: str, result: Optional[str]) ->
|
||||
continue
|
||||
if value in (None, "", [], {}):
|
||||
continue
|
||||
if isinstance(value, (dict, list)):
|
||||
preview = json.dumps(value, ensure_ascii=False, default=str)
|
||||
else:
|
||||
preview = str(value)
|
||||
lines.append(f"- **{key}:** {_truncate_text(preview, limit=500)}")
|
||||
if len(lines) >= 14:
|
||||
lines.extend(_format_structured_value(str(key), value, indent=0, max_depth=3, max_items=8))
|
||||
if len(lines) >= 40:
|
||||
lines.append("- ... more fields truncated")
|
||||
break
|
||||
|
||||
content = data.get("content")
|
||||
@@ -744,8 +899,9 @@ def _build_polished_completion_content(
|
||||
if formatter is None and tool_name in _POLISHED_TOOLS:
|
||||
formatter = lambda: _format_generic_structured_result(tool_name, result)
|
||||
if formatter is None:
|
||||
return None
|
||||
text = formatter()
|
||||
text = _format_generic_structured_result(tool_name, result, fallback_to_text=False)
|
||||
else:
|
||||
text = formatter()
|
||||
if not text:
|
||||
return None
|
||||
return [_text(text)]
|
||||
@@ -1135,6 +1291,11 @@ def build_tool_start(
|
||||
tool_call_id, title, kind=kind, content=content, locations=locations,
|
||||
)
|
||||
|
||||
if not arguments:
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, title, kind=kind, content=None, locations=locations, raw_input=None,
|
||||
)
|
||||
|
||||
# Generic fallback
|
||||
try:
|
||||
args_text = json.dumps(arguments, indent=2, default=str)
|
||||
@@ -1147,6 +1308,10 @@ def build_tool_start(
|
||||
)
|
||||
|
||||
|
||||
def _is_structured_json_result(result: Optional[str]) -> bool:
|
||||
return isinstance(_json_loads_maybe(result), (dict, list))
|
||||
|
||||
|
||||
def build_tool_complete(
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
@@ -1169,9 +1334,9 @@ def build_tool_complete(
|
||||
return acp.update_tool_call(
|
||||
tool_call_id,
|
||||
kind=kind,
|
||||
status="completed",
|
||||
status="failed" if _tool_result_failed(result, tool_name) else "completed",
|
||||
content=content,
|
||||
raw_output=None if tool_name in _POLISHED_TOOLS else result,
|
||||
raw_output=None if tool_name in _POLISHED_TOOLS or _is_structured_json_result(result) else result,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+49
-8
@@ -560,7 +560,16 @@ def init_agent(
|
||||
agent._client_kwargs = {}
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model} (Anthropic native)")
|
||||
if effective_key and len(effective_key) > 12:
|
||||
# ``effective_key`` may be a callable Entra ID bearer
|
||||
# provider for Azure Foundry anthropic_messages mode.
|
||||
# The Anthropic adapter installs an httpx event hook
|
||||
# that mints a fresh JWT per request — we never
|
||||
# invoke or inspect the callable in the banner.
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
|
||||
if is_token_provider(effective_key):
|
||||
print("🔑 Using credentials: Microsoft Entra ID")
|
||||
elif isinstance(effective_key, str) and len(effective_key) > 12:
|
||||
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
|
||||
elif agent.api_mode == "bedrock_converse":
|
||||
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
|
||||
@@ -764,12 +773,19 @@ def init_agent(
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model}")
|
||||
if base_url:
|
||||
print(f"🔗 Using custom base URL: {base_url}")
|
||||
# Always show API key info (masked) for debugging auth issues
|
||||
# ``api_key`` may be a callable Entra ID bearer
|
||||
# provider (Azure Foundry). The OpenAI SDK mints a
|
||||
# fresh JWT per request internally — the banner
|
||||
# never invokes or inspects the callable.
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
|
||||
key_used = client_kwargs.get("api_key", "none")
|
||||
if key_used and key_used != "dummy-key" and len(key_used) > 12:
|
||||
if is_token_provider(key_used):
|
||||
print("🔑 Using credentials: Microsoft Entra ID")
|
||||
elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12:
|
||||
print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}")
|
||||
else:
|
||||
print(f"⚠️ Warning: API key appears invalid or missing (got: '{key_used[:20] if key_used else 'none'}...')")
|
||||
print("⚠️ Warning: API key appears invalid or missing")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
||||
|
||||
@@ -812,7 +828,6 @@ def init_agent(
|
||||
tool_names = sorted(agent.valid_tool_names)
|
||||
if not agent.quiet_mode:
|
||||
print(f"🛠️ Loaded {len(agent.tools)} tools: {', '.join(tool_names)}")
|
||||
|
||||
# Show filtering info if applied
|
||||
if enabled_toolsets:
|
||||
print(f" ✅ Enabled toolsets: {', '.join(enabled_toolsets)}")
|
||||
@@ -820,7 +835,18 @@ def init_agent(
|
||||
print(f" ❌ Disabled toolsets: {', '.join(disabled_toolsets)}")
|
||||
elif not agent.quiet_mode:
|
||||
print("🛠️ No tools loaded (all tools filtered out or unavailable)")
|
||||
|
||||
|
||||
# Kanban worker/orchestrator lifecycle guidance is session-static:
|
||||
# the dispatcher decides at spawn time whether this process is a kanban
|
||||
# worker (kanban_show tool is present iff HERMES_KANBAN_TASK is set).
|
||||
# Resolving the ~835-token block once here avoids re-running the
|
||||
# membership test + reference on every system-prompt rebuild
|
||||
# (init + each context compression).
|
||||
from agent.prompt_builder import KANBAN_GUIDANCE
|
||||
agent._kanban_worker_guidance = (
|
||||
KANBAN_GUIDANCE if "kanban_show" in agent.valid_tool_names else ""
|
||||
)
|
||||
|
||||
# Check tool requirements
|
||||
if agent.tools and not agent.quiet_mode:
|
||||
requirements = _ra().check_toolset_requirements()
|
||||
@@ -1089,6 +1115,9 @@ def init_agent(
|
||||
compression_protect_first = max(
|
||||
0, int(_compression_cfg.get("protect_first_n", 3))
|
||||
)
|
||||
compression_abort_on_summary_failure = str(
|
||||
_compression_cfg.get("abort_on_summary_failure", False)
|
||||
).lower() in {"true", "1", "yes"}
|
||||
|
||||
# Read optional explicit context_length override for the auxiliary
|
||||
# compression model. Custom endpoints often cannot report this via
|
||||
@@ -1303,6 +1332,7 @@ def init_agent(
|
||||
config_context_length=_config_context_length,
|
||||
provider=agent.provider,
|
||||
api_mode=agent.api_mode,
|
||||
abort_on_summary_failure=compression_abort_on_summary_failure,
|
||||
)
|
||||
agent.compression_enabled = compression_enabled
|
||||
|
||||
@@ -1395,7 +1425,12 @@ def init_agent(
|
||||
_ra().logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override)
|
||||
if agent._ollama_num_ctx is None and agent.base_url and is_local_endpoint(agent.base_url):
|
||||
try:
|
||||
_detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=agent.api_key or "")
|
||||
# ``agent.api_key`` may be a callable (Entra token provider).
|
||||
# Ollama detection makes a manual HTTP request and expects a
|
||||
# string — Azure Foundry isn't a local endpoint so this branch
|
||||
# never fires for Entra, but guard defensively.
|
||||
_key_for_ollama = agent.api_key if isinstance(agent.api_key, str) else ""
|
||||
_detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=_key_for_ollama or "")
|
||||
if _detected and _detected > 0:
|
||||
agent._ollama_num_ctx = _detected
|
||||
except Exception as exc:
|
||||
@@ -1431,7 +1466,13 @@ def init_agent(
|
||||
# Gateway status_callback is not yet wired, so any warning is stored
|
||||
# in _compression_warning and replayed in the first run_conversation().
|
||||
agent._compression_warning = None
|
||||
agent._check_compression_model_feasibility()
|
||||
# Lazy feasibility check: deferred to the first turn that approaches the
|
||||
# compression threshold. Running it eagerly here costs ~400ms cold (network
|
||||
# probe of the auxiliary provider chain + /models lookup) on every agent
|
||||
# init, including short ``chat -q`` runs that never reach the threshold.
|
||||
# ``ensure_compression_feasibility_checked`` (called from
|
||||
# ``run_conversation``'s preflight) runs it at most once per agent.
|
||||
agent._compression_feasibility_checked = False
|
||||
|
||||
# Snapshot primary runtime for per-turn restoration. When fallback
|
||||
# activates during a turn, the next turn restores these values so the
|
||||
|
||||
@@ -39,7 +39,7 @@ from agent.message_sanitization import (
|
||||
_repair_tool_call_arguments,
|
||||
_sanitize_surrogates,
|
||||
)
|
||||
from agent.tool_dispatch_helpers import _trajectory_normalize_msg
|
||||
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
|
||||
from agent.trajectory import convert_scratchpad_to_think
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
|
||||
@@ -317,12 +317,11 @@ def sanitize_tool_call_arguments(
|
||||
if existing_tool_msg is None:
|
||||
messages.insert(
|
||||
insert_at,
|
||||
{
|
||||
"role": "tool",
|
||||
"name": function_name if function_name != "?" else "",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": marker,
|
||||
},
|
||||
make_tool_result_message(
|
||||
function_name if function_name != "?" else "",
|
||||
marker,
|
||||
tool_call_id,
|
||||
),
|
||||
)
|
||||
insert_at += 1
|
||||
else:
|
||||
@@ -606,7 +605,22 @@ def recover_with_credential_pool(
|
||||
return False, True
|
||||
|
||||
if effective_reason == FailoverReason.auth:
|
||||
if agent._is_entitlement_failure(error_context, status_code):
|
||||
# Subscription/entitlement 403s look like auth failures on the wire
|
||||
# but refresh cannot fix them — the OAuth token is already valid,
|
||||
# the account simply lacks the entitlement. Without this guard,
|
||||
# ``try_refresh_current()`` keeps minting fresh tokens against the
|
||||
# same unsubscribed account and the main agent loop spins re-issuing
|
||||
# the same 403 until the user Ctrl+C's.
|
||||
#
|
||||
# Defense-in-depth for #26847: xAI's backend has been seen to 403
|
||||
# standard SuperGrok subscribers with bodies that don't match the
|
||||
# existing entitlement keyword set in ``_is_entitlement_failure``.
|
||||
# Any 403 against ``xai-oauth`` is treated as entitlement here so
|
||||
# the refresh loop can't spin in those cases either.
|
||||
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
|
||||
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
|
||||
is_entitlement = True
|
||||
if is_entitlement:
|
||||
_ra().logger.info(
|
||||
"Credential %s — entitlement-shaped 403 from %s; "
|
||||
"skipping pool refresh (account lacks subscription, "
|
||||
@@ -1390,10 +1404,16 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
_sm_custom_providers = get_compatible_custom_providers(_sm_cfg)
|
||||
except Exception:
|
||||
_sm_custom_providers = None
|
||||
# ``agent.api_key`` may be a callable (Azure Foundry Entra ID
|
||||
# token provider). ``get_model_context_length`` expects a
|
||||
# string for its live-probe paths; for Foundry the context
|
||||
# length normally resolves via config or static catalogs and
|
||||
# never hits a probe, but coerce to empty string defensively.
|
||||
_ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else ""
|
||||
new_context_length = get_model_context_length(
|
||||
agent.model,
|
||||
base_url=agent.base_url,
|
||||
api_key=agent.api_key,
|
||||
api_key=_ctx_api_key,
|
||||
provider=agent.provider,
|
||||
config_context_length=getattr(agent, "_config_context_length", None),
|
||||
custom_providers=_sm_custom_providers,
|
||||
@@ -1402,7 +1422,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
model=agent.model,
|
||||
context_length=new_context_length,
|
||||
base_url=agent.base_url,
|
||||
api_key=getattr(agent, "api_key", ""),
|
||||
api_key=agent.api_key, # context_compressor forwards to call_llm; callable preserved
|
||||
provider=agent.provider,
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
|
||||
+122
-9
@@ -17,6 +17,7 @@ import os
|
||||
import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -364,7 +365,7 @@ def _normalize_base_url_text(base_url) -> str:
|
||||
def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for non-Anthropic endpoints using the Anthropic Messages API.
|
||||
|
||||
Third-party proxies (Azure AI Foundry, AWS Bedrock, self-hosted) authenticate
|
||||
Third-party proxies (Microsoft Foundry, AWS Bedrock, self-hosted) authenticate
|
||||
with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth
|
||||
detection should be skipped for these endpoints.
|
||||
"""
|
||||
@@ -508,6 +509,29 @@ def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_azure_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for Azure-hosted Anthropic Messages endpoints.
|
||||
|
||||
Covers both the modern Foundry host family (``*.services.ai.azure.*``)
|
||||
and the legacy Azure OpenAI host family (``*.openai.azure.*``) when
|
||||
serving Anthropic's ``/anthropic`` route. Used to opt-in those hosts
|
||||
to the ``api-version`` query-param plumbing required by Azure.
|
||||
|
||||
Intentionally avoids a finite allow-list of TLD suffixes so it works
|
||||
across sovereign / private Azure clouds.
|
||||
"""
|
||||
normalized = _normalize_base_url_text(base_url)
|
||||
if not normalized:
|
||||
return False
|
||||
parsed = urlparse(normalized)
|
||||
host = (parsed.hostname or "").lower().rstrip(".")
|
||||
path = (parsed.path or "").lower()
|
||||
host_padded = f".{host}."
|
||||
is_foundry_host = ".services.ai.azure." in host_padded
|
||||
is_legacy_azoai_host = ".openai.azure." in host_padded
|
||||
return (is_foundry_host or is_legacy_azoai_host) and "/anthropic" in path
|
||||
|
||||
|
||||
def _common_betas_for_base_url(
|
||||
base_url: str | None,
|
||||
*,
|
||||
@@ -523,7 +547,7 @@ def _common_betas_for_base_url(
|
||||
|
||||
The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by
|
||||
default because some subscriptions reject it. Add it only for endpoint
|
||||
families that still require it for 1M context, currently Azure AI Foundry.
|
||||
families that still require it for 1M context, currently Microsoft Foundry.
|
||||
Bedrock uses its own client helper below and opts in explicitly.
|
||||
|
||||
``drop_context_1m_beta=True`` strips the 1M-context beta from any path that
|
||||
@@ -540,8 +564,81 @@ def _common_betas_for_base_url(
|
||||
return betas
|
||||
|
||||
|
||||
def _build_anthropic_client_with_bearer_hook(
|
||||
token_provider,
|
||||
base_url: str = None,
|
||||
timeout: float = None,
|
||||
*,
|
||||
drop_context_1m_beta: bool = False,
|
||||
):
|
||||
"""Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`.
|
||||
|
||||
Anthropic SDK 0.86.0 stores ``api_key`` / ``auth_token`` as static
|
||||
strings; there is no callable-token contract. To get per-request
|
||||
bearer refresh (Microsoft's documented Foundry pattern), we hand
|
||||
the SDK a custom ``httpx.Client`` whose request event hook mints a
|
||||
fresh JWT from the Entra credential chain and rewrites
|
||||
``Authorization: Bearer <jwt>`` on every outbound request. The SDK
|
||||
ignores its own auth logic when ``http_client`` is provided (the
|
||||
hook strips any pre-set Authorization).
|
||||
|
||||
The placeholder ``auth_token`` is required because the SDK raises
|
||||
``AnthropicError`` at construction if neither ``api_key`` nor
|
||||
``auth_token`` is set — but the hook overrides it per-request so
|
||||
the placeholder value never reaches Azure.
|
||||
"""
|
||||
_anthropic_sdk = _get_anthropic_sdk()
|
||||
if _anthropic_sdk is None:
|
||||
raise ImportError(
|
||||
"The 'anthropic' package is required for Azure Foundry Anthropic-style "
|
||||
"endpoints with Entra ID auth. Install with: pip install 'anthropic>=0.39.0'"
|
||||
)
|
||||
|
||||
normalize_proxy_env_vars()
|
||||
|
||||
from httpx import Timeout
|
||||
from agent.azure_identity_adapter import build_bearer_http_client
|
||||
|
||||
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
|
||||
timeout_obj = Timeout(timeout=float(_read_timeout), connect=10.0)
|
||||
|
||||
# Strip any trailing /v1 — the Anthropic SDK appends /v1/messages.
|
||||
normalized_base_url = _normalize_base_url_text(base_url)
|
||||
if normalized_base_url:
|
||||
import re as _re
|
||||
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
|
||||
|
||||
http_client = build_bearer_http_client(token_provider, timeout=timeout_obj)
|
||||
|
||||
kwargs = {
|
||||
"timeout": timeout_obj,
|
||||
"http_client": http_client,
|
||||
# The SDK requires *something* for api_key/auth_token. Our
|
||||
# event hook overrides Authorization per request so this value
|
||||
# is never sent. The sentinel string makes accidental leaks
|
||||
# diagnosable in logs.
|
||||
"auth_token": "entra-id-bearer-via-http-hook",
|
||||
}
|
||||
|
||||
if normalized_base_url:
|
||||
if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url:
|
||||
kwargs["base_url"] = normalized_base_url
|
||||
kwargs["default_query"] = {"api-version": "2025-04-15"}
|
||||
else:
|
||||
kwargs["base_url"] = normalized_base_url
|
||||
|
||||
common_betas = _common_betas_for_base_url(
|
||||
normalized_base_url,
|
||||
drop_context_1m_beta=drop_context_1m_beta,
|
||||
)
|
||||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
|
||||
return _anthropic_sdk.Anthropic(**kwargs)
|
||||
|
||||
|
||||
def build_anthropic_client(
|
||||
api_key: str,
|
||||
api_key,
|
||||
base_url: str = None,
|
||||
timeout: float = None,
|
||||
*,
|
||||
@@ -549,6 +646,17 @@ def build_anthropic_client(
|
||||
):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
``api_key`` accepts either:
|
||||
|
||||
* a static ``str`` — the historical contract for all key-based and
|
||||
OAuth flows.
|
||||
* a ``Callable[[], str]`` — an Entra ID bearer token provider from
|
||||
:mod:`agent.azure_identity_adapter`. The Anthropic SDK itself
|
||||
requires a static string, so when given a callable we construct
|
||||
a custom ``httpx.Client`` with a request event hook that mints a
|
||||
fresh JWT per outbound request and rewrites the ``Authorization``
|
||||
header. The SDK never sees the callable directly.
|
||||
|
||||
If *timeout* is provided it overrides the default 900s read timeout. The
|
||||
connect timeout stays at 10s. Callers pass this from the per-provider /
|
||||
per-model ``request_timeout_seconds`` config so Anthropic-native and
|
||||
@@ -570,6 +678,14 @@ def build_anthropic_client(
|
||||
"Install it with: pip install 'anthropic>=0.39.0'"
|
||||
)
|
||||
|
||||
# Callable api_key → Entra ID bearer provider path. Delegated to a
|
||||
# helper so the existing static-key code below stays unchanged.
|
||||
if callable(api_key) and not isinstance(api_key, str):
|
||||
return _build_anthropic_client_with_bearer_hook(
|
||||
api_key, base_url, timeout,
|
||||
drop_context_1m_beta=drop_context_1m_beta,
|
||||
)
|
||||
|
||||
normalize_proxy_env_vars()
|
||||
|
||||
from httpx import Timeout
|
||||
@@ -584,8 +700,7 @@ def build_anthropic_client(
|
||||
# Pass it via default_query so the SDK appends it to every request URL
|
||||
# without corrupting the base_url (appending it directly produces
|
||||
# malformed paths like /anthropic?api-version=.../v1/messages).
|
||||
_is_azure_endpoint = "azure.com" in normalized_base_url.lower()
|
||||
if _is_azure_endpoint and "api-version" not in normalized_base_url:
|
||||
if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url:
|
||||
kwargs["base_url"] = normalized_base_url.rstrip("/")
|
||||
kwargs["default_query"] = {"api-version": "2025-04-15"}
|
||||
else:
|
||||
@@ -615,7 +730,7 @@ def build_anthropic_client(
|
||||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
elif _is_third_party_anthropic_endpoint(base_url):
|
||||
# Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their
|
||||
# Third-party proxies (Microsoft Foundry, AWS Bedrock, etc.) use their
|
||||
# own API keys with x-api-key auth. Skip OAuth detection — their keys
|
||||
# don't follow Anthropic's sk-ant-* prefix convention and would be
|
||||
# misclassified as OAuth tokens.
|
||||
@@ -1757,7 +1872,7 @@ def convert_messages_to_anthropic(
|
||||
# causing HTTP 400 "Invalid signature in thinking block".
|
||||
#
|
||||
# Signatures are Anthropic-proprietary. Third-party endpoints
|
||||
# (MiniMax, Azure AI Foundry, self-hosted proxies) cannot validate
|
||||
# (MiniMax, Microsoft Foundry, self-hosted proxies) cannot validate
|
||||
# them and will reject them outright. When targeting a third-party
|
||||
# endpoint, strip ALL thinking/redacted_thinking blocks from every
|
||||
# assistant message — the third-party will generate its own
|
||||
@@ -2103,5 +2218,3 @@ def build_anthropic_kwargs(
|
||||
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
|
||||
+188
-18
@@ -711,8 +711,12 @@ class _CodexCompletionsAdapter:
|
||||
# keywords (HTTP 400). Strip them here to match the parity guarantee that
|
||||
# chat_completion_helpers.py provides for the main-agent xAI path.
|
||||
try:
|
||||
from tools.schema_sanitizer import strip_pattern_and_format
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools, _ = strip_pattern_and_format(list(tools))
|
||||
tools, _ = strip_slash_enum(tools)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Auxiliary client: failed to sanitize tool schemas for "
|
||||
@@ -1303,7 +1307,10 @@ def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]:
|
||||
with xAI Grok OAuth.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
|
||||
from hermes_cli.auth import (
|
||||
DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
_xai_validate_inference_base_url,
|
||||
)
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
if pool and pool.has_credentials():
|
||||
@@ -1314,13 +1321,13 @@ def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]:
|
||||
or getattr(entry, "access_token", "")
|
||||
or ""
|
||||
).strip()
|
||||
base_url = str(
|
||||
base_url = _xai_validate_inference_base_url(
|
||||
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
|
||||
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/")
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
or DEFAULT_XAI_OAUTH_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
or str(getattr(entry, "runtime_base_url", None) or "").strip().rstrip("/")
|
||||
or str(getattr(entry, "base_url", None) or "").strip().rstrip("/"),
|
||||
fallback=DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
)
|
||||
if api_key and base_url:
|
||||
return api_key, base_url
|
||||
except Exception as exc:
|
||||
@@ -1902,6 +1909,120 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
|
||||
|
||||
def _try_azure_foundry(
|
||||
*,
|
||||
model: Optional[str] = None,
|
||||
explicit_api_key: Optional[str] = None,
|
||||
explicit_base_url: Optional[str] = None,
|
||||
api_mode: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Resolve an Azure Foundry auxiliary client via the runtime resolver.
|
||||
|
||||
Mirrors the ``_try_anthropic`` / ``_try_nous`` shape but delegates to
|
||||
:func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime` —
|
||||
the same resolver the main agent uses — so:
|
||||
|
||||
* ``auth_mode: api_key`` (default) gets the static
|
||||
``AZURE_FOUNDRY_API_KEY`` string.
|
||||
* ``auth_mode: entra_id`` gets a callable bearer-token provider
|
||||
(``Callable[[], str]`` from
|
||||
:mod:`agent.azure_identity_adapter`).
|
||||
* Per-model ``api_mode`` auto-routing for GPT-5.x / o-series /
|
||||
codex models works.
|
||||
* ``model.entra.{tenant_id,client_id,authority,scope}`` config
|
||||
fields propagate.
|
||||
* Non-default ``model.base_url`` overrides are honored.
|
||||
|
||||
The OpenAI SDK accepts both shapes for ``api_key`` so the caller
|
||||
can forward the result without coercion.
|
||||
|
||||
Returns ``(client, model)`` or ``(None, None)`` on failure.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.config import load_config
|
||||
except ImportError:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
except Exception:
|
||||
model_cfg = {}
|
||||
|
||||
try:
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg=model_cfg,
|
||||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=explicit_base_url,
|
||||
target_model=model,
|
||||
)
|
||||
except AuthError as exc:
|
||||
logger.debug("Auxiliary azure-foundry: %s", exc)
|
||||
return None, None
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary azure-foundry runtime error: %s", exc)
|
||||
return None, None
|
||||
|
||||
api_key = runtime.get("api_key")
|
||||
base_url = str(runtime.get("base_url", "") or "")
|
||||
runtime_api_mode = api_mode or runtime.get("api_mode") or "chat_completions"
|
||||
|
||||
# Empty-string check on api_key here would be wrong for callable
|
||||
# token providers (callables are truthy and non-empty by definition).
|
||||
# Bail only when api_key is None / empty string.
|
||||
_has_key = bool(api_key) if not callable(api_key) else True
|
||||
if not _has_key or not base_url:
|
||||
return None, None
|
||||
|
||||
final_model = _normalize_resolved_model(
|
||||
model or str(model_cfg.get("default") or ""),
|
||||
"azure-foundry",
|
||||
)
|
||||
if not final_model:
|
||||
# No fallback aux model for Azure — the user must have a
|
||||
# deployment name. Surface that as "no client" so the auto
|
||||
# chain falls through to the next provider rather than 404ing.
|
||||
logger.debug(
|
||||
"Auxiliary azure-foundry: no model resolved (model=%r, default=%r)",
|
||||
model, model_cfg.get("default"),
|
||||
)
|
||||
return None, None
|
||||
|
||||
# Azure pre-v1 endpoints sometimes carry api-version query params
|
||||
# in the base URL; the OpenAI SDK drops them when joining paths,
|
||||
# so lift them out and pass via default_query.
|
||||
extra: Dict[str, Any] = {}
|
||||
_clean_base, _dq = _extract_url_query_params(base_url)
|
||||
if _dq:
|
||||
extra["default_query"] = _dq
|
||||
|
||||
client = OpenAI(api_key=api_key, base_url=_clean_base, **extra)
|
||||
|
||||
if runtime_api_mode == "codex_responses":
|
||||
# GPT-5.x / o-series / codex models on Azure Foundry are
|
||||
# Responses-API-only — wrap so chat.completions.create() is
|
||||
# translated to /responses behind the scenes.
|
||||
return CodexAuxiliaryClient(client, final_model), final_model
|
||||
|
||||
if runtime_api_mode == "anthropic_messages":
|
||||
# Forward ``api_key`` verbatim — for static keys it's a string,
|
||||
# for Entra ID it's a callable. ``_maybe_wrap_anthropic`` →
|
||||
# ``build_anthropic_client`` detects the callable and installs
|
||||
# the bearer-injecting httpx hook.
|
||||
return _maybe_wrap_anthropic(
|
||||
client, final_model, api_key,
|
||||
base_url, runtime_api_mode,
|
||||
), final_model
|
||||
|
||||
# chat_completions — return the plain OpenAI client.
|
||||
return client, final_model
|
||||
|
||||
|
||||
def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]:
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
|
||||
@@ -1957,20 +2078,31 @@ _AUTO_PROVIDER_LABELS = {
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
}
|
||||
|
||||
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode")
|
||||
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode")
|
||||
|
||||
|
||||
def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
"""Return a sanitized copy of a live main-runtime override."""
|
||||
def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Return a sanitized copy of a live main-runtime override.
|
||||
|
||||
Most fields are stripped strings. ``api_key`` may legitimately be a
|
||||
zero-arg callable (Azure Foundry Entra ID token provider) — preserve
|
||||
those as-is so auxiliary clients inherit the same authentication
|
||||
surface as the main agent. The OpenAI SDK accepts ``Callable[[], str]``
|
||||
for ``api_key`` and calls it before every request.
|
||||
"""
|
||||
if not isinstance(main_runtime, dict):
|
||||
return {}
|
||||
normalized: Dict[str, str] = {}
|
||||
normalized: Dict[str, Any] = {}
|
||||
for field in _MAIN_RUNTIME_FIELDS:
|
||||
value = main_runtime.get(field)
|
||||
# Preserve a callable api_key (Entra ID bearer provider) unchanged.
|
||||
if field == "api_key" and callable(value) and not isinstance(value, str):
|
||||
normalized[field] = value
|
||||
continue
|
||||
if isinstance(value, str) and value.strip():
|
||||
normalized[field] = value.strip()
|
||||
provider = normalized.get("provider")
|
||||
if provider:
|
||||
if isinstance(provider, str):
|
||||
normalized["provider"] = provider.lower()
|
||||
return normalized
|
||||
|
||||
@@ -2762,10 +2894,10 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
|
||||
runtime = _normalize_main_runtime(main_runtime)
|
||||
runtime_provider = runtime.get("provider", "")
|
||||
runtime_model = runtime.get("model", "")
|
||||
runtime_base_url = runtime.get("base_url", "")
|
||||
runtime_model = str(runtime.get("model") or "")
|
||||
runtime_base_url = str(runtime.get("base_url") or "")
|
||||
runtime_api_key = runtime.get("api_key", "")
|
||||
runtime_api_mode = runtime.get("api_mode", "")
|
||||
runtime_api_mode = str(runtime.get("api_mode") or "")
|
||||
|
||||
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
|
||||
# provider (not 'custom'). This catches the common "env poisoning"
|
||||
@@ -2793,8 +2925,8 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
# on aggregators (OpenRouter, Nous) who previously got routed to a
|
||||
# cheap provider-side default. Explicit per-task overrides set via
|
||||
# config.yaml (auxiliary.<task>.provider) still win over this.
|
||||
main_provider = runtime_provider or _read_main_provider()
|
||||
main_model = runtime_model or _read_main_model()
|
||||
main_provider = str(runtime_provider or _read_main_provider() or "")
|
||||
main_model = str(runtime_model or _read_main_model() or "")
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
resolved_provider = main_provider
|
||||
@@ -3188,7 +3320,11 @@ def resolve_provider_client(
|
||||
if client is not None:
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
_cbase = str(getattr(client, "base_url", "") or "")
|
||||
_ckey = str(getattr(client, "api_key", "") or "")
|
||||
# ``client.api_key`` may be a callable (Azure Foundry Entra
|
||||
# bearer provider). Pass empty string for the wrapper-detection
|
||||
# path — wrapping decisions are based on base_url + api_mode.
|
||||
_raw_ckey = getattr(client, "api_key", "")
|
||||
_ckey = "" if (callable(_raw_ckey) and not isinstance(_raw_ckey, str)) else str(_raw_ckey or "")
|
||||
client = _wrap_if_needed(client, final_model, _cbase, _ckey)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
@@ -3300,6 +3436,40 @@ def resolve_provider_client(
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── Azure Foundry (delegates to runtime resolver for auth_mode-aware routing) ─
|
||||
#
|
||||
# The generic PROVIDER_REGISTRY path below uses
|
||||
# ``resolve_api_key_provider_credentials`` which only knows about the
|
||||
# static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important
|
||||
# cases for the ``azure-foundry`` provider:
|
||||
#
|
||||
# 1. ``model.auth_mode: entra_id`` — no static key exists; we need
|
||||
# a callable bearer-token provider from ``azure_identity_adapter``.
|
||||
# 2. Non-default ``model.base_url`` (Foundry projects path) — the
|
||||
# env-var-only resolver doesn't apply config-yaml-driven URL
|
||||
# overrides.
|
||||
#
|
||||
# Delegate to the same runtime resolver the main agent uses so
|
||||
# auxiliary tasks (title generation, compression, vision, embedding,
|
||||
# session search) inherit the user's full Azure config.
|
||||
if provider == "azure-foundry":
|
||||
client, default_model = _try_azure_foundry(
|
||||
model=model,
|
||||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=explicit_base_url,
|
||||
api_mode=api_mode,
|
||||
)
|
||||
if client is None:
|
||||
logger.warning(
|
||||
"resolve_provider_client: azure-foundry requested but "
|
||||
"runtime resolution failed (run: hermes doctor for "
|
||||
"diagnostics)"
|
||||
)
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
"""Microsoft Entra ID adapter for Microsoft Foundry.
|
||||
|
||||
Provides keyless authentication for Microsoft Foundry deployments using the
|
||||
`azure-identity` SDK's `DefaultAzureCredential` chain (env service principal
|
||||
→ workload identity → managed identity → VS Code → Azure CLI → azd →
|
||||
PowerShell → broker).
|
||||
|
||||
Architecture mirrors `agent/bedrock_adapter.py`:
|
||||
|
||||
* Lazy import. `azure-identity` is only loaded when ``model.auth_mode =
|
||||
entra_id`` is selected. Users who stick with `AZURE_FOUNDRY_API_KEY`
|
||||
never pay the import cost.
|
||||
* SDK-callable contract. The public entry point ``build_token_provider``
|
||||
returns a zero-arg callable produced by ``get_bearer_token_provider`` —
|
||||
this is exactly the value Microsoft's documented sample plugs into
|
||||
``OpenAI(api_key=token_provider, base_url=...)``. The OpenAI SDK calls
|
||||
it before every request, so token refresh is transparent.
|
||||
* Three explicit consumer-side helpers (display / cache / http-bearer)
|
||||
rather than one generic "materialize" function — splitting them by
|
||||
purpose prevents accidental token-minting in logging paths or token
|
||||
leakage into cache keys / dashboard JSON.
|
||||
* No persisted JWT. ``azure-identity`` caches in-process and (where
|
||||
available) in the OS keychain or ``~/.IdentityService``. Hermes does
|
||||
not duplicate that storage in ``auth.json``.
|
||||
|
||||
Reference: https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id
|
||||
|
||||
Requires: ``azure-identity`` (optional dependency — only needed when
|
||||
``model.auth_mode = entra_id``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Microsoft-documented scope for Foundry inference auth. Both the new
|
||||
# Foundry portal and the legacy Azure OpenAI managed-identity docs use
|
||||
# this scope for ALL Foundry endpoint shapes (*.openai.azure.com,
|
||||
# *.services.ai.azure.com, *.ai.azure.com). The older control-plane
|
||||
# scope ``https://cognitiveservices.azure.com/.default`` is for ARM
|
||||
# resource management and is rejected for inference by newer
|
||||
# resources — users with that requirement override via
|
||||
# ``model.entra.scope`` in config.yaml.
|
||||
SCOPE_AI_AZURE_DEFAULT = "https://ai.azure.com/.default"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy SDK import — only loaded when the Entra path is actually used.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AZURE_IDENTITY_FEATURE = "provider.azure_identity"
|
||||
|
||||
|
||||
def has_azure_identity_installed() -> bool:
|
||||
"""Return True if `azure-identity` can be imported right now.
|
||||
|
||||
Cheap check — does not walk the credential chain.
|
||||
"""
|
||||
try:
|
||||
import azure.identity # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _require_azure_identity():
|
||||
"""Import ``azure.identity``, lazy-installing it if allowed.
|
||||
|
||||
Raises ``ImportError`` with a clear actionable message when the
|
||||
package is missing and lazy installs are disabled.
|
||||
"""
|
||||
try:
|
||||
import azure.identity as _ai
|
||||
return _ai
|
||||
except ImportError:
|
||||
try:
|
||||
from tools.lazy_deps import ensure, FeatureUnavailable
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The 'azure-identity' package is required for Azure AI "
|
||||
"Foundry Entra ID authentication. Install it with: "
|
||||
"pip install azure-identity"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
ensure(_AZURE_IDENTITY_FEATURE, prompt=False)
|
||||
except FeatureUnavailable as exc:
|
||||
raise ImportError(
|
||||
"The 'azure-identity' package is required for Azure AI "
|
||||
"Foundry Entra ID authentication. " + str(exc)
|
||||
) from exc
|
||||
|
||||
# Retry import after lazy install.
|
||||
import azure.identity as _ai # noqa: WPS440
|
||||
return _ai
|
||||
|
||||
|
||||
def reset_credential_cache() -> None:
|
||||
"""Clear the cached ``DefaultAzureCredential``. Used by tests and
|
||||
profile switches.
|
||||
|
||||
Defensive against tests that ``monkeypatch.setattr`` over
|
||||
``build_credential`` with a plain (non-lru-cached) function — those
|
||||
won't expose ``cache_clear()`` until pytest reverts the patch.
|
||||
"""
|
||||
cache_clear = getattr(build_credential, "cache_clear", None)
|
||||
if callable(cache_clear):
|
||||
cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-provider construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntraIdentityConfig:
|
||||
"""Serializable Entra ID config.
|
||||
|
||||
Captures the Hermes-managed Entra knobs we need outside Azure SDK
|
||||
environment configuration. Everything else
|
||||
(tenant ID, service principal secret, federated token file, sovereign
|
||||
cloud authority, etc.) flows through azure-identity's standard
|
||||
``AZURE_*`` env vars — see the Bedrock pattern in
|
||||
``hermes_cli/runtime_provider.py:1310-1377`` for the analogous
|
||||
"let the SDK read env" approach.
|
||||
|
||||
``scope`` is Microsoft's documented Foundry inference audience. Almost
|
||||
everyone uses the default; sovereign-cloud / non-standard tenants can
|
||||
override via ``model.entra.scope``. Identity selection (user-assigned
|
||||
managed identity, workload identity, service principal, tenant, authority)
|
||||
stays in the standard Azure SDK env vars such as ``AZURE_CLIENT_ID``.
|
||||
|
||||
``exclude_interactive_browser`` is kept as an internal constructor knob
|
||||
so probes stay non-interactive by default. It is not written by the setup
|
||||
wizard.
|
||||
|
||||
The dataclass is frozen so it's hashable for ``functools.lru_cache``
|
||||
keying, and serializable across multiprocessing boundaries (workers
|
||||
rebuild the credential inside their own process).
|
||||
"""
|
||||
|
||||
scope: str = SCOPE_AI_AZURE_DEFAULT
|
||||
exclude_interactive_browser: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
scope = str(self.scope or "").strip() or SCOPE_AI_AZURE_DEFAULT
|
||||
object.__setattr__(self, "scope", scope)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"scope": self.scope,
|
||||
"exclude_interactive_browser": self.exclude_interactive_browser,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]],
|
||||
*, default_scope: Optional[str] = None) -> "EntraIdentityConfig":
|
||||
data = data or {}
|
||||
scope = str(data.get("scope") or "").strip() or default_scope or SCOPE_AI_AZURE_DEFAULT
|
||||
exclude_browser = bool(data.get("exclude_interactive_browser", True))
|
||||
return cls(
|
||||
scope=scope,
|
||||
exclude_interactive_browser=exclude_browser,
|
||||
)
|
||||
|
||||
|
||||
def _build_default_credential(config: EntraIdentityConfig) -> Any:
|
||||
"""Construct a ``DefaultAzureCredential`` for ``config``.
|
||||
|
||||
Only Hermes-selected knobs are passed as kwargs. Everything else
|
||||
(tenant, service principal secret, federated token file, sovereign
|
||||
cloud authority, etc.) is read by ``azure-identity`` from the
|
||||
standard ``AZURE_*`` environment variables — see Microsoft's
|
||||
documented credential resolution chain. Users configure those in
|
||||
``~/.hermes/.env`` or the deployment environment.
|
||||
"""
|
||||
ai = _require_azure_identity()
|
||||
kwargs: Dict[str, Any] = {}
|
||||
# SDK default is True (browser excluded); only pass when the user
|
||||
# explicitly opts in to interactive browser auth.
|
||||
if not config.exclude_interactive_browser:
|
||||
kwargs["exclude_interactive_browser_credential"] = False
|
||||
return ai.DefaultAzureCredential(**kwargs)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def build_credential(config: EntraIdentityConfig) -> Any:
|
||||
"""Return the cached ``DefaultAzureCredential`` for ``config``.
|
||||
|
||||
Hermes processes use exactly one Entra config at a time (the
|
||||
``model.entra.*`` block in config.yaml drives every aux task,
|
||||
subagent, and credential probe in the session). ``maxsize=1`` is
|
||||
intentional: it reflects the actual usage pattern and keeps the
|
||||
cache trivially small.
|
||||
|
||||
``EntraIdentityConfig`` is a frozen dataclass, so it's hashable and
|
||||
safe as an LRU-cache key. ``functools.lru_cache`` is thread-safe in
|
||||
CPython.
|
||||
|
||||
If two distinct configs are ever passed (tests do this; production
|
||||
rarely), the LRU eviction handles it correctly — each call still
|
||||
returns a credential matching its config; only one is cached at a
|
||||
time. Use :func:`reset_credential_cache` to clear (e.g. in tests).
|
||||
"""
|
||||
return _build_default_credential(config)
|
||||
|
||||
|
||||
def build_token_provider(scope: Optional[str] = None,
|
||||
*,
|
||||
config: Optional[EntraIdentityConfig] = None,
|
||||
base_url: Optional[str] = None,
|
||||
exclude_interactive_browser: bool = True,
|
||||
) -> Callable[[], str]:
|
||||
"""Return a zero-arg callable that mints a fresh Entra bearer JWT.
|
||||
|
||||
The returned callable is exactly what Microsoft's documented Foundry
|
||||
sample expects::
|
||||
|
||||
from openai import OpenAI
|
||||
client = OpenAI(
|
||||
base_url="https://my-resource.openai.azure.com/openai/v1/",
|
||||
api_key=build_token_provider(),
|
||||
)
|
||||
|
||||
Scope resolution order:
|
||||
1. ``config.scope`` when a config object is supplied
|
||||
2. explicit ``scope`` kwarg
|
||||
3. ``SCOPE_AI_AZURE_DEFAULT`` (Microsoft's documented Foundry scope)
|
||||
|
||||
``base_url`` is unused today and kept for back-compat. Tenant /
|
||||
service-principal / sovereign-cloud configuration flows through
|
||||
``azure-identity``'s standard ``AZURE_*`` environment variables —
|
||||
see :func:`_build_default_credential` for the rationale.
|
||||
|
||||
NOT serializable across process boundaries. For multiprocessing
|
||||
workers, serialize the ``EntraIdentityConfig`` and rebuild the
|
||||
provider inside the worker.
|
||||
"""
|
||||
ai = _require_azure_identity()
|
||||
if config is None:
|
||||
config = EntraIdentityConfig(
|
||||
scope=scope or SCOPE_AI_AZURE_DEFAULT,
|
||||
exclude_interactive_browser=exclude_interactive_browser,
|
||||
)
|
||||
credential = build_credential(config)
|
||||
return ai.get_bearer_token_provider(credential, config.scope)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential probing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def has_azure_identity_credentials(scope: Optional[str] = None,
|
||||
*,
|
||||
config: Optional[EntraIdentityConfig] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
allow_install: bool = True,
|
||||
**overrides: Any) -> bool:
|
||||
"""Best-effort probe: can `DefaultAzureCredential` mint a token now?
|
||||
|
||||
Runs ``credential.get_token(scope)`` under a thread-based timeout so
|
||||
a slow token service can't hang the caller. Returns False on any
|
||||
error — never raises. Use for ``hermes doctor`` /
|
||||
``hermes auth status`` / wizard preflight.
|
||||
|
||||
``allow_install``: when True (default) and ``azure-identity`` is not
|
||||
importable, the adapter triggers the standard lazy-install path
|
||||
(subject to ``security.allow_lazy_installs``) before probing. Set
|
||||
False to make this strictly an "is installed?" check — used on hot
|
||||
paths like CLI startup where we never want pip to run.
|
||||
|
||||
NOT used by ``is_provider_configured()`` — that path is structural
|
||||
only (no token mint), so CLI startup doesn't pay this latency.
|
||||
"""
|
||||
if not has_azure_identity_installed():
|
||||
if not allow_install:
|
||||
return False
|
||||
try:
|
||||
_require_azure_identity()
|
||||
except ImportError as exc:
|
||||
logger.debug("azure-identity lazy install unavailable: %s", exc)
|
||||
return False
|
||||
if config is None:
|
||||
effective_scope = (scope or "").strip() or SCOPE_AI_AZURE_DEFAULT
|
||||
config = EntraIdentityConfig(scope=effective_scope, **overrides)
|
||||
|
||||
result = {"ok": False}
|
||||
|
||||
def _probe() -> None:
|
||||
try:
|
||||
credential = build_credential(config)
|
||||
tok = credential.get_token(config.scope)
|
||||
result["ok"] = bool(getattr(tok, "token", None))
|
||||
except Exception as exc:
|
||||
logger.debug("Entra credential probe failed: %s", exc)
|
||||
result["ok"] = False
|
||||
|
||||
thread = threading.Thread(target=_probe, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=max(0.01, timeout_seconds))
|
||||
if thread.is_alive():
|
||||
logger.debug("Entra token service probe timed out after %ss", timeout_seconds)
|
||||
return False
|
||||
return bool(result.get("ok"))
|
||||
|
||||
|
||||
def describe_active_credential(config: Optional[EntraIdentityConfig] = None,
|
||||
*,
|
||||
scope: Optional[str] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
allow_install: bool = True,
|
||||
**overrides: Any) -> Dict[str, Any]:
|
||||
"""Return diagnostic info about the active credential chain.
|
||||
|
||||
Best-effort: runs ``get_token()`` and inspects what came back.
|
||||
Designed for ``hermes doctor`` and the wizard preflight — never
|
||||
raises, returns ``{"ok": False, "error": ...}`` on failure.
|
||||
|
||||
``allow_install``: when True (default) and ``azure-identity`` is not
|
||||
importable, the adapter triggers the standard lazy-install path
|
||||
(subject to ``security.allow_lazy_installs``) before probing. The
|
||||
install failure is surfaced as the diagnostic error when it fails.
|
||||
Set False for hot CLI paths that should never trigger pip.
|
||||
|
||||
``azure-identity`` doesn't expose the winning inner credential as
|
||||
a public field, so we report a coarse picture (env vars present,
|
||||
token expiry, claims-derived tenant) rather than the credential
|
||||
class name. Users wanting the precise class can run with
|
||||
``AZURE_LOG_LEVEL=DEBUG``.
|
||||
"""
|
||||
info: Dict[str, Any] = {"ok": False}
|
||||
if not has_azure_identity_installed():
|
||||
if not allow_install:
|
||||
info["error"] = "azure-identity not installed"
|
||||
info["hint"] = (
|
||||
"pip install azure-identity (or rely on lazy install at "
|
||||
"first use)"
|
||||
)
|
||||
return info
|
||||
try:
|
||||
_require_azure_identity()
|
||||
except ImportError as exc:
|
||||
info["error"] = str(exc) or "azure-identity not installed"
|
||||
info["hint"] = (
|
||||
"pip install azure-identity manually, or enable lazy "
|
||||
"installs (security.allow_lazy_installs: true in "
|
||||
"config.yaml)."
|
||||
)
|
||||
return info
|
||||
|
||||
if config is None:
|
||||
effective_scope = (scope or "").strip() or SCOPE_AI_AZURE_DEFAULT
|
||||
config = EntraIdentityConfig(scope=effective_scope, **overrides)
|
||||
|
||||
info["scope"] = config.scope
|
||||
# Tenant / authority / service-principal config flow through the
|
||||
# standard ``AZURE_*`` env vars; surface them below.
|
||||
if os.environ.get("AZURE_TENANT_ID", "").strip():
|
||||
info["tenant_id_env"] = os.environ["AZURE_TENANT_ID"].strip()
|
||||
|
||||
# Surface which env-var sources are present without minting yet.
|
||||
env_sources = []
|
||||
if os.environ.get("AZURE_FEDERATED_TOKEN_FILE", "").strip():
|
||||
env_sources.append("WorkloadIdentityCredential (AZURE_FEDERATED_TOKEN_FILE)")
|
||||
if (os.environ.get("AZURE_CLIENT_ID", "").strip()
|
||||
and os.environ.get("AZURE_CLIENT_SECRET", "").strip()
|
||||
and os.environ.get("AZURE_TENANT_ID", "").strip()):
|
||||
env_sources.append("EnvironmentCredential (client secret)")
|
||||
if os.environ.get("IDENTITY_ENDPOINT", "").strip() or os.environ.get("MSI_ENDPOINT", "").strip():
|
||||
env_sources.append("ManagedIdentityCredential (IDENTITY_ENDPOINT)")
|
||||
info["env_sources"] = env_sources
|
||||
|
||||
# Now try minting.
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
def _probe() -> None:
|
||||
try:
|
||||
credential = build_credential(config)
|
||||
tok = credential.get_token(config.scope)
|
||||
result["token"] = tok
|
||||
except Exception as exc:
|
||||
result["error"] = str(exc)
|
||||
|
||||
thread = threading.Thread(target=_probe, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=max(0.01, timeout_seconds))
|
||||
if thread.is_alive():
|
||||
info["error"] = f"Token probe timed out after {timeout_seconds:.0f}s"
|
||||
info["hint"] = (
|
||||
"DefaultAzureCredential can be slow when the token service is unreachable "
|
||||
"or when az login state is stale. Try `az login` or set "
|
||||
"AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET."
|
||||
)
|
||||
return info
|
||||
|
||||
if "error" in result:
|
||||
info["error"] = result["error"]
|
||||
return info
|
||||
|
||||
token = result.get("token")
|
||||
if token is None:
|
||||
info["error"] = "credential chain exhausted"
|
||||
return info
|
||||
|
||||
info["ok"] = True
|
||||
info["expires_on"] = getattr(token, "expires_on", None)
|
||||
return info
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consumer-side helpers — split by purpose to prevent accidental token
|
||||
# minting in logging / cache-key / dashboard paths.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_token_provider(value: Any) -> bool:
|
||||
"""Return True when ``value`` is a callable Entra token provider.
|
||||
|
||||
Used at the seams where a consumer must decide between
|
||||
string-API-key semantics and bearer-callable semantics.
|
||||
"""
|
||||
return callable(value) and not isinstance(value, str)
|
||||
|
||||
|
||||
def materialize_bearer_for_http(value: Any) -> str:
|
||||
"""Return a fresh Bearer JWT for a manual HTTP request.
|
||||
|
||||
Only call this at sites that must construct an ``Authorization``
|
||||
header outside the OpenAI SDK (e.g. ``hermes_cli/azure_detect.py``).
|
||||
Calls the callable exactly once and returns the resulting token.
|
||||
|
||||
**Anthropic SDK integration:** the Anthropic Python SDK does not
|
||||
accept a ``Callable[[], str]`` for ``auth_token``. Instead,
|
||||
:func:`build_bearer_http_client` returns an ``httpx.Client`` whose
|
||||
request event hook calls this function and rewrites the
|
||||
``Authorization`` header per request — and that client is passed to
|
||||
the Anthropic SDK via ``http_client=...``. See
|
||||
:func:`agent.anthropic_adapter.build_anthropic_client` for the
|
||||
consumer.
|
||||
|
||||
Raises ``ValueError`` if ``value`` is not a callable token provider
|
||||
or non-empty string.
|
||||
"""
|
||||
if is_token_provider(value):
|
||||
token = value()
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token provider returned empty value")
|
||||
return token
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
raise ValueError("no usable api_key / token provider")
|
||||
|
||||
|
||||
def build_bearer_http_client(token_provider: Callable[[], str], **httpx_kwargs: Any) -> Any:
|
||||
"""Return an ``httpx.Client`` that mints a fresh Entra bearer JWT
|
||||
per outbound request.
|
||||
|
||||
The Anthropic SDK (≤ 0.86.0 at the time of writing) stores
|
||||
``api_key`` / ``auth_token`` as static strings and computes the
|
||||
``Authorization`` header at construction time. To get per-request
|
||||
token refresh (the Microsoft-recommended Foundry pattern for
|
||||
callable bearer providers), we install an httpx ``request`` event
|
||||
hook on a custom client and pass that client to the SDK via
|
||||
``http_client=...``. The hook:
|
||||
|
||||
1. Calls :func:`materialize_bearer_for_http` to mint a fresh JWT
|
||||
(azure-identity caches internally — this is cheap when the
|
||||
cached token is still valid).
|
||||
2. Strips any pre-set ``Authorization`` / ``api-key`` /
|
||||
``x-api-key`` headers the SDK may have added (avoids
|
||||
conflicting auth values).
|
||||
3. Sets ``Authorization: Bearer <fresh-jwt>``.
|
||||
|
||||
``token_provider`` must be a zero-arg callable returning a string —
|
||||
typically the result of :func:`build_token_provider`.
|
||||
|
||||
``httpx_kwargs`` are forwarded verbatim to ``httpx.Client(...)`` so
|
||||
callers can attach a ``timeout``, ``transport``, ``proxy``, etc.
|
||||
|
||||
Raises ``ImportError`` if ``httpx`` is not installed (it is a
|
||||
transitive dependency of both ``openai`` and ``anthropic`` SDKs, so
|
||||
in practice always available when this helper is reached).
|
||||
"""
|
||||
if not is_token_provider(token_provider):
|
||||
raise ValueError(
|
||||
"build_bearer_http_client requires a zero-arg callable "
|
||||
"token provider"
|
||||
)
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError as exc: # pragma: no cover — httpx ships with openai/anthropic
|
||||
raise ImportError(
|
||||
"httpx is required for Entra ID bearer auth on Microsoft Foundry "
|
||||
"Anthropic-style endpoints. It is normally a transitive "
|
||||
"dependency of the openai/anthropic SDKs."
|
||||
) from exc
|
||||
|
||||
def _inject_bearer(request: "httpx.Request") -> None:
|
||||
try:
|
||||
token = materialize_bearer_for_http(token_provider)
|
||||
except ValueError as exc:
|
||||
# Token provider failed (chain exhausted, token service unreachable,
|
||||
# az login expired, etc.). Strip any auth headers the SDK
|
||||
# may have set — including our own placeholder sentinel
|
||||
# ``entra-id-bearer-via-http-hook`` from
|
||||
# ``_build_anthropic_client_with_bearer_hook`` — so the
|
||||
# outbound request hits Azure with NO Authorization rather
|
||||
# than with the placeholder. Azure returns a clean 401
|
||||
# "missing auth" that is easier to diagnose than a 401
|
||||
# against the sentinel string, and the sentinel never
|
||||
# appears in upstream access logs.
|
||||
#
|
||||
# Log at WARNING (not DEBUG) so the misconfiguration is
|
||||
# visible at default log levels.
|
||||
logger.warning(
|
||||
"Bearer hook: Entra ID token provider returned empty (%s) "
|
||||
"— stripping Authorization headers. Azure will respond 401. "
|
||||
"Run `hermes doctor` or `az login` to recover.",
|
||||
exc,
|
||||
)
|
||||
for header_name in ("Authorization", "authorization", "Api-Key", "api-key", "X-Api-Key", "x-api-key"):
|
||||
request.headers.pop(header_name, None)
|
||||
return
|
||||
for header_name in ("Authorization", "authorization", "Api-Key", "api-key", "X-Api-Key", "x-api-key"):
|
||||
request.headers.pop(header_name, None)
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
return httpx.Client(
|
||||
event_hooks={"request": [_inject_bearer]},
|
||||
**httpx_kwargs,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EntraIdentityConfig",
|
||||
"SCOPE_AI_AZURE_DEFAULT",
|
||||
"build_bearer_http_client",
|
||||
"build_credential",
|
||||
"build_token_provider",
|
||||
"describe_active_credential",
|
||||
"has_azure_identity_credentials",
|
||||
"has_azure_identity_installed",
|
||||
"is_token_provider",
|
||||
"materialize_bearer_for_http",
|
||||
"reset_credential_cache",
|
||||
]
|
||||
@@ -112,6 +112,12 @@ _SKILL_REVIEW_PROMPT = (
|
||||
"skill that governs that task needs to carry the lesson.\n\n"
|
||||
"If you notice two existing skills that overlap, note it in your "
|
||||
"reply — the background curator handles consolidation at scale.\n\n"
|
||||
"Protected skills (DO NOT edit these):\n"
|
||||
" • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n"
|
||||
" • Hub-installed skills (installed via 'hermes skills install').\n"
|
||||
" • Pinned skills (marked via 'hermes curator pin').\n"
|
||||
"If the only skills that need updating are protected, say\n"
|
||||
"'Nothing to save.' and stop.\n\n"
|
||||
"Do NOT capture (these become persistent self-imposed constraints "
|
||||
"that bite you later when the environment changes):\n"
|
||||
" • Environment-dependent failures: missing binaries, fresh-install "
|
||||
@@ -189,6 +195,12 @@ _COMBINED_REVIEW_PROMPT = (
|
||||
"should carry user-preference lessons when relevant.\n\n"
|
||||
"If you notice overlapping existing skills, mention it — the "
|
||||
"background curator handles consolidation.\n\n"
|
||||
"Protected skills (DO NOT edit these):\n"
|
||||
" • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n"
|
||||
" • Hub-installed skills (installed via 'hermes skills install').\n"
|
||||
" • Pinned skills (marked via 'hermes curator pin').\n"
|
||||
"If the only skills that need updating are protected, say\n"
|
||||
"'Nothing to save.' and stop.\n\n"
|
||||
"Do NOT capture as skills (these become persistent self-imposed "
|
||||
"constraints that bite you later when the environment changes):\n"
|
||||
" • Environment-dependent failures: missing binaries, fresh-install "
|
||||
|
||||
@@ -291,10 +291,17 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
# in tool schemas (HTTP 400 "Invalid arguments passed to the model").
|
||||
# Most commonly hit when MCP-derived tools carry JSON Schema validation
|
||||
# keywords through. Strip them before building kwargs. See #27197.
|
||||
# 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.
|
||||
if is_xai_responses:
|
||||
try:
|
||||
from tools.schema_sanitizer import strip_pattern_and_format
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools_for_api, _ = strip_pattern_and_format(tools_for_api)
|
||||
tools_for_api, _ = strip_slash_enum(tools_for_api)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"%s⚠️ Failed to sanitize tool schemas for xAI: %s",
|
||||
@@ -866,9 +873,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
# the fallback activation drops to 128K even when config says 204800.
|
||||
if hasattr(agent, 'context_compressor') and agent.context_compressor:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
# ``agent.api_key`` may be callable (Entra ID); the
|
||||
# context-length resolver expects a string for live
|
||||
# probes. Foundry typically resolves via config/static
|
||||
# catalogs anyway, so coerce defensively.
|
||||
_fb_ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else ""
|
||||
fb_context_length = get_model_context_length(
|
||||
agent.model, base_url=agent.base_url,
|
||||
api_key=agent.api_key, provider=agent.provider,
|
||||
api_key=_fb_ctx_api_key, provider=agent.provider,
|
||||
config_context_length=getattr(agent, "_config_context_length", None),
|
||||
custom_providers=getattr(agent, "_custom_providers", None),
|
||||
)
|
||||
@@ -876,7 +888,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
model=agent.model,
|
||||
context_length=fb_context_length,
|
||||
base_url=agent.base_url,
|
||||
api_key=getattr(agent, "api_key", ""),
|
||||
api_key=getattr(agent, "api_key", ""), # callable preserved → call_llm
|
||||
provider=agent.provider,
|
||||
)
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ class ContextCompressor(ContextEngine):
|
||||
model: str,
|
||||
context_length: int,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
api_key: Any = "",
|
||||
provider: str = "",
|
||||
api_mode: str = "",
|
||||
) -> None:
|
||||
@@ -415,6 +415,7 @@ class ContextCompressor(ContextEngine):
|
||||
config_context_length: int | None = None,
|
||||
provider: str = "",
|
||||
api_mode: str = "",
|
||||
abort_on_summary_failure: bool = False,
|
||||
):
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
@@ -426,6 +427,11 @@ class ContextCompressor(ContextEngine):
|
||||
self.protect_last_n = protect_last_n
|
||||
self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80))
|
||||
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.
|
||||
self.abort_on_summary_failure = abort_on_summary_failure
|
||||
|
||||
self.context_length = get_model_context_length(
|
||||
model, base_url=base_url, api_key=api_key,
|
||||
@@ -478,6 +484,12 @@ class ContextCompressor(ContextEngine):
|
||||
# (gateway hygiene, /compress) can surface a visible warning.
|
||||
self._last_summary_dropped_count: int = 0
|
||||
self._last_summary_fallback_used: bool = False
|
||||
# When summary generation fails we now ABORT compression entirely
|
||||
# and return the original messages unchanged instead of dropping
|
||||
# the middle window with a static placeholder. Callers inspect
|
||||
# this flag to know "compression was attempted but aborted, freeze
|
||||
# the chat until the user manually retries via /compress".
|
||||
self._last_compress_aborted: bool = False
|
||||
# When a user-configured summary model fails and we recover by
|
||||
# retrying on the main model, record the failure so gateway /
|
||||
# CLI callers can still warn the user even though compression
|
||||
@@ -1371,7 +1383,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
# Main compression entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None) -> List[Dict[str, Any]]:
|
||||
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None, force: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Compress conversation messages by summarizing middle turns.
|
||||
|
||||
Algorithm:
|
||||
@@ -1389,6 +1401,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
provided, the summariser will prioritise preserving information
|
||||
related to this topic and be more aggressive about compressing
|
||||
everything else. Inspired by Claude Code's ``/compact``.
|
||||
force: If True, clear any active summary-failure cooldown before
|
||||
running so a manual ``/compress`` can retry immediately after
|
||||
an auto-compression abort. Auto-compress callers pass False.
|
||||
"""
|
||||
# Reset per-call summary failure state — callers inspect these fields
|
||||
# after compress() returns to decide whether to surface a warning.
|
||||
@@ -1397,6 +1412,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
self._last_summary_error = None
|
||||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compress_aborted = False
|
||||
|
||||
# Manual /compress (force=True) bypasses the failure cooldown so the
|
||||
# user can retry immediately after an auto-compress abort. Without
|
||||
# this, /compress would silently no-op for 30-60s after a failure.
|
||||
if force and self._summary_failure_cooldown_until > 0.0:
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
n_messages = len(messages)
|
||||
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
|
||||
_min_for_compress = self._protect_head_size(messages) + 3 + 1
|
||||
@@ -1472,6 +1494,32 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
# Phase 3: Generate structured summary
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
|
||||
# If summary generation failed, behavior splits on
|
||||
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
|
||||
# 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 /
|
||||
# _last_summary_dropped_count for gateway hygiene to
|
||||
# surface a warning.
|
||||
# Default is False (historical behavior).
|
||||
if not summary and self.abort_on_summary_failure:
|
||||
n_skipped = compress_end - compress_start
|
||||
self._last_summary_dropped_count = 0 # nothing actually dropped
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_compress_aborted = True
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Summary generation failed — aborting compression "
|
||||
"(compression.abort_on_summary_failure=true). "
|
||||
"%d message(s) preserved unchanged. Conversation is "
|
||||
"frozen until the next /compress or /new.",
|
||||
n_skipped,
|
||||
)
|
||||
return messages
|
||||
|
||||
# Phase 4: Assemble compressed message list
|
||||
compressed = []
|
||||
for i in range(compress_start):
|
||||
@@ -1486,7 +1534,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
compressed.append(msg)
|
||||
|
||||
# If LLM summary failed, insert a static fallback so the model
|
||||
# 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 not summary:
|
||||
if not self.quiet_mode:
|
||||
|
||||
@@ -103,7 +103,15 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
return
|
||||
|
||||
aux_base_url = str(getattr(client, "base_url", ""))
|
||||
aux_api_key = str(getattr(client, "api_key", ""))
|
||||
# ``client.api_key`` may be a callable (Azure Foundry Entra ID
|
||||
# bearer provider). The context-length resolver chain expects a
|
||||
# string, but it only needs a key for live catalogue probes
|
||||
# (provider model lists). For Entra clients the model-metadata
|
||||
# chain still resolves via models.dev + hardcoded family
|
||||
# fallbacks, which don't require auth — pass empty string rather
|
||||
# than minting a bearer JWT just to look up a context length.
|
||||
_raw_aux_key = getattr(client, "api_key", "")
|
||||
aux_api_key = "" if (callable(_raw_aux_key) and not isinstance(_raw_aux_key, str)) else str(_raw_aux_key or "")
|
||||
|
||||
aux_context = get_model_context_length(
|
||||
aux_model,
|
||||
@@ -248,6 +256,7 @@ def compress_context(
|
||||
approx_tokens: Optional[int] = None,
|
||||
task_id: str = "default",
|
||||
focus_topic: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Tuple[list, str]:
|
||||
"""Compress conversation context and split the session in SQLite.
|
||||
|
||||
@@ -260,10 +269,31 @@ def compress_context(
|
||||
focus_topic: Optional focus string for guided compression — the
|
||||
summariser will prioritise preserving information related to
|
||||
this topic. Inspired by Claude Code's ``/compact <focus>``.
|
||||
force: If True, bypass any active summary-failure cooldown. Set
|
||||
by the manual ``/compress`` slash command so users can retry
|
||||
immediately after an auto-compress abort. Auto-compress
|
||||
callers use the default ``False``.
|
||||
|
||||
Returns:
|
||||
``(compressed_messages, new_system_prompt)`` tuple.
|
||||
``(compressed_messages, new_system_prompt)`` tuple. When
|
||||
compression aborts (aux LLM failed to produce a usable summary),
|
||||
returns the original messages unchanged and the existing system
|
||||
prompt — the session is NOT rotated. Callers should detect the
|
||||
no-op via ``len(returned) == len(input)`` and stop the retry loop.
|
||||
"""
|
||||
# Lazy feasibility check — run the auxiliary-provider probe + context
|
||||
# length lookup just-in-time on the first compression attempt instead of
|
||||
# at AIAgent.__init__. Saves ~400ms cold off every short session that
|
||||
# never reaches the threshold (the vast majority of ``chat -q`` runs).
|
||||
# The check itself sets ``agent._compression_warning`` so the
|
||||
# status-callback replay machinery still emits the warning to the user
|
||||
# the first time it would matter.
|
||||
if not getattr(agent, "_compression_feasibility_checked", True):
|
||||
try:
|
||||
check_compression_model_feasibility(agent)
|
||||
finally:
|
||||
agent._compression_feasibility_checked = True
|
||||
|
||||
_pre_msg_count = len(messages)
|
||||
logger.info(
|
||||
"context compression started: session=%s messages=%d tokens=~%s model=%s focus=%r",
|
||||
@@ -283,12 +313,31 @@ def compress_context(
|
||||
pass
|
||||
|
||||
try:
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic)
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic, force=force)
|
||||
except TypeError:
|
||||
# Plugin context engine with strict signature that doesn't accept
|
||||
# focus_topic — fall back to calling without it.
|
||||
# focus_topic / force — fall back to calling without them.
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
|
||||
|
||||
# If compression aborted (aux LLM failed to produce a usable summary)
|
||||
# the compressor returns the input messages unchanged. Surface the
|
||||
# error to the user, skip the session-rotation work entirely (no
|
||||
# session has logically ended), and let auto-compress callers detect
|
||||
# the no-op via len(returned) == len(input).
|
||||
if getattr(agent.context_compressor, "_last_compress_aborted", False):
|
||||
_err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error"
|
||||
if getattr(agent, "_last_compression_summary_warning", None) != _err:
|
||||
agent._last_compression_summary_warning = _err
|
||||
agent._emit_warning(
|
||||
f"⚠ Compression aborted: {_err}. "
|
||||
"No messages were dropped — conversation continues unchanged. "
|
||||
"Run /compress to retry, or /new to start a fresh session."
|
||||
)
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
return messages, _existing_sp
|
||||
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
if summary_error:
|
||||
if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
|
||||
|
||||
@@ -1807,7 +1807,11 @@ def run_conversation(
|
||||
# that survives message/tool sanitization (#6843).
|
||||
_credential_sanitized = False
|
||||
_raw_key = getattr(agent, "api_key", None) or ""
|
||||
if _raw_key:
|
||||
# Entra ID bearer providers are callables — their
|
||||
# minted JWTs are always ASCII, so no sanitization
|
||||
# is needed (and ``_strip_non_ascii`` would crash
|
||||
# on a callable input).
|
||||
if _raw_key and isinstance(_raw_key, str):
|
||||
_clean_key = _strip_non_ascii(_raw_key)
|
||||
if _clean_key != _raw_key:
|
||||
agent.api_key = _clean_key
|
||||
@@ -2080,15 +2084,26 @@ def run_conversation(
|
||||
):
|
||||
anthropic_auth_retry_attempted = True
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
if agent._try_refresh_anthropic_client_credentials():
|
||||
print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
|
||||
continue
|
||||
# Credential refresh didn't help — show diagnostic info
|
||||
key = agent._anthropic_api_key
|
||||
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
|
||||
print(f"{agent.log_prefix}🔐 Anthropic 401 — authentication failed.")
|
||||
print(f"{agent.log_prefix} Auth method: {auth_method}")
|
||||
print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if key and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)")
|
||||
if is_token_provider(key):
|
||||
# Azure Foundry Entra ID — the bearer token is
|
||||
# minted per-request by an httpx event hook on a
|
||||
# custom http_client passed to the SDK. The 401
|
||||
# means Azure rejected the JWT (RBAC role missing,
|
||||
# az login expired, IMDS unreachable, etc.).
|
||||
print(f"{agent.log_prefix} Auth method: Microsoft Entra ID (httpx event hook)")
|
||||
print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or")
|
||||
print(f"{agent.log_prefix} `az login` if your developer session expired.")
|
||||
else:
|
||||
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
|
||||
print(f"{agent.log_prefix} Auth method: {auth_method}")
|
||||
print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)")
|
||||
print(f"{agent.log_prefix} Troubleshooting:")
|
||||
from hermes_constants import display_hermes_home as _dhh_fn
|
||||
_dhh = _dhh_fn()
|
||||
@@ -2317,7 +2332,7 @@ def run_conversation(
|
||||
# still recover. See _pool_may_recover_from_rate_limit
|
||||
# for the single-credential-pool and CloudCode-quota
|
||||
# exceptions. Fixes #11314 and #13636.
|
||||
pool_may_recover = _pool_may_recover_from_rate_limit(
|
||||
pool_may_recover = _ra()._pool_may_recover_from_rate_limit(
|
||||
agent._credential_pool,
|
||||
provider=agent.provider,
|
||||
base_url=getattr(agent, "base_url", None),
|
||||
|
||||
@@ -636,7 +636,10 @@ class CopilotACPClient:
|
||||
block_error = get_read_block_error(str(path))
|
||||
if block_error:
|
||||
raise PermissionError(block_error)
|
||||
content = path.read_text() if path.exists() else ""
|
||||
try:
|
||||
content = path.read_text()
|
||||
except FileNotFoundError:
|
||||
content = ""
|
||||
line = params.get("line")
|
||||
limit = params.get("limit")
|
||||
if isinstance(line, int) and line > 1:
|
||||
|
||||
+118
-1
@@ -10,7 +10,7 @@ import time
|
||||
import uuid
|
||||
import re
|
||||
from dataclasses import dataclass, fields, replace
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
@@ -797,6 +797,13 @@ class CredentialPool:
|
||||
except Exception as wexc:
|
||||
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
||||
elif self.provider == "openai-codex":
|
||||
# Adopt fresher tokens from auth.json before spending the
|
||||
# refresh_token — single-use tokens consumed by another Hermes
|
||||
# process sharing the same auth.json singleton would otherwise
|
||||
# trigger ``refresh_token_reused`` on the next POST.
|
||||
synced = self._sync_codex_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
refreshed = auth_mod.refresh_codex_oauth_pure(
|
||||
entry.access_token,
|
||||
entry.refresh_token,
|
||||
@@ -907,6 +914,116 @@ class CredentialPool:
|
||||
self._replace_entry(synced, updated)
|
||||
self._persist()
|
||||
return updated
|
||||
# Terminal error: auth.json has no newer tokens — the stored
|
||||
# refresh_token is dead. Clear it from auth.json so the next
|
||||
# session does not re-seed the same revoked credentials, and
|
||||
# remove all singleton-seeded (loopback_pkce) entries from the
|
||||
# in-memory pool. Mirrors the Nous quarantine path above.
|
||||
if auth_mod._is_terminal_xai_oauth_refresh_error(exc):
|
||||
logger.debug(
|
||||
"xAI OAuth refresh token is terminally invalid; clearing local token state"
|
||||
)
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "xai-oauth") or {}
|
||||
if isinstance(state, dict):
|
||||
tokens = state.get("tokens") or {}
|
||||
if isinstance(tokens, dict):
|
||||
store_refresh = str(tokens.get("refresh_token") or "").strip()
|
||||
entry_refresh = str(entry.refresh_token or "").strip()
|
||||
if not store_refresh or store_refresh == entry_refresh:
|
||||
tokens.pop("access_token", None)
|
||||
tokens.pop("refresh_token", None)
|
||||
state["tokens"] = tokens
|
||||
state["last_auth_error"] = {
|
||||
"provider": "xai-oauth",
|
||||
"code": getattr(exc, "code", "unknown"),
|
||||
"message": str(exc),
|
||||
"reason": "credential_pool_refresh_failure",
|
||||
"relogin_required": True,
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_save_provider_state(auth_store, "xai-oauth", state)
|
||||
_save_auth_store(auth_store)
|
||||
except Exception as clear_exc:
|
||||
logger.debug(
|
||||
"Failed to clear terminal xAI OAuth state: %s", clear_exc
|
||||
)
|
||||
self._entries = [
|
||||
item for item in self._entries
|
||||
if item.source != "loopback_pkce"
|
||||
]
|
||||
if self._current_id == entry.id:
|
||||
self._current_id = None
|
||||
self._persist()
|
||||
return None
|
||||
# For openai-codex: same race as xAI/nous — another Hermes process
|
||||
# may have consumed the refresh token between our proactive sync
|
||||
# and the HTTP call. Re-check auth.json and adopt the fresh tokens
|
||||
# if they have rotated since.
|
||||
if self.provider == "openai-codex":
|
||||
synced = self._sync_codex_entry_from_auth_store(entry)
|
||||
if synced.refresh_token != entry.refresh_token:
|
||||
logger.debug(
|
||||
"Codex OAuth refresh failed but auth.json has newer tokens — adopting"
|
||||
)
|
||||
updated = replace(
|
||||
synced,
|
||||
last_status=STATUS_OK,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
last_error_reason=None,
|
||||
last_error_message=None,
|
||||
last_error_reset_at=None,
|
||||
)
|
||||
self._replace_entry(synced, updated)
|
||||
self._persist()
|
||||
return updated
|
||||
# Terminal error: auth.json has no newer tokens — the stored
|
||||
# refresh_token is dead. Clear it from auth.json so the next
|
||||
# session does not re-seed the same revoked credentials, and
|
||||
# remove all singleton-seeded (device_code) entries from the
|
||||
# in-memory pool. Mirrors the xAI and Nous quarantine paths.
|
||||
if auth_mod._is_terminal_codex_oauth_refresh_error(exc):
|
||||
logger.debug(
|
||||
"Codex OAuth refresh token is terminally invalid; clearing local token state"
|
||||
)
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "openai-codex") or {}
|
||||
if isinstance(state, dict):
|
||||
tokens = state.get("tokens") or {}
|
||||
if isinstance(tokens, dict):
|
||||
store_refresh = str(tokens.get("refresh_token") or "").strip()
|
||||
entry_refresh = str(entry.refresh_token or "").strip()
|
||||
if not store_refresh or store_refresh == entry_refresh:
|
||||
tokens.pop("access_token", None)
|
||||
tokens.pop("refresh_token", None)
|
||||
state["tokens"] = tokens
|
||||
state["last_auth_error"] = {
|
||||
"provider": "openai-codex",
|
||||
"code": getattr(exc, "code", "unknown"),
|
||||
"message": str(exc),
|
||||
"reason": "credential_pool_refresh_failure",
|
||||
"relogin_required": True,
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_save_provider_state(auth_store, "openai-codex", state)
|
||||
_save_auth_store(auth_store)
|
||||
except Exception as clear_exc:
|
||||
logger.debug(
|
||||
"Failed to clear terminal Codex OAuth state: %s", clear_exc
|
||||
)
|
||||
self._entries = [
|
||||
item for item in self._entries
|
||||
if item.source != "device_code"
|
||||
]
|
||||
if self._current_id == entry.id:
|
||||
self._current_id = None
|
||||
self._persist()
|
||||
return None
|
||||
# For nous: another process may have consumed the refresh token
|
||||
# between our proactive sync and the HTTP call. Re-sync from
|
||||
# auth.json and adopt the fresh tokens if available.
|
||||
|
||||
@@ -510,6 +510,35 @@ def classify_api_error(
|
||||
should_compress=False,
|
||||
)
|
||||
|
||||
# xAI Grok subscription entitlement errors.
|
||||
#
|
||||
# xAI returns "You have either run out of available resources or do not
|
||||
# have an active Grok subscription" through two distinct code paths:
|
||||
#
|
||||
# • HTTP 403 — status_code is set; _classify_by_status (step 2) routes
|
||||
# it to FailoverReason.auth correctly, and _is_entitlement_failure
|
||||
# then prevents the credential-refresh loop.
|
||||
#
|
||||
# • SSE ``type=error`` frame — surfaced as _StreamErrorEvent with
|
||||
# status_code=None. _classify_by_status is skipped entirely, and
|
||||
# "grok subscription" / "out of available resources" appear in none
|
||||
# of the message-pattern lists below. Without this guard the error
|
||||
# falls through to FailoverReason.unknown (retryable=True), burning
|
||||
# max_retries before the agent stops — and _is_entitlement_failure
|
||||
# is never called because it only runs under FailoverReason.auth.
|
||||
#
|
||||
# Both X Premium+ and SuperGrok subscribers hit this path when their
|
||||
# subscription tier does not cover the requested model or feature.
|
||||
if (
|
||||
"do not have an active grok subscription" in error_msg
|
||||
or ("out of available resources" in error_msg and "grok" in error_msg)
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.auth,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# ── 2. HTTP status code classification ──────────────────────────
|
||||
|
||||
if status_code is not None:
|
||||
|
||||
+59
-5
@@ -91,10 +91,12 @@ class StreamingContextScrubber:
|
||||
def __init__(self) -> None:
|
||||
self._in_span: bool = False
|
||||
self._buf: str = ""
|
||||
self._at_block_boundary: bool = True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._in_span = False
|
||||
self._buf = ""
|
||||
self._at_block_boundary = True
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""Return the visible portion of ``text`` after scrubbing.
|
||||
@@ -121,19 +123,22 @@ class StreamingContextScrubber:
|
||||
buf = buf[idx + len(self._CLOSE_TAG):]
|
||||
self._in_span = False
|
||||
else:
|
||||
idx = buf.lower().find(self._OPEN_TAG)
|
||||
idx = self._find_boundary_open_tag(buf)
|
||||
if idx == -1:
|
||||
# No open tag — hold back a potential partial open tag
|
||||
held = self._max_partial_suffix(buf, self._OPEN_TAG)
|
||||
held = (
|
||||
self._max_pending_open_suffix(buf)
|
||||
or self._max_partial_suffix(buf, self._OPEN_TAG)
|
||||
)
|
||||
if held:
|
||||
out.append(buf[:-held])
|
||||
self._append_visible(out, buf[:-held])
|
||||
self._buf = buf[-held:]
|
||||
else:
|
||||
out.append(buf)
|
||||
self._append_visible(out, buf)
|
||||
return "".join(out)
|
||||
# Emit text before the tag, enter span
|
||||
if idx > 0:
|
||||
out.append(buf[:idx])
|
||||
self._append_visible(out, buf[:idx])
|
||||
buf = buf[idx + len(self._OPEN_TAG):]
|
||||
self._in_span = True
|
||||
|
||||
@@ -169,6 +174,55 @@ class StreamingContextScrubber:
|
||||
return i
|
||||
return 0
|
||||
|
||||
def _find_boundary_open_tag(self, buf: str) -> int:
|
||||
"""Find an opening fence only when it starts a block-like span."""
|
||||
buf_lower = buf.lower()
|
||||
search_start = 0
|
||||
while True:
|
||||
idx = buf_lower.find(self._OPEN_TAG, search_start)
|
||||
if idx == -1:
|
||||
return -1
|
||||
if self._is_block_boundary(buf, idx) and self._has_block_opener_suffix(buf, idx):
|
||||
return idx
|
||||
search_start = idx + 1
|
||||
|
||||
def _max_pending_open_suffix(self, buf: str) -> int:
|
||||
"""Hold a complete boundary tag until the following char confirms it."""
|
||||
if not buf.lower().endswith(self._OPEN_TAG):
|
||||
return 0
|
||||
idx = len(buf) - len(self._OPEN_TAG)
|
||||
if not self._is_block_boundary(buf, idx):
|
||||
return 0
|
||||
return len(self._OPEN_TAG)
|
||||
|
||||
def _has_block_opener_suffix(self, buf: str, idx: int) -> bool:
|
||||
after_idx = idx + len(self._OPEN_TAG)
|
||||
if after_idx >= len(buf):
|
||||
return False
|
||||
return buf[after_idx] in "\r\n"
|
||||
|
||||
def _is_block_boundary(self, buf: str, idx: int) -> bool:
|
||||
if idx == 0:
|
||||
return self._at_block_boundary
|
||||
preceding = buf[:idx]
|
||||
last_newline = preceding.rfind("\n")
|
||||
if last_newline == -1:
|
||||
return self._at_block_boundary and preceding.strip() == ""
|
||||
return preceding[last_newline + 1:].strip() == ""
|
||||
|
||||
def _append_visible(self, out: list[str], text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
out.append(text)
|
||||
self._update_block_boundary(text)
|
||||
|
||||
def _update_block_boundary(self, text: str) -> None:
|
||||
last_newline = text.rfind("\n")
|
||||
if last_newline != -1:
|
||||
self._at_block_boundary = text[last_newline + 1:].strip() == ""
|
||||
else:
|
||||
self._at_block_boundary = self._at_block_boundary and text.strip() == ""
|
||||
|
||||
|
||||
def build_memory_context_block(raw_context: str) -> str:
|
||||
"""Wrap prefetched memory in a fenced block with system note."""
|
||||
|
||||
@@ -206,7 +206,12 @@ KANBAN_GUIDANCE = (
|
||||
"files outside it unless the task explicitly asks.\n"
|
||||
"3. **Heartbeat on long operations.** Call `kanban_heartbeat(note=...)` "
|
||||
"every few minutes during long subprocesses (training, encoding, crawling). "
|
||||
"Skip heartbeats for short tasks.\n"
|
||||
"Skip heartbeats for short tasks. **If your task may run longer than 1 hour, "
|
||||
"you MUST call `kanban_heartbeat` at least once an hour** — the dispatcher "
|
||||
"reclaims tasks running past `kanban.dispatch_stale_timeout_seconds` "
|
||||
"(default 4 hours) when no heartbeat has arrived in the last hour. A "
|
||||
"reclaim re-queues the task as `ready` without penalty (no failure counter "
|
||||
"tick), but you lose your current run's progress.\n"
|
||||
"4. **Block on genuine ambiguity.** If you need a human decision you cannot "
|
||||
"infer (missing credentials, UX choice, paywalled source, peer output you "
|
||||
"need first), call `kanban_block(reason=\"...\")` and stop. Don't guess. "
|
||||
@@ -268,7 +273,7 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
|
||||
# Model name substrings that trigger 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")
|
||||
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek")
|
||||
|
||||
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
||||
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
||||
|
||||
+99
-35
@@ -103,6 +103,7 @@ _PREFIX_PATTERNS = [
|
||||
r"hsk-[A-Za-z0-9]{10,}", # Hindsight API key
|
||||
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
|
||||
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
|
||||
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
|
||||
]
|
||||
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||
@@ -320,6 +321,15 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
patterns when the text is known to be source code (e.g. MAX_TOKENS=***
|
||||
constants, "apiKey": "test" fixtures). Prefix patterns, auth headers,
|
||||
private keys, DB connstrings, JWTs, and URL secrets are still redacted.
|
||||
|
||||
Performance: each regex pattern is gated behind a cheap substring
|
||||
pre-check (e.g. ``"=" in text`` for ENV assignments, ``"://" in text``
|
||||
for URLs, ``"eyJ" in text`` for JWTs). On a typical hermes log line
|
||||
(no secrets) this drops the 13-pattern scan from ~5.6us to ~1.8us per
|
||||
record (-68%). The pre-checks are conservative — false positives
|
||||
still run the full regex, which then doesn't match. False negatives
|
||||
are impossible because every regex requires the gated substring to
|
||||
match.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
@@ -330,68 +340,122 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
if not (force or _REDACT_ENABLED):
|
||||
return text
|
||||
|
||||
# Known prefixes (sk-, ghp_, etc.)
|
||||
text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text)
|
||||
# Known prefixes (sk-, ghp_, etc.) — gate on substring presence
|
||||
if _has_known_prefix_substring(text):
|
||||
text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text)
|
||||
|
||||
# ENV assignments: OPENAI_API_KEY=*** (skip for code files — false positives)
|
||||
if not code_file:
|
||||
def _redact_env(m):
|
||||
name, quote, value = m.group(1), m.group(2), m.group(3)
|
||||
return f"{name}={quote}{_mask_token(value)}{quote}"
|
||||
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
||||
if "=" in text:
|
||||
def _redact_env(m):
|
||||
name, quote, value = m.group(1), m.group(2), m.group(3)
|
||||
return f"{name}={quote}{_mask_token(value)}{quote}"
|
||||
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
||||
|
||||
# JSON fields: "apiKey": "***" (skip for code files — false positives)
|
||||
def _redact_json(m):
|
||||
key, value = m.group(1), m.group(2)
|
||||
return f'{key}: "{_mask_token(value)}"'
|
||||
text = _JSON_FIELD_RE.sub(_redact_json, text)
|
||||
if ":" in text and '"' in text:
|
||||
def _redact_json(m):
|
||||
key, value = m.group(1), m.group(2)
|
||||
return f'{key}: "{_mask_token(value)}"'
|
||||
text = _JSON_FIELD_RE.sub(_redact_json, text)
|
||||
|
||||
# Authorization headers
|
||||
text = _AUTH_HEADER_RE.sub(
|
||||
lambda m: m.group(1) + _mask_token(m.group(2)),
|
||||
text,
|
||||
)
|
||||
# Authorization headers — _AUTH_HEADER_RE is "Authorization: Bearer ..."
|
||||
# case-insensitive, so "uthorization" is the cheapest substring gate that
|
||||
# covers both "Authorization" and "authorization" without a casefold().
|
||||
if "uthorization" in text or "UTHORIZATION" in text:
|
||||
text = _AUTH_HEADER_RE.sub(
|
||||
lambda m: m.group(1) + _mask_token(m.group(2)),
|
||||
text,
|
||||
)
|
||||
|
||||
# Telegram bot tokens
|
||||
def _redact_telegram(m):
|
||||
prefix = m.group(1) or ""
|
||||
digits = m.group(2)
|
||||
return f"{prefix}{digits}:***"
|
||||
text = _TELEGRAM_RE.sub(_redact_telegram, text)
|
||||
# Telegram bot tokens — pattern requires ":<token>" with digits prefix
|
||||
if ":" in text:
|
||||
def _redact_telegram(m):
|
||||
prefix = m.group(1) or ""
|
||||
digits = m.group(2)
|
||||
return f"{prefix}{digits}:***"
|
||||
text = _TELEGRAM_RE.sub(_redact_telegram, text)
|
||||
|
||||
# Private key blocks
|
||||
text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text)
|
||||
if "BEGIN" in text and "-----" in text:
|
||||
text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text)
|
||||
|
||||
# Database connection string passwords
|
||||
text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text)
|
||||
if "://" in text:
|
||||
text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text)
|
||||
|
||||
# JWT tokens (eyJ... — base64-encoded JSON headers)
|
||||
text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text)
|
||||
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.
|
||||
text = _redact_url_userinfo(text)
|
||||
if "://" in text:
|
||||
text = _redact_url_userinfo(text)
|
||||
|
||||
# URL query params containing opaque tokens (?access_token=…&code=…)
|
||||
text = _redact_url_query_params(text)
|
||||
# URL query params containing opaque tokens (?access_token=…&code=…)
|
||||
if "?" in text:
|
||||
text = _redact_url_query_params(text)
|
||||
|
||||
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
|
||||
text = _redact_form_body(text)
|
||||
if "&" in text and "=" in text:
|
||||
text = _redact_form_body(text)
|
||||
|
||||
# Discord user/role mentions (<@snowflake_id>)
|
||||
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
|
||||
if "<@" in text:
|
||||
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
|
||||
|
||||
# E.164 phone numbers (Signal, WhatsApp)
|
||||
def _redact_phone(m):
|
||||
phone = m.group(1)
|
||||
if len(phone) <= 8:
|
||||
return phone[:2] + "****" + phone[-2:]
|
||||
return phone[:4] + "****" + phone[-4:]
|
||||
text = _SIGNAL_PHONE_RE.sub(_redact_phone, text)
|
||||
if "+" in text:
|
||||
def _redact_phone(m):
|
||||
phone = m.group(1)
|
||||
if len(phone) <= 8:
|
||||
return phone[:2] + "****" + phone[-2:]
|
||||
return phone[:4] + "****" + phone[-4:]
|
||||
text = _SIGNAL_PHONE_RE.sub(_redact_phone, text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# Substrings used to gate ``_PREFIX_RE`` execution. If none of these appear in
|
||||
# the input string, the prefix regex cannot match anything, so we skip it.
|
||||
# False positives are fine (they just run the regex, which then matches
|
||||
# nothing) — the bound is "no false negatives" and that holds because every
|
||||
# pattern in ``_PREFIX_PATTERNS`` has at least one of these as a literal
|
||||
# substring of its leading characters.
|
||||
#
|
||||
# Derived automatically from ``_PREFIX_PATTERNS`` at module load time so a
|
||||
# future PR that adds a new prefix to the regex list can't silently break
|
||||
# the screen.
|
||||
|
||||
def _extract_literal_prefix(pattern: str) -> str:
|
||||
"""Return the leading literal characters of a regex pattern.
|
||||
|
||||
Stops at the first regex metacharacter (``[``, ``(``, ``\\``, ``.``,
|
||||
``?``, ``*``, ``+``, ``|``, ``{``, ``^``, ``$``). Returns the literal
|
||||
that any match of the pattern MUST contain as a substring, so the
|
||||
pre-screen never produces false negatives.
|
||||
"""
|
||||
meta = "[(\\.?*+|{^$"
|
||||
for i, ch in enumerate(pattern):
|
||||
if ch in meta:
|
||||
return pattern[:i]
|
||||
return pattern
|
||||
|
||||
|
||||
_PREFIX_SUBSTRINGS = tuple(
|
||||
_extract_literal_prefix(p) for p in _PREFIX_PATTERNS
|
||||
)
|
||||
|
||||
|
||||
def _has_known_prefix_substring(text: str) -> bool:
|
||||
"""Return True if ``text`` contains any known credential prefix substring.
|
||||
|
||||
Used as a cheap pre-check before invoking the expensive ``_PREFIX_RE``.
|
||||
"""
|
||||
return any(p in text for p in _PREFIX_SUBSTRINGS)
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""Log formatter that redacts secrets from all log messages."""
|
||||
|
||||
|
||||
@@ -632,7 +632,10 @@ def _locked_update_approvals() -> Iterator[Dict[str, Any]]:
|
||||
yield data
|
||||
save_allowlist(data)
|
||||
finally:
|
||||
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
|
||||
try:
|
||||
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
|
||||
def _prompt_and_record(
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Skill bundles — aliases that load multiple skills under one slash command.
|
||||
|
||||
A skill bundle is a small YAML file that names a set of skills to load
|
||||
together. Invoking ``/<bundle-name>`` from the CLI or gateway loads every
|
||||
referenced skill's full content into a single user message, the same way
|
||||
``/<skill-name>`` does — but for N skills at once.
|
||||
|
||||
Storage
|
||||
-------
|
||||
Bundles live in ``~/.hermes/skill-bundles/*.yaml`` (and the equivalent
|
||||
profile-aware directory under ``HERMES_HOME``). Each file looks like::
|
||||
|
||||
name: backend-dev
|
||||
description: Backend feature work — code review, testing, PR workflow.
|
||||
skills:
|
||||
- github-code-review
|
||||
- test-driven-development
|
||||
- github-pr-workflow
|
||||
instruction: |
|
||||
Optional extra guidance to inject above the skill bodies.
|
||||
|
||||
The file's stem is treated as a fallback name when ``name:`` is absent, so
|
||||
dropping a YAML into the directory is enough to register a new bundle.
|
||||
|
||||
Conflict resolution
|
||||
-------------------
|
||||
If a bundle and a skill share the same slash name, the bundle wins. The
|
||||
slash command dispatch checks bundles first, then falls back to skills.
|
||||
This is the intended behavior — a user who names a bundle ``research``
|
||||
explicitly wants ``/research`` to mean their bundle, not whatever skill
|
||||
happens to share the slug.
|
||||
|
||||
Public API
|
||||
----------
|
||||
- :func:`get_skill_bundles` — return ``{"/slug": bundle_info}``
|
||||
- :func:`resolve_bundle_command_key` — map a user-typed command to its slug
|
||||
- :func:`build_bundle_invocation_message` — produce the full user message
|
||||
- :func:`reload_bundles` — re-scan disk and return a diff
|
||||
- :func:`list_bundles` — return rich info for display (``hermes bundles``)
|
||||
- :func:`save_bundle` / :func:`delete_bundle` — file-level operations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Slug normalization — matches agent/skill_commands.py so a bundle and a
|
||||
# skill called "Foo Bar" both resolve to "/foo-bar".
|
||||
_BUNDLE_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
||||
_BUNDLE_MULTI_HYPHEN = re.compile(r"-{2,}")
|
||||
|
||||
_bundles_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_bundles_cache_mtime: Optional[float] = None
|
||||
|
||||
|
||||
def _bundles_dir() -> Path:
|
||||
"""Return the canonical bundles directory under HERMES_HOME.
|
||||
|
||||
Honors ``HERMES_BUNDLES_DIR`` for tests; falls back to
|
||||
``<HERMES_HOME>/skill-bundles``.
|
||||
"""
|
||||
override = os.environ.get("HERMES_BUNDLES_DIR")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return get_hermes_home() / "skill-bundles"
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
cmd = name.lower().replace(" ", "-").replace("_", "-")
|
||||
cmd = _BUNDLE_INVALID_CHARS.sub("", cmd)
|
||||
cmd = _BUNDLE_MULTI_HYPHEN.sub("-", cmd).strip("-")
|
||||
return cmd
|
||||
|
||||
|
||||
def _iter_bundle_files() -> List[Path]:
|
||||
base = _bundles_dir()
|
||||
if not base.exists():
|
||||
return []
|
||||
files: List[Path] = []
|
||||
for ext in ("*.yaml", "*.yml"):
|
||||
files.extend(sorted(base.glob(ext)))
|
||||
return files
|
||||
|
||||
|
||||
def _max_mtime(files: List[Path]) -> float:
|
||||
"""Highest mtime across the bundle files plus the dir itself.
|
||||
|
||||
Watching the directory mtime catches deletions; watching individual
|
||||
files catches edits. Together they're a cheap freshness check.
|
||||
"""
|
||||
base = _bundles_dir()
|
||||
mtimes = []
|
||||
if base.exists():
|
||||
try:
|
||||
mtimes.append(base.stat().st_mtime)
|
||||
except OSError:
|
||||
pass
|
||||
for f in files:
|
||||
try:
|
||||
mtimes.append(f.stat().st_mtime)
|
||||
except OSError:
|
||||
continue
|
||||
return max(mtimes) if mtimes else 0.0
|
||||
|
||||
|
||||
def _load_bundle_file(path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Parse a single bundle YAML file. Returns ``None`` on any error.
|
||||
|
||||
Errors are logged at WARNING level. We don't raise — a broken bundle
|
||||
shouldn't take down slash command discovery.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.warning("Could not read bundle %s: %s", path, exc)
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as exc:
|
||||
logger.warning("Invalid YAML in bundle %s: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Bundle %s is not a mapping; skipping", path)
|
||||
return None
|
||||
|
||||
name = str(data.get("name") or path.stem).strip()
|
||||
if not name:
|
||||
logger.warning("Bundle %s has no name; skipping", path)
|
||||
return None
|
||||
|
||||
skills = data.get("skills") or []
|
||||
if not isinstance(skills, list) or not skills:
|
||||
logger.warning("Bundle %s has no skills list; skipping", path)
|
||||
return None
|
||||
skills = [str(s).strip() for s in skills if str(s).strip()]
|
||||
if not skills:
|
||||
logger.warning("Bundle %s has empty skills list; skipping", path)
|
||||
return None
|
||||
|
||||
description = str(data.get("description") or "").strip()
|
||||
instruction = str(data.get("instruction") or "").strip()
|
||||
|
||||
slug = _slugify(name)
|
||||
if not slug:
|
||||
logger.warning("Bundle %s yielded empty slug; skipping", path)
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": description or f"Load {len(skills)} skills as a bundle",
|
||||
"skills": skills,
|
||||
"instruction": instruction,
|
||||
"path": str(path),
|
||||
}
|
||||
|
||||
|
||||
def scan_bundles() -> Dict[str, Dict[str, Any]]:
|
||||
"""Scan the bundles directory and rebuild the cache.
|
||||
|
||||
Returns the same mapping as :func:`get_skill_bundles` — ``"/slug"`` →
|
||||
bundle info dict. Later bundles with a duplicate slug are skipped with
|
||||
a warning (first wins, alphabetical order).
|
||||
"""
|
||||
global _bundles_cache, _bundles_cache_mtime
|
||||
files = _iter_bundle_files()
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for f in files:
|
||||
info = _load_bundle_file(f)
|
||||
if not info:
|
||||
continue
|
||||
key = f"/{info['slug']}"
|
||||
if key in out:
|
||||
logger.warning(
|
||||
"Duplicate bundle slug %s from %s; keeping %s",
|
||||
key, f, out[key]["path"],
|
||||
)
|
||||
continue
|
||||
out[key] = info
|
||||
_bundles_cache = out
|
||||
_bundles_cache_mtime = _max_mtime(files)
|
||||
return out
|
||||
|
||||
|
||||
def get_skill_bundles() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return the current bundle mapping, rescanning when disk changed.
|
||||
|
||||
Cheap to call repeatedly: only rescans when the bundles directory or
|
||||
any bundle file's mtime is newer than the cached snapshot.
|
||||
"""
|
||||
files = _iter_bundle_files()
|
||||
current_mtime = _max_mtime(files)
|
||||
if not _bundles_cache or _bundles_cache_mtime != current_mtime:
|
||||
scan_bundles()
|
||||
return _bundles_cache
|
||||
|
||||
|
||||
def resolve_bundle_command_key(command: str) -> Optional[str]:
|
||||
"""Resolve a user-typed command to its canonical bundle slash key.
|
||||
|
||||
Hyphens and underscores are treated interchangeably to mirror the
|
||||
skill-command behavior (Telegram converts hyphens to underscores in
|
||||
bot command names).
|
||||
"""
|
||||
if not command:
|
||||
return None
|
||||
cmd_key = f"/{command.replace('_', '-')}"
|
||||
return cmd_key if cmd_key in get_skill_bundles() else None
|
||||
|
||||
|
||||
def reload_bundles() -> Dict[str, Any]:
|
||||
"""Re-scan the bundles directory and return a diff.
|
||||
|
||||
Mirrors :func:`agent.skill_commands.reload_skills` so callers can use
|
||||
the same display logic. Returns a dict with ``added``, ``removed``,
|
||||
``unchanged``, and ``total`` keys.
|
||||
"""
|
||||
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
|
||||
return {k.lstrip("/"): (v or {}).get("description", "") for k, v in cmds.items()}
|
||||
|
||||
before = _snapshot(_bundles_cache)
|
||||
new = scan_bundles()
|
||||
after = _snapshot(new)
|
||||
|
||||
added_names = sorted(set(after) - set(before))
|
||||
removed_names = sorted(set(before) - set(after))
|
||||
unchanged = sorted(set(after) & set(before))
|
||||
|
||||
return {
|
||||
"added": [{"name": n, "description": after[n]} for n in added_names],
|
||||
"removed": [{"name": n, "description": before[n]} for n in removed_names],
|
||||
"unchanged": unchanged,
|
||||
"total": len(after),
|
||||
}
|
||||
|
||||
|
||||
def list_bundles() -> List[Dict[str, Any]]:
|
||||
"""Return a sorted list of bundle info dicts for display."""
|
||||
bundles = get_skill_bundles()
|
||||
return sorted(bundles.values(), key=lambda b: b["slug"])
|
||||
|
||||
|
||||
def build_bundle_invocation_message(
|
||||
cmd_key: str,
|
||||
user_instruction: str = "",
|
||||
task_id: str | None = None,
|
||||
) -> Optional[Tuple[str, List[str], List[str]]]:
|
||||
"""Build the user message content for a bundle slash command invocation.
|
||||
|
||||
Returns ``(message, loaded_skill_names, missing_skill_names)`` or
|
||||
``None`` if the bundle wasn't found.
|
||||
|
||||
A bundle that references skills the user doesn't have installed still
|
||||
loads — the agent gets a note about which ones were skipped. This is
|
||||
the same forgiving stance ``build_preloaded_skills_prompt`` uses for
|
||||
``-s`` CLI preloading.
|
||||
"""
|
||||
bundles = get_skill_bundles()
|
||||
info = bundles.get(cmd_key)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
# Late import to avoid pulling tools/* at module import time and to
|
||||
# keep skill_bundles cheap to import in test environments.
|
||||
from agent.skill_commands import _load_skill_payload, _build_skill_message
|
||||
|
||||
loaded_names: List[str] = []
|
||||
missing: List[str] = []
|
||||
skill_blocks: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
bundle_name = info["name"]
|
||||
skills = info["skills"]
|
||||
extra_instruction = info.get("instruction") or ""
|
||||
|
||||
for skill_id in skills:
|
||||
identifier = (skill_id or "").strip()
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
|
||||
loaded = _load_skill_payload(identifier, task_id=task_id)
|
||||
if not loaded:
|
||||
missing.append(identifier)
|
||||
continue
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
|
||||
try:
|
||||
from tools.skill_usage import bump_use
|
||||
bump_use(skill_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
activation_note = (
|
||||
f'[Loaded as part of the "{bundle_name}" skill bundle.]'
|
||||
)
|
||||
skill_blocks.append(
|
||||
_build_skill_message(
|
||||
loaded_skill,
|
||||
skill_dir,
|
||||
activation_note,
|
||||
session_id=task_id,
|
||||
)
|
||||
)
|
||||
loaded_names.append(skill_name)
|
||||
|
||||
if not skill_blocks:
|
||||
return None
|
||||
|
||||
# Header — tells the agent this is a bundle, lists the skills, and
|
||||
# provides any author-supplied instruction.
|
||||
header_lines = [
|
||||
f'[IMPORTANT: The user has invoked the "{bundle_name}" skill bundle, '
|
||||
f"loading {len(loaded_names)} skills together. Treat every skill below "
|
||||
"as active guidance for this turn.]",
|
||||
"",
|
||||
f"Bundle: {bundle_name}",
|
||||
f"Skills loaded: {', '.join(loaded_names)}",
|
||||
]
|
||||
if missing:
|
||||
header_lines.append(f"Skills missing (skipped): {', '.join(missing)}")
|
||||
if extra_instruction:
|
||||
header_lines.extend(["", f"Bundle instruction: {extra_instruction}"])
|
||||
if user_instruction:
|
||||
header_lines.extend(
|
||||
["", f"User instruction: {user_instruction}"]
|
||||
)
|
||||
|
||||
header = "\n".join(header_lines)
|
||||
return ("\n\n".join([header, *skill_blocks]), loaded_names, missing)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-level CRUD helpers — used by `hermes bundles` CLI subcommand.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bundle_path_for(name: str) -> Path:
|
||||
"""Return the canonical filesystem path for a bundle name."""
|
||||
slug = _slugify(name)
|
||||
if not slug:
|
||||
raise ValueError(f"Bundle name {name!r} normalizes to an empty slug")
|
||||
return _bundles_dir() / f"{slug}.yaml"
|
||||
|
||||
|
||||
def save_bundle(
|
||||
name: str,
|
||||
skills: List[str],
|
||||
description: str = "",
|
||||
instruction: str = "",
|
||||
overwrite: bool = False,
|
||||
) -> Path:
|
||||
"""Write a bundle to disk and invalidate the cache.
|
||||
|
||||
Raises ``FileExistsError`` if the target exists and ``overwrite`` is
|
||||
False. Raises ``ValueError`` if the inputs are unusable.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Bundle name is required")
|
||||
cleaned_skills = [str(s).strip() for s in skills if str(s).strip()]
|
||||
if not cleaned_skills:
|
||||
raise ValueError("Bundle must reference at least one skill")
|
||||
|
||||
path = bundle_path_for(name)
|
||||
if path.exists() and not overwrite:
|
||||
raise FileExistsError(f"Bundle already exists at {path}")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload: Dict[str, Any] = {"name": name, "skills": cleaned_skills}
|
||||
if description:
|
||||
payload["description"] = description
|
||||
if instruction:
|
||||
payload["instruction"] = instruction
|
||||
|
||||
path.write_text(
|
||||
yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
scan_bundles() # refresh cache
|
||||
return path
|
||||
|
||||
|
||||
def delete_bundle(name: str) -> Path:
|
||||
"""Delete a bundle by name. Returns the deleted path.
|
||||
|
||||
Raises ``FileNotFoundError`` if the bundle doesn't exist.
|
||||
"""
|
||||
path = bundle_path_for(name)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"No bundle at {path}")
|
||||
path.unlink()
|
||||
scan_bundles()
|
||||
return path
|
||||
|
||||
|
||||
def get_bundle(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a bundle by name (slug-normalized)."""
|
||||
slug = _slugify(name)
|
||||
return get_skill_bundles().get(f"/{slug}")
|
||||
@@ -79,6 +79,14 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||
except FileNotFoundError:
|
||||
return "[inline-shell error: bash not found]"
|
||||
except RuntimeError as exc:
|
||||
# tests/conftest.py installs a live-system guard that blocks real
|
||||
# os.kill on out-of-tree PIDs. subprocess.run(timeout=...) may trip
|
||||
# that guard while trying to clean up the timed-out shell; treat that
|
||||
# as the same timeout outcome instead of surfacing the guard error.
|
||||
if "live-system guard: blocked os.kill" in str(exc):
|
||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||
return f"[inline-shell error: {exc}]"
|
||||
except Exception as exc:
|
||||
return f"[inline-shell error: {exc}]"
|
||||
|
||||
|
||||
@@ -111,8 +111,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# Kanban worker/orchestrator lifecycle — only present when the
|
||||
# dispatcher spawned this process (kanban_show check_fn gates on
|
||||
# HERMES_KANBAN_TASK env var). Normal chat sessions never see
|
||||
# this block.
|
||||
if "kanban_show" in agent.valid_tool_names:
|
||||
# this block. Resolved once at __init__ (see _kanban_worker_guidance).
|
||||
_kanban_guidance = getattr(agent, "_kanban_worker_guidance", None)
|
||||
if _kanban_guidance:
|
||||
tool_guidance.append(_kanban_guidance)
|
||||
elif _kanban_guidance is None and "kanban_show" in agent.valid_tool_names:
|
||||
# Fallback for code paths that bypass agent_init (rare).
|
||||
tool_guidance.append(KANBAN_GUIDANCE)
|
||||
if tool_guidance:
|
||||
stable_parts.append(" ".join(tool_guidance))
|
||||
|
||||
@@ -317,6 +317,19 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return msg
|
||||
|
||||
|
||||
def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict:
|
||||
"""Build a tool-result message dict with both the OpenAI-format ``name``
|
||||
field (required by the wire format and provider adapters) and the internal
|
||||
``tool_name`` field (written to the session DB messages table)."""
|
||||
return {
|
||||
"role": "tool",
|
||||
"name": name,
|
||||
"tool_name": name,
|
||||
"content": content,
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_NEVER_PARALLEL_TOOLS",
|
||||
"_PARALLEL_SAFE_TOOLS",
|
||||
@@ -333,4 +346,5 @@ __all__ = [
|
||||
"_extract_file_mutation_targets",
|
||||
"_extract_error_preview",
|
||||
"_trajectory_normalize_msg",
|
||||
"make_tool_result_message",
|
||||
]
|
||||
|
||||
+13
-27
@@ -35,6 +35,7 @@ from agent.tool_dispatch_helpers import (
|
||||
_is_multimodal_tool_result,
|
||||
_multimodal_text_summary,
|
||||
_append_subdir_hint_to_multimodal,
|
||||
make_tool_result_message,
|
||||
)
|
||||
from tools.terminal_tool import (
|
||||
_get_approval_callback,
|
||||
@@ -74,12 +75,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if agent._interrupt_requested:
|
||||
print(f"{agent.log_prefix}⚡ Interrupt: skipping {num_tools} tool call(s)")
|
||||
for tc in tool_calls:
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"name": tc.function.name,
|
||||
"content": f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
||||
"tool_call_id": tc.id,
|
||||
})
|
||||
messages.append(make_tool_result_message(
|
||||
tc.function.name,
|
||||
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
||||
tc.id,
|
||||
))
|
||||
return
|
||||
|
||||
# ── Parse args + pre-execution bookkeeping ───────────────────────
|
||||
@@ -443,13 +443,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# image tool result never poisons canonical session history.
|
||||
# String results pass through unchanged.
|
||||
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
|
||||
tool_msg = {
|
||||
"role": "tool",
|
||||
"name": name,
|
||||
"content": _tool_content,
|
||||
"tool_call_id": tc.id,
|
||||
}
|
||||
messages.append(tool_msg)
|
||||
messages.append(make_tool_result_message(name, _tool_content, tc.id))
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Same as the sequential path: drain between each collected
|
||||
@@ -864,13 +858,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# Unwrap _multimodal dicts to an OpenAI-style content list
|
||||
# (see parallel path for rationale). String results pass through.
|
||||
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
|
||||
tool_msg = {
|
||||
"role": "tool",
|
||||
"name": function_name,
|
||||
"content": _tool_content,
|
||||
"tool_call_id": tool_call.id
|
||||
}
|
||||
messages.append(tool_msg)
|
||||
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Drain pending steer BETWEEN individual tool calls so the
|
||||
@@ -892,13 +880,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {remaining} remaining tool call(s)", force=True)
|
||||
for skipped_tc in assistant_message.tool_calls[i:]:
|
||||
skipped_name = skipped_tc.function.name
|
||||
skip_msg = {
|
||||
"role": "tool",
|
||||
"name": skipped_name,
|
||||
"content": f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
|
||||
"tool_call_id": skipped_tc.id
|
||||
}
|
||||
messages.append(skip_msg)
|
||||
messages.append(make_tool_result_message(
|
||||
skipped_name,
|
||||
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
|
||||
skipped_tc.id,
|
||||
))
|
||||
break
|
||||
|
||||
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):
|
||||
|
||||
@@ -336,10 +336,7 @@ class ToolCallGuardrailController:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="same_tool_failure_warning",
|
||||
message=(
|
||||
f"{tool_name} has failed {same_count} times this turn. "
|
||||
"This looks like a loop; change approach before retrying."
|
||||
),
|
||||
message=_tool_failure_recovery_hint(tool_name, same_count),
|
||||
tool_name=tool_name,
|
||||
count=same_count,
|
||||
signature=signature,
|
||||
@@ -406,6 +403,26 @@ def append_toolguard_guidance(result: str, decision: ToolGuardrailDecision) -> s
|
||||
return (result or "") + suffix
|
||||
|
||||
|
||||
def _tool_failure_recovery_hint(tool_name: str, count: int) -> str:
|
||||
"""Action-oriented guidance for recovering from repeated tool failures."""
|
||||
common = (
|
||||
f"{tool_name} has failed {count} times this turn. This looks like a loop. "
|
||||
"Do not switch to text-only replies; keep using tools, but diagnose before retrying. "
|
||||
"First inspect the latest error/output and verify your assumptions. "
|
||||
)
|
||||
if tool_name == "terminal":
|
||||
return common + (
|
||||
"For terminal failures, run a small diagnostic such as `pwd && ls -la` "
|
||||
"in the same tool, then try an absolute path, a simpler command, a different "
|
||||
"working directory, or a different tool such as read_file/write_file/patch."
|
||||
)
|
||||
return common + (
|
||||
"Try different arguments, a narrower query/path, an absolute path when relevant, "
|
||||
"or a different tool that can make progress. If the blocker is external, report "
|
||||
"the blocker after one diagnostic attempt instead of repeating the same failing path."
|
||||
)
|
||||
|
||||
|
||||
def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||||
return args if isinstance(args, Mapping) else {}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>Hermes Agent - Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+497
-332
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,6 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"sync-assets": "node scripts/sync-assets.cjs",
|
||||
"predev": "npm run sync-assets",
|
||||
"prebuild": "npm run sync-assets",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
@@ -14,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nous-research/ui": "^0.10.0",
|
||||
"@nous-research/ui": "0.14.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
@@ -25,9 +22,11 @@
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"gsap": "^3.15.0",
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy font and asset folders from @nous-research/ui into public/ for Vite.
|
||||
*
|
||||
* Locates @nous-research/ui by walking up from this script looking for
|
||||
* node_modules/@nous-research/ui — works whether the dep is co-located
|
||||
* (non-workspace layout) or hoisted to the repo root (npm workspaces).
|
||||
*/
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const DASHBOARD_ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
function locateUiPackage() {
|
||||
let dir = DASHBOARD_ROOT
|
||||
const { root } = path.parse(dir)
|
||||
while (true) {
|
||||
const candidate = path.join(dir, 'node_modules', '@nous-research', 'ui')
|
||||
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
||||
return candidate
|
||||
}
|
||||
if (dir === root) break
|
||||
dir = path.dirname(dir)
|
||||
}
|
||||
throw new Error(
|
||||
'@nous-research/ui not found. Run `npm install` from the repo root.'
|
||||
)
|
||||
}
|
||||
|
||||
const uiRoot = locateUiPackage()
|
||||
const distRoot = path.join(uiRoot, 'dist')
|
||||
|
||||
const mappings = [
|
||||
['fonts', path.join(DASHBOARD_ROOT, 'public', 'fonts')],
|
||||
['assets', path.join(DASHBOARD_ROOT, 'public', 'ds-assets')],
|
||||
]
|
||||
|
||||
for (const [srcName, destPath] of mappings) {
|
||||
const srcPath = path.join(distRoot, srcName)
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
throw new Error(`Missing ${srcPath} in @nous-research/ui — rebuild that package.`)
|
||||
}
|
||||
fs.rmSync(destPath, { recursive: true, force: true })
|
||||
fs.cpSync(srcPath, destPath, { recursive: true })
|
||||
console.log(`synced ${path.relative(DASHBOARD_ROOT, destPath)}`)
|
||||
}
|
||||
@@ -424,8 +424,8 @@ export default function App() {
|
||||
|
||||
<header
|
||||
className={cn(
|
||||
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
|
||||
"flex items-center gap-2 px-3",
|
||||
"lg:hidden fixed top-0 left-0 right-0 z-40 min-h-14",
|
||||
"flex items-center gap-2 px-4 py-2",
|
||||
"border-b border-current/20",
|
||||
"bg-background-base/90 backdrop-blur-sm",
|
||||
)}
|
||||
@@ -469,7 +469,7 @@ export default function App() {
|
||||
|
||||
<PluginSlot name="header-banner" />
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<aside
|
||||
id="app-sidebar"
|
||||
@@ -575,7 +575,7 @@ export default function App() {
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher dropUp />
|
||||
<LanguageSwitcher />
|
||||
<LanguageSwitcher dropUp />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -588,8 +588,8 @@ export default function App() {
|
||||
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
|
||||
"px-3 sm:px-6",
|
||||
isChatRoute
|
||||
? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4"
|
||||
: "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8",
|
||||
? "pb-0 pt-1 sm:pt-2 lg:pt-4"
|
||||
: "pt-2 sm:pt-4 lg:pt-6",
|
||||
isDocsRoute && "min-h-0 flex-1",
|
||||
)}
|
||||
>
|
||||
@@ -597,6 +597,8 @@ export default function App() {
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
!isChatRoute &&
|
||||
"pb-[calc(2rem+env(safe-area-inset-bottom,0px))] lg:pb-8",
|
||||
(isDocsRoute || isChatRoute) &&
|
||||
"min-h-0 flex flex-1 flex-col",
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier";
|
||||
|
||||
import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
|
||||
|
||||
/**
|
||||
* Replicates the visual layer stack of `<Overlays dark />` from
|
||||
* `@nous-research/ui` without pulling in its leva / gsap / three peer deps.
|
||||
@@ -10,7 +12,7 @@ import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier";
|
||||
* `ThemeProvider` can repaint the stack without remounting.
|
||||
*
|
||||
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
|
||||
* z-2 filler-bg jpeg, inverted, opacity 0.033, difference
|
||||
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
|
||||
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
|
||||
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
||||
* color-dodge) — gated on GPU tier
|
||||
@@ -58,7 +60,7 @@ export function Backdrop() {
|
||||
alt=""
|
||||
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
|
||||
fetchPriority="low"
|
||||
src="/ds-assets/filler-bg0.jpg"
|
||||
src={fillerBgUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CLOSE_DRAG_MIN_PX = 72;
|
||||
const CLOSE_DRAG_RATIO = 0.18;
|
||||
const SHEET_TRANSITION_MS = 280;
|
||||
|
||||
/**
|
||||
* Mobile-first picker shell: fixed backdrop + bottom sheet, portaled to `body`
|
||||
* so nested overflow/transform in the sidebar cannot clip menus (theme /
|
||||
* language switchers). Open/close uses slide + fade; teardown is delayed until
|
||||
* the exit animation finishes so animations can complete.
|
||||
*
|
||||
* Drag the header/handle downward to dismiss (skipped when reduced motion is on).
|
||||
*/
|
||||
export function BottomPickSheet({
|
||||
backdropDismissLabel = "Dismiss",
|
||||
children,
|
||||
onClose,
|
||||
open,
|
||||
title,
|
||||
}: BottomPickSheetProps) {
|
||||
const [renderPortal, setRenderPortal] = useState(open);
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [dragOffsetPx, setDragOffsetPx] = useState(0);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const dragTrackingRef = useRef(false);
|
||||
const dragStartYRef = useRef(0);
|
||||
const dragOffsetRef = useRef(0);
|
||||
|
||||
const reducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const syncDragPx = (next: number) => {
|
||||
dragOffsetRef.current = next;
|
||||
setDragOffsetPx(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
|
||||
const ms = reducedMotion ? 0 : SHEET_TRANSITION_MS;
|
||||
|
||||
let openRafId = 0;
|
||||
let exitRafId = 0;
|
||||
|
||||
if (open) {
|
||||
openRafId = requestAnimationFrame(() => {
|
||||
dragTrackingRef.current = false;
|
||||
dragOffsetRef.current = 0;
|
||||
setDragActive(false);
|
||||
setDragOffsetPx(0);
|
||||
setRenderPortal(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setEntered(true));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
exitRafId = requestAnimationFrame(() => {
|
||||
dragTrackingRef.current = false;
|
||||
setDragActive(false);
|
||||
setEntered(false);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
dragOffsetRef.current = 0;
|
||||
setDragOffsetPx(0);
|
||||
setRenderPortal(false);
|
||||
closeTimerRef.current = null;
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(openRafId);
|
||||
cancelAnimationFrame(exitRafId);
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderPortal) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [renderPortal]);
|
||||
|
||||
if (!renderPortal || typeof document === "undefined") return null;
|
||||
|
||||
const durationClass = reducedMotion ? "duration-0" : "duration-[280ms]";
|
||||
|
||||
const draggingVisual = dragActive || dragOffsetPx > 0;
|
||||
|
||||
const onDragPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (reducedMotion || !entered) return;
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
|
||||
dragTrackingRef.current = true;
|
||||
setDragActive(true);
|
||||
dragStartYRef.current = e.clientY;
|
||||
syncDragPx(0);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onDragPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragTrackingRef.current) return;
|
||||
const dy = e.clientY - dragStartYRef.current;
|
||||
const next = Math.max(0, dy);
|
||||
const sheetH = sheetRef.current?.offsetHeight ?? 560;
|
||||
syncDragPx(Math.min(next, sheetH));
|
||||
};
|
||||
|
||||
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragTrackingRef.current) return;
|
||||
dragTrackingRef.current = false;
|
||||
setDragActive(false);
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
|
||||
const sheetH = sheetRef.current?.offsetHeight ?? 560;
|
||||
const threshold = Math.max(CLOSE_DRAG_MIN_PX, sheetH * CLOSE_DRAG_RATIO);
|
||||
const d = dragOffsetRef.current;
|
||||
|
||||
if (d >= threshold) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
syncDragPx(0);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex flex-col justify-end">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={backdropDismissLabel}
|
||||
className={cn(
|
||||
"absolute inset-0 bg-black/55 backdrop-blur-[2px]",
|
||||
"transition-opacity ease-out motion-reduce:transition-none",
|
||||
durationClass,
|
||||
entered ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-label={title}
|
||||
aria-modal="true"
|
||||
ref={sheetRef}
|
||||
className={cn(
|
||||
"relative flex max-h-[85dvh] min-h-0 flex-col rounded-t-xl border border-current/20",
|
||||
"bg-background-base/98 pb-[max(1rem,env(safe-area-inset-bottom))]",
|
||||
"shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md",
|
||||
"ease-out motion-reduce:transition-none transform-gpu",
|
||||
draggingVisual ? "transition-none" : cn("transition-transform", durationClass),
|
||||
entered ? "translate-y-0" : "translate-y-full",
|
||||
)}
|
||||
role="dialog"
|
||||
style={
|
||||
entered && dragOffsetPx > 0
|
||||
? { transform: `translateY(${dragOffsetPx}px)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col gap-2 border-b border-current/15 px-4 pb-3 pt-2",
|
||||
"touch-none select-none",
|
||||
reducedMotion ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
onPointerCancel={endDrag}
|
||||
onPointerDown={onDragPointerDown}
|
||||
onPointerMove={onDragPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-auto h-1 w-10 shrink-0 rounded-full bg-current/20"
|
||||
/>
|
||||
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface BottomPickSheetProps {
|
||||
backdropDismissLabel?: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
import { LOCALE_META } from "@/i18n";
|
||||
import type { Locale } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Language picker — shows the current language's flag + endonym, opens a
|
||||
@@ -12,15 +15,34 @@ import type { Locale } from "@/i18n";
|
||||
*
|
||||
* Replaces the older two-state EN↔ZH toggle now that we ship 16 locales
|
||||
* (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
|
||||
*
|
||||
* Locale markers use lipis/flag-icons (SVG sprites) instead of emoji so flags
|
||||
* render consistently across platforms.
|
||||
*
|
||||
* When placed at the bottom of the sidebar (next to ThemeSwitcher), pass
|
||||
* `dropUp` so the list opens above the trigger and avoids clipping below the
|
||||
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
|
||||
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
|
||||
*/
|
||||
export function LanguageSwitcher() {
|
||||
export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
// Close on outside click / Escape so the dropdown doesn't trap the user.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
|
||||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!containerRef.current) return;
|
||||
@@ -28,20 +50,14 @@ export function LanguageSwitcher() {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [open, useMobileSheet]);
|
||||
|
||||
const current = LOCALE_META[locale];
|
||||
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
|
||||
const sheetTitle = t.language.switchTo;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-flex">
|
||||
@@ -55,7 +71,7 @@ export function LanguageSwitcher() {
|
||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-base leading-none">{current.flag}</span>
|
||||
<LocaleFlagIcon countryCode={current.flagCountryCode} />
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
@@ -65,36 +81,103 @@ export function LanguageSwitcher() {
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t.language.switchTo}
|
||||
className="absolute right-0 top-full mt-1 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto"
|
||||
{useMobileSheet && (
|
||||
<BottomPickSheet
|
||||
backdropDismissLabel={t.common.close}
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
title={sheetTitle}
|
||||
>
|
||||
{allLocales.map(([code, meta]) => {
|
||||
const selected = code === locale;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => {
|
||||
setLocale(code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={
|
||||
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
|
||||
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
|
||||
}
|
||||
>
|
||||
<span className="text-base leading-none">{meta.flag}</span>
|
||||
<span className="truncate">{meta.name}</span>
|
||||
{selected && <span className="ml-auto text-xs">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div aria-label={sheetTitle} role="listbox">
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
</BottomPickSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageSwitcherOptions({
|
||||
allLocales,
|
||||
locale,
|
||||
setLocale,
|
||||
setOpen,
|
||||
}: LanguageSwitcherOptionsProps) {
|
||||
return (
|
||||
<>
|
||||
{allLocales.map(([code, meta]) => {
|
||||
const selected = code === locale;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-selected={selected}
|
||||
className={
|
||||
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
|
||||
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
|
||||
}
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setLocale(code);
|
||||
setOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<LocaleFlagIcon countryCode={meta.flagCountryCode} />
|
||||
|
||||
<span className="truncate">{meta.name}</span>
|
||||
|
||||
{selected && <span className="ml-auto text-xs">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LocaleFlagIcon({ countryCode }: LocaleFlagIconProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("fi fis shrink-0 text-base leading-none", `fi-${countryCode}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LanguageSwitcherOptionsProps {
|
||||
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
|
||||
locale: Locale;
|
||||
setLocale: (code: Locale) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
||||
interface LocaleFlagIconProps {
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/**
|
||||
* Two-stage model picker modal.
|
||||
@@ -194,7 +195,14 @@ export function ModelPickerDialog(props: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// Portal to document.body: the main dashboard column in App.tsx is
|
||||
// `relative z-2`, which creates a stacking context that traps fixed
|
||||
// descendants below the app sidebar (z-50). Without the portal this
|
||||
// modal's z-[100] is scoped to z-2 and the sidebar covers its left
|
||||
// edge — visible especially in the Large theme variants where the
|
||||
// larger root font widens the dialog into the sidebar's column. See
|
||||
// Toast.tsx for the same pattern.
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
@@ -296,7 +304,8 @@ export function ModelPickerDialog(props: Props) {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||
import type { DashboardTheme } from "@/themes";
|
||||
import type { DashboardTheme, ThemeListEntry } from "@/themes";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -17,18 +19,31 @@ import { cn } from "@/lib/utils";
|
||||
*
|
||||
* When placed at the bottom of a container (e.g. the sidebar rail), pass
|
||||
* `dropUp` so the menu opens above the trigger instead of clipping below
|
||||
* the viewport.
|
||||
* the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a
|
||||
* bottom sheet portaled to `document.body` so the picker is not clipped by
|
||||
* the sidebar (same idea as a responsive Drawer).
|
||||
*/
|
||||
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
const { themeName, availableThemes, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
@@ -37,19 +52,13 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, close]);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [open, close, useMobileSheet]);
|
||||
|
||||
const current = availableThemes.find((th) => th.name === themeName);
|
||||
const label = current?.label ?? themeName;
|
||||
const sheetTitle = t.theme?.title ?? "Theme";
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
@@ -74,77 +83,113 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
{useMobileSheet && (
|
||||
<BottomPickSheet
|
||||
backdropDismissLabel={t.common.close}
|
||||
onClose={close}
|
||||
open={open}
|
||||
title={sheetTitle}
|
||||
>
|
||||
<div aria-label={sheetTitle} role="listbox">
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
</BottomPickSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t.theme?.title ?? "Theme"}
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
||||
>
|
||||
{t.theme?.title ?? "Theme"}
|
||||
{sheetTitle}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{availableThemes.map((th) => {
|
||||
const isActive = th.name === themeName;
|
||||
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={th.name}
|
||||
active={isActive}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => {
|
||||
setTheme(th.name);
|
||||
close();
|
||||
}}
|
||||
className="gap-3"
|
||||
>
|
||||
{paletteTheme ? (
|
||||
<ThemeSwatch theme={paletteTheme} />
|
||||
) : (
|
||||
<PlaceholderSwatch />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Typography
|
||||
mondwest
|
||||
className="truncate text-[0.75rem] tracking-wide uppercase"
|
||||
>
|
||||
{th.label}
|
||||
</Typography>
|
||||
{th.description && (
|
||||
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
|
||||
{th.description}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-midground",
|
||||
isActive ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwitcherOptions({
|
||||
availableThemes,
|
||||
close,
|
||||
setTheme,
|
||||
themeName,
|
||||
}: ThemeSwitcherOptionsProps) {
|
||||
return (
|
||||
<>
|
||||
{availableThemes.map((th) => {
|
||||
const isActive = th.name === themeName;
|
||||
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
active={isActive}
|
||||
aria-selected={isActive}
|
||||
className="gap-3"
|
||||
key={th.name}
|
||||
onClick={() => {
|
||||
setTheme(th.name);
|
||||
close();
|
||||
}}
|
||||
role="option"
|
||||
>
|
||||
{paletteTheme ? (
|
||||
<ThemeSwatch theme={paletteTheme} />
|
||||
) : (
|
||||
<PlaceholderSwatch />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Typography
|
||||
mondwest
|
||||
className="truncate text-[0.75rem] tracking-wide uppercase"
|
||||
>
|
||||
{th.label}
|
||||
</Typography>
|
||||
{th.description && (
|
||||
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
|
||||
{th.description}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-midground",
|
||||
isActive ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
|
||||
const { background, midground, warmGlow } = theme.palette;
|
||||
return (
|
||||
@@ -168,6 +213,13 @@ function PlaceholderSwatch() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ThemeSwitcherOptionsProps {
|
||||
availableThemes: ThemeListEntry[];
|
||||
close: () => void;
|
||||
setTheme: (name: string) => void;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ export function PageHeaderProvider({
|
||||
const displayTitle = titleOverride ?? defaultTitle;
|
||||
|
||||
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
|
||||
/** Env jump-nav is wide — stack below title on small screens so KEYS stays readable. */
|
||||
const isEnvRoute =
|
||||
pathname === "/env" || pathname.startsWith("/env/");
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
@@ -51,37 +54,65 @@ export function PageHeaderProvider({
|
||||
<header
|
||||
className={cn(
|
||||
"z-1 w-full shrink-0",
|
||||
"box-border h-14 min-h-14",
|
||||
"border-b border-current/20",
|
||||
"box-border border-b border-current/20",
|
||||
"bg-background-base/40 backdrop-blur-sm",
|
||||
"overflow-hidden",
|
||||
"sm:min-h-0",
|
||||
// Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
|
||||
"min-h-0 overflow-x-hidden overflow-y-visible py-3 sm:h-14 sm:min-h-[3.5rem] sm:overflow-hidden sm:py-0",
|
||||
)}
|
||||
role="banner"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0",
|
||||
"flex w-full min-w-0 flex-1 gap-3 px-3 sm:h-full sm:gap-3 sm:px-6",
|
||||
isChatRoute
|
||||
? "flex-row items-center"
|
||||
: "flex-col justify-center sm:flex-row sm:items-center",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 gap-2 sm:gap-3",
|
||||
afterTitle && isEnvRoute
|
||||
? "flex-col items-start sm:flex-row sm:items-center"
|
||||
: afterTitle
|
||||
? "flex-row flex-wrap items-center"
|
||||
: "flex-row items-center",
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className="font-expanded min-w-0 truncate text-sm font-bold tracking-[0.08em] text-midground"
|
||||
className={cn(
|
||||
"font-expanded min-w-0 text-sm font-bold tracking-[0.08em] text-midground",
|
||||
afterTitle && isEnvRoute
|
||||
? "max-w-full sm:min-w-0 sm:shrink sm:truncate"
|
||||
: afterTitle
|
||||
? "shrink truncate"
|
||||
: "truncate",
|
||||
)}
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
{afterTitle}
|
||||
{afterTitle ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 scrollbar-none",
|
||||
isEnvRoute
|
||||
? "w-full overflow-x-auto sm:flex-1 sm:overflow-x-auto"
|
||||
: "shrink-0 overflow-visible",
|
||||
)}
|
||||
>
|
||||
{afterTitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{end ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 justify-end sm:max-w-md sm:flex-1",
|
||||
isChatRoute ? "w-auto shrink-0" : "w-full",
|
||||
"flex min-w-0 sm:max-w-md sm:flex-1",
|
||||
isChatRoute
|
||||
? "w-auto shrink-0 justify-end"
|
||||
: "w-full justify-start sm:justify-end",
|
||||
)}
|
||||
>
|
||||
{end}
|
||||
@@ -93,6 +124,8 @@ export function PageHeaderProvider({
|
||||
<main
|
||||
className={cn(
|
||||
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
||||
// Bottom inset for scrolled pages lives on the route outlet wrapper in
|
||||
// `App.tsx` (`w-full min-w-0`) so it pads scrollable content, not flex chrome.
|
||||
isChatRoute
|
||||
? "overflow-hidden"
|
||||
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** True when viewport width is strictly below `px` (matches Tailwind `min-width: px`). */
|
||||
export function useBelowBreakpoint(px: number) {
|
||||
const query = `(max-width: ${px - 1}px)`;
|
||||
const [matches, setMatches] = useState(() =>
|
||||
typeof window !== "undefined" ? window.matchMedia(query).matches : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const sync = () => setMatches(mql.matches);
|
||||
sync();
|
||||
mql.addEventListener("change", sync);
|
||||
return () => mql.removeEventListener("change", sync);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -654,6 +654,7 @@ export const af: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triage",
|
||||
todo: "Te doen",
|
||||
scheduled: "Geskeduleerd",
|
||||
ready: "Gereed",
|
||||
running: "Aan die gang",
|
||||
blocked: "Geblokkeer",
|
||||
@@ -663,6 +664,7 @@ export const af: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Rou idees — 'n spesifiseerder sal die spesifikasie uitwerk",
|
||||
todo: "Wag op afhanklikhede of nie toegewys nie",
|
||||
scheduled: "Wag op 'n bekende tydvertraging of geskeduleerde opvolg",
|
||||
ready: "Afhanklikhede is bevredig; wys 'n profiel toe om te versend",
|
||||
running: "Deur 'n werker geëis — in vlug",
|
||||
blocked: "Werker het mensinvoer aangevra",
|
||||
|
||||
@@ -38,25 +38,26 @@ const TRANSLATIONS: Record<Locale, Translations> = {
|
||||
|
||||
// Display metadata for the language picker — endonym (native name) so users
|
||||
// recognize their language even if they don't speak the current UI language,
|
||||
// plus a flag emoji for visual scanning. Exposed as a constant so the
|
||||
// LanguageSwitcher and any future settings page can share the same list.
|
||||
export const LOCALE_META: Record<Locale, { name: string; flag: string }> = {
|
||||
en: { name: "English", flag: "🇬🇧" },
|
||||
zh: { name: "简体中文", flag: "🇨🇳" },
|
||||
"zh-hant": { name: "繁體中文", flag: "🇹🇼" },
|
||||
ja: { name: "日本語", flag: "🇯🇵" },
|
||||
de: { name: "Deutsch", flag: "🇩🇪" },
|
||||
es: { name: "Español", flag: "🇪🇸" },
|
||||
fr: { name: "Français", flag: "🇫🇷" },
|
||||
tr: { name: "Türkçe", flag: "🇹🇷" },
|
||||
uk: { name: "Українська", flag: "🇺🇦" },
|
||||
af: { name: "Afrikaans", flag: "🇿🇦" },
|
||||
ko: { name: "한국어", flag: "🇰🇷" },
|
||||
it: { name: "Italiano", flag: "🇮🇹" },
|
||||
ga: { name: "Gaeilge", flag: "🇮🇪" },
|
||||
pt: { name: "Português", flag: "🇵🇹" },
|
||||
ru: { name: "Русский", flag: "🇷🇺" },
|
||||
hu: { name: "Magyar", flag: "🇭🇺" },
|
||||
// plus a flag-icons sprite (ISO 3166-1 alpha-2) for visual scanning.
|
||||
// Exposed as a constant so the LanguageSwitcher and any future settings page
|
||||
// can share the same list.
|
||||
export const LOCALE_META: Record<Locale, { name: string; flagCountryCode: string }> = {
|
||||
en: { name: "English", flagCountryCode: "gb" },
|
||||
zh: { name: "简体中文", flagCountryCode: "cn" },
|
||||
"zh-hant": { name: "繁體中文", flagCountryCode: "tw" },
|
||||
ja: { name: "日本語", flagCountryCode: "jp" },
|
||||
de: { name: "Deutsch", flagCountryCode: "de" },
|
||||
es: { name: "Español", flagCountryCode: "es" },
|
||||
fr: { name: "Français", flagCountryCode: "fr" },
|
||||
tr: { name: "Türkçe", flagCountryCode: "tr" },
|
||||
uk: { name: "Українська", flagCountryCode: "ua" },
|
||||
af: { name: "Afrikaans", flagCountryCode: "za" },
|
||||
ko: { name: "한국어", flagCountryCode: "kr" },
|
||||
it: { name: "Italiano", flagCountryCode: "it" },
|
||||
ga: { name: "Gaeilge", flagCountryCode: "ie" },
|
||||
pt: { name: "Português", flagCountryCode: "pt" },
|
||||
ru: { name: "Русский", flagCountryCode: "ru" },
|
||||
hu: { name: "Magyar", flagCountryCode: "hu" },
|
||||
};
|
||||
|
||||
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
|
||||
|
||||
@@ -653,6 +653,7 @@ export const de: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triage",
|
||||
todo: "Zu erledigen",
|
||||
scheduled: "Geplant",
|
||||
ready: "Bereit",
|
||||
running: "In Bearbeitung",
|
||||
blocked: "Blockiert",
|
||||
@@ -662,6 +663,7 @@ export const de: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Rohe Ideen — ein Specifier wird die Spezifikation ausarbeiten",
|
||||
todo: "Wartet auf Abhängigkeiten oder ist nicht zugewiesen",
|
||||
scheduled: "Wartet auf eine bekannte Verzögerung oder eine geplante Nachverfolgung",
|
||||
ready: "Abhängigkeiten erfüllt; Profil zum Dispatch zuweisen",
|
||||
running: "Von einem Worker übernommen — in Bearbeitung",
|
||||
blocked: "Worker hat um menschliche Eingabe gebeten",
|
||||
|
||||
@@ -658,6 +658,7 @@ export const en: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triage",
|
||||
todo: "Todo",
|
||||
scheduled: "Scheduled",
|
||||
ready: "Ready",
|
||||
running: "In Progress",
|
||||
blocked: "Blocked",
|
||||
@@ -667,6 +668,7 @@ export const en: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Raw ideas — a specifier will flesh out the spec",
|
||||
todo: "Waiting on dependencies or unassigned",
|
||||
scheduled: "Waiting on a known time delay or scheduled follow-up",
|
||||
ready: "Dependencies satisfied; assign a profile to dispatch",
|
||||
running: "Claimed by a worker — in-flight",
|
||||
blocked: "Worker asked for human input",
|
||||
@@ -679,6 +681,8 @@ export const en: Translations = {
|
||||
"Archive this task? It disappears from the default board view.",
|
||||
confirmBlocked:
|
||||
"Mark this task as blocked? The worker's claim is released.",
|
||||
confirmScheduled:
|
||||
"Move this task to Scheduled? Use this for known time delays rather than human blockers.",
|
||||
completionSummary:
|
||||
"Completion summary for {label}. This is stored as the task result.",
|
||||
completionSummaryRequired:
|
||||
|
||||
@@ -653,6 +653,7 @@ export const es: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Clasificación",
|
||||
todo: "Por hacer",
|
||||
scheduled: "Programado",
|
||||
ready: "Listo",
|
||||
running: "En curso",
|
||||
blocked: "Bloqueado",
|
||||
@@ -662,6 +663,7 @@ export const es: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Ideas en bruto — un specifier desarrollará la especificación",
|
||||
todo: "Esperando dependencias o sin asignar",
|
||||
scheduled: "Esperando un retraso conocido o un seguimiento programado",
|
||||
ready: "Dependencias satisfechas; asigna un perfil para despachar",
|
||||
running: "Reclamado por un worker — en ejecución",
|
||||
blocked: "El worker pidió intervención humana",
|
||||
|
||||
@@ -653,6 +653,7 @@ export const fr: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triage",
|
||||
todo: "À faire",
|
||||
scheduled: "Planifié",
|
||||
ready: "Prêt",
|
||||
running: "En cours",
|
||||
blocked: "Bloqué",
|
||||
@@ -662,6 +663,7 @@ export const fr: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Idées brutes — un specifier rédigera la spécification",
|
||||
todo: "En attente de dépendances ou non assigné",
|
||||
scheduled: "En attente d'un délai connu ou d'un suivi planifié",
|
||||
ready: "Dépendances satisfaites ; assignez un profil pour dispatch",
|
||||
running: "Réclamé par un worker — en cours d'exécution",
|
||||
blocked: "Le worker a demandé une intervention humaine",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const ga: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triáiseáil",
|
||||
todo: "Le déanamh",
|
||||
scheduled: "Sceidealta",
|
||||
ready: "Réidh",
|
||||
running: "Ar siúl",
|
||||
blocked: "Bactha",
|
||||
@@ -663,6 +664,7 @@ export const ga: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Smaointe amha — déanfaidh specifier an spec a chur i bhfeidhm",
|
||||
todo: "Ag fanacht ar spleáchais nó gan sannadh",
|
||||
scheduled: "Ag fanacht ar mhoill ama atá ar eolas nó ar leanúint sceidealta",
|
||||
ready: "Tá na spleáchais sásaithe; sann próifíl le dispatch a dhéanamh",
|
||||
running: "Éilithe ag worker — ar siúl",
|
||||
blocked: "D'iarr an worker ionchur duine",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const hu: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triázs",
|
||||
todo: "Tennivaló",
|
||||
scheduled: "Ütemezett",
|
||||
ready: "Indulásra kész",
|
||||
running: "Folyamatban",
|
||||
blocked: "Blokkolva",
|
||||
@@ -663,6 +664,7 @@ export const hu: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Nyers ötletek — egy specifier kidolgozza a specifikációt",
|
||||
todo: "Függőségekre vár vagy nincs felelőse",
|
||||
scheduled: "Ismert időzítésre vagy ütemezett utánkövetésre vár",
|
||||
ready: "A függőségek teljesültek; rendelj hozzá profilt az indításhoz",
|
||||
running: "Worker felvette — folyamatban",
|
||||
blocked: "A worker emberi beavatkozást kért",
|
||||
|
||||
@@ -653,6 +653,7 @@ export const it: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triage",
|
||||
todo: "Da fare",
|
||||
scheduled: "Pianificato",
|
||||
ready: "Pronto",
|
||||
running: "In corso",
|
||||
blocked: "Bloccato",
|
||||
@@ -662,6 +663,7 @@ export const it: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Idee grezze — un specifier elaborerà la specifica",
|
||||
todo: "In attesa di dipendenze o non assegnato",
|
||||
scheduled: "In attesa di un ritardo noto o di un follow-up pianificato",
|
||||
ready: "Dipendenze soddisfatte; assegna un profilo per il dispatch",
|
||||
running: "Preso in carico da un worker — in esecuzione",
|
||||
blocked: "Il worker ha richiesto input umano",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const ja: Translations = {
|
||||
columnLabels: {
|
||||
triage: "トリアージ",
|
||||
todo: "ToDo",
|
||||
scheduled: "スケジュール済み",
|
||||
ready: "準備完了",
|
||||
running: "進行中",
|
||||
blocked: "ブロック中",
|
||||
@@ -663,6 +664,7 @@ export const ja: Translations = {
|
||||
columnHelp: {
|
||||
triage: "未整理のアイデア — スペシファイアが仕様を肉付けします",
|
||||
todo: "依存関係の待機中、または未割り当て",
|
||||
scheduled: "既知の時間遅延またはスケジュール済みのフォローアップ待ち",
|
||||
ready: "依存関係は満たされています。ディスパッチするにはプロファイルを割り当ててください",
|
||||
running: "ワーカーが取得中 — 実行中",
|
||||
blocked: "ワーカーが人間の入力を求めています",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const ko: Translations = {
|
||||
columnLabels: {
|
||||
triage: "분류",
|
||||
todo: "할 일",
|
||||
scheduled: "예약됨",
|
||||
ready: "준비됨",
|
||||
running: "진행 중",
|
||||
blocked: "차단됨",
|
||||
@@ -663,6 +664,7 @@ export const ko: Translations = {
|
||||
columnHelp: {
|
||||
triage: "원시 아이디어 — 스페시파이어가 사양을 구체화합니다",
|
||||
todo: "종속성 대기 중 또는 미지정",
|
||||
scheduled: "알려진 시간 지연 또는 예약된 후속 조치를 기다리는 중",
|
||||
ready: "종속성이 충족됨; 디스패치하려면 프로필을 지정하세요",
|
||||
running: "워커가 점유 중 — 실행 중",
|
||||
blocked: "워커가 사람의 입력을 요청함",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const pt: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triagem",
|
||||
todo: "A fazer",
|
||||
scheduled: "Agendado",
|
||||
ready: "Pronto",
|
||||
running: "Em curso",
|
||||
blocked: "Bloqueado",
|
||||
@@ -663,6 +664,7 @@ export const pt: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Ideias em bruto — um specifier vai detalhar a especificação",
|
||||
todo: "À espera de dependências ou sem atribuição",
|
||||
scheduled: "À espera de um atraso conhecido ou de um seguimento agendado",
|
||||
ready: "Dependências satisfeitas; atribua um perfil para despachar",
|
||||
running: "Reivindicado por um worker — em execução",
|
||||
blocked: "O worker pediu intervenção humana",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const ru: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Сортировка",
|
||||
todo: "К выполнению",
|
||||
scheduled: "Запланировано",
|
||||
ready: "Готово к работе",
|
||||
running: "В работе",
|
||||
blocked: "Заблокировано",
|
||||
@@ -663,6 +664,7 @@ export const ru: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Сырые идеи — specifier подготовит спецификацию",
|
||||
todo: "Ожидает зависимостей или без исполнителя",
|
||||
scheduled: "Ожидает известной задержки по времени или запланированного продолжения",
|
||||
ready: "Зависимости выполнены; назначьте профиль для диспетчеризации",
|
||||
running: "Взято воркером — выполняется",
|
||||
blocked: "Воркер запросил вмешательство человека",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const tr: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Triyaj",
|
||||
todo: "Yapılacak",
|
||||
scheduled: "Zamanlandı",
|
||||
ready: "Hazır",
|
||||
running: "Sürüyor",
|
||||
blocked: "Engellendi",
|
||||
@@ -663,6 +664,7 @@ export const tr: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Ham fikirler — bir specifier şartnameyi detaylandıracak",
|
||||
todo: "Bağımlılıklar bekleniyor veya atanmamış",
|
||||
scheduled: "Bilinen bir zaman gecikmesi veya zamanlanmış takip bekleniyor",
|
||||
ready: "Bağımlılıklar karşılandı; dispatch için bir profil atayın",
|
||||
running: "Bir worker tarafından alındı — yürütülüyor",
|
||||
blocked: "Worker insan girdisi istedi",
|
||||
|
||||
@@ -666,6 +666,7 @@ export interface Translations {
|
||||
columnLabels: {
|
||||
triage: string;
|
||||
todo: string;
|
||||
scheduled: string;
|
||||
ready: string;
|
||||
running: string;
|
||||
blocked: string;
|
||||
@@ -675,6 +676,7 @@ export interface Translations {
|
||||
columnHelp: {
|
||||
triage: string;
|
||||
todo: string;
|
||||
scheduled: string;
|
||||
ready: string;
|
||||
running: string;
|
||||
blocked: string;
|
||||
@@ -684,6 +686,7 @@ export interface Translations {
|
||||
confirmDone: string;
|
||||
confirmArchive: string;
|
||||
confirmBlocked: string;
|
||||
confirmScheduled?: string;
|
||||
completionSummary: string;
|
||||
completionSummaryRequired: string;
|
||||
triagePlaceholder: string;
|
||||
|
||||
@@ -654,6 +654,7 @@ export const uk: Translations = {
|
||||
columnLabels: {
|
||||
triage: "Сортування",
|
||||
todo: "До виконання",
|
||||
scheduled: "Заплановано",
|
||||
ready: "Готово",
|
||||
running: "У роботі",
|
||||
blocked: "Заблоковано",
|
||||
@@ -663,6 +664,7 @@ export const uk: Translations = {
|
||||
columnHelp: {
|
||||
triage: "Сирі ідеї — специфікатор деталізує специфікацію",
|
||||
todo: "Очікує на залежності або не призначено",
|
||||
scheduled: "Очікує на відому затримку в часі або заплановане продовження",
|
||||
ready: "Залежності задоволені; призначте профіль для диспетчеризації",
|
||||
running: "Захоплено воркером — у роботі",
|
||||
blocked: "Воркер запитав втручання людини",
|
||||
|
||||
@@ -654,6 +654,7 @@ export const zhHant: Translations = {
|
||||
columnLabels: {
|
||||
triage: "待分類",
|
||||
todo: "待辦",
|
||||
scheduled: "已排程",
|
||||
ready: "就緒",
|
||||
running: "進行中",
|
||||
blocked: "已封鎖",
|
||||
@@ -663,6 +664,7 @@ export const zhHant: Translations = {
|
||||
columnHelp: {
|
||||
triage: "原始想法 — 規格制定者將完善規格",
|
||||
todo: "等待相依項目或尚未指派",
|
||||
scheduled: "等待已知的時間延遲或已排程的後續處理",
|
||||
ready: "相依項目已滿足;指派設定檔以便排程",
|
||||
running: "已被工作者領取 — 執行中",
|
||||
blocked: "工作者請求人工輸入",
|
||||
|
||||
@@ -650,6 +650,7 @@ export const zh: Translations = {
|
||||
columnLabels: {
|
||||
triage: "待分类",
|
||||
todo: "待办",
|
||||
scheduled: "已调度",
|
||||
ready: "就绪",
|
||||
running: "进行中",
|
||||
blocked: "阻塞",
|
||||
@@ -659,6 +660,7 @@ export const zh: Translations = {
|
||||
columnHelp: {
|
||||
triage: "原始想法 — 规范制定者将完善规格",
|
||||
todo: "等待依赖项或未分配",
|
||||
scheduled: "等待已知的时间延迟或已调度的跟进",
|
||||
ready: "依赖项已满足;分配一个配置文件以便调度",
|
||||
running: "已被工作者认领 — 执行中",
|
||||
blocked: "工作者请求人工输入",
|
||||
|
||||
@@ -138,21 +138,22 @@ export const api = {
|
||||
},
|
||||
|
||||
// Cron jobs
|
||||
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
|
||||
fetchJSON<CronJob>("/api/cron/jobs", {
|
||||
getCronJobs: (profile = "all") =>
|
||||
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(job),
|
||||
}),
|
||||
pauseCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
|
||||
resumeCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
|
||||
triggerCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
|
||||
deleteCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
||||
pauseCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
resumeCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
triggerCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/trigger?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
deleteCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
|
||||
|
||||
// Profiles (minimal)
|
||||
getProfiles: () =>
|
||||
@@ -553,6 +554,10 @@ export interface ModelsAnalyticsResponse {
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
profile?: string | null;
|
||||
profile_name?: string | null;
|
||||
hermes_home?: string | null;
|
||||
is_default_profile?: boolean;
|
||||
name?: string | null;
|
||||
prompt?: string | null;
|
||||
script?: string | null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { SystemActionsProvider } from "./contexts/SystemActions";
|
||||
|
||||
@@ -439,7 +439,7 @@ export default function AnalyticsPage() {
|
||||
);
|
||||
setEnd(
|
||||
showTokens === false ? null : (
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
|
||||
@@ -298,10 +298,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// is false; enabling it gives users a single-action selection
|
||||
// path on top of the modifier-based bypass above.
|
||||
rightClickSelectsWord: true,
|
||||
// Single-scroll-system experiment:
|
||||
// let the inner Hermes TUI own transcript history/scroll behavior.
|
||||
// The outer browser xterm should act as a display/input bridge only.
|
||||
scrollback: 0,
|
||||
// Browser-embedded chat runs the TUI in inline mode. Keep transcript
|
||||
// history in xterm.js so the browser wheel can scroll it directly.
|
||||
scrollback: 5000,
|
||||
theme: TERMINAL_THEME,
|
||||
});
|
||||
termRef.current = term;
|
||||
@@ -345,7 +344,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// original keydown event's activation. Log to aid debugging.
|
||||
console.warn("[dashboard clipboard] OSC 52 write failed:", err.message);
|
||||
});
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.warn("[dashboard clipboard] malformed OSC 52 payload");
|
||||
}
|
||||
return true;
|
||||
@@ -404,34 +403,16 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
fitRef.current = fit;
|
||||
term.loadAddon(fit);
|
||||
|
||||
// Single-scroll-system experiment:
|
||||
// keep browser xterm as a display/input bridge only, and let the inner
|
||||
// Hermes TUI own transcript scrolling.
|
||||
//
|
||||
// In practice, the most reliable path here is NOT terminal mouse-wheel
|
||||
// protocol emulation — that can vary by terminal mode and parser path.
|
||||
// The inner TUI already handles keyboard-driven transcript scrolling
|
||||
// correctly (`Shift+Up` / `Shift+Down`, `PageUp` / `PageDown`), so we
|
||||
// translate browser wheel gestures into those known-good key sequences.
|
||||
// Dashboard chat should scroll the browser-side transcript, not send
|
||||
// mouse-wheel protocol bytes through the PTY.
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const delta = ev.deltaY;
|
||||
if (!delta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+Up / Shift+Down: the TUI maps these to line-by-line
|
||||
// transcript scrolling, which feels much closer to wheel behavior
|
||||
// than PageUp/PageDown's half-page jumps.
|
||||
const step = Math.max(1, Math.round(Math.abs(delta) / 50));
|
||||
const seq = delta > 0 ? "\x1b[1;2B" : "\x1b[1;2A";
|
||||
|
||||
for (let i = 0; i < step; i++) {
|
||||
wsRef.current.send(seq);
|
||||
}
|
||||
term.scrollLines(delta > 0 ? step : -step);
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -417,14 +417,14 @@ export default function ConfigPage() {
|
||||
<PluginSlot name="config:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:flex-1">
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||
{configPath ?? t.config.configPath}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob } from "@/lib/api";
|
||||
import type { CronJob, ProfileInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
@@ -69,6 +69,24 @@ function getJobState(job: CronJob): string {
|
||||
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
|
||||
}
|
||||
|
||||
function getJobProfile(job: CronJob): string {
|
||||
return asText(job.profile) || asText(job.profile_name) || "default";
|
||||
}
|
||||
|
||||
function getJobKey(job: CronJob): string {
|
||||
return `${getJobProfile(job)}:${job.id}`;
|
||||
}
|
||||
|
||||
function splitJobKey(key: string): { profile: string; id: string } {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { profile: "default", id: key };
|
||||
return { profile: key.slice(0, idx) || "default", id: key.slice(idx + 1) };
|
||||
}
|
||||
|
||||
function profileLabel(profile: string): string {
|
||||
return profile === "default" ? "default" : profile;
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
|
||||
enabled: "success",
|
||||
scheduled: "success",
|
||||
@@ -79,6 +97,8 @@ const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
|
||||
|
||||
export default function CronPage() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
@@ -96,14 +116,22 @@ export default function CronPage() {
|
||||
});
|
||||
const [deliver, setDeliver] = useState("local");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
|
||||
|
||||
const loadJobs = useCallback(() => {
|
||||
api
|
||||
.getCronJobs()
|
||||
.getCronJobs(selectedProfile)
|
||||
.then(setJobs)
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast, t.common.loading]);
|
||||
}, [selectedProfile, showToast, t.common.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
@@ -116,12 +144,15 @@ export default function CronPage() {
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createCronJob({
|
||||
prompt: prompt.trim(),
|
||||
schedule: schedule.trim(),
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
});
|
||||
await api.createCronJob(
|
||||
{
|
||||
prompt: prompt.trim(),
|
||||
schedule: schedule.trim(),
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
},
|
||||
createProfile,
|
||||
);
|
||||
showToast(t.common.create + " ✓", "success");
|
||||
setPrompt("");
|
||||
setSchedule("");
|
||||
@@ -139,14 +170,15 @@ export default function CronPage() {
|
||||
const handlePauseResume = async (job: CronJob) => {
|
||||
try {
|
||||
const isPaused = getJobState(job) === "paused";
|
||||
const profile = getJobProfile(job);
|
||||
if (isPaused) {
|
||||
await api.resumeCronJob(job.id);
|
||||
await api.resumeCronJob(job.id, profile);
|
||||
showToast(
|
||||
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
await api.pauseCronJob(job.id);
|
||||
await api.pauseCronJob(job.id, profile);
|
||||
showToast(
|
||||
`${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
@@ -160,7 +192,7 @@ export default function CronPage() {
|
||||
|
||||
const handleTrigger = async (job: CronJob) => {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
await api.triggerCronJob(job.id, getJobProfile(job));
|
||||
showToast(
|
||||
`${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
@@ -173,10 +205,11 @@ export default function CronPage() {
|
||||
|
||||
const jobDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (id: string) => {
|
||||
const job = jobs.find((j) => j.id === id);
|
||||
async (key: string) => {
|
||||
const { profile, id } = splitJobKey(key);
|
||||
const job = jobs.find((j) => getJobKey(j) === key);
|
||||
try {
|
||||
await api.deleteCronJob(id);
|
||||
await api.deleteCronJob(id, profile);
|
||||
showToast(
|
||||
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
|
||||
"success",
|
||||
@@ -216,7 +249,7 @@ export default function CronPage() {
|
||||
}
|
||||
|
||||
const pendingJob = jobDelete.pendingId
|
||||
? jobs.find((j) => j.id === jobDelete.pendingId)
|
||||
? jobs.find((j) => getJobKey(j) === jobDelete.pendingId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -270,6 +303,21 @@ export default function CronPage() {
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-profile">Profile</Label>
|
||||
<Select
|
||||
id="cron-profile"
|
||||
value={createProfile}
|
||||
onValueChange={(v) => setSelectedProfile(v)}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<SelectOption key={profile.name} value={profile.name}>
|
||||
{profileLabel(profile.name)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
@@ -345,13 +393,31 @@ export default function CronPage() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</H2>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</H2>
|
||||
|
||||
<div className="grid gap-1 min-w-[220px]">
|
||||
<Label htmlFor="cron-profile-filter">Profile</Label>
|
||||
<Select
|
||||
id="cron-profile-filter"
|
||||
value={selectedProfile}
|
||||
onValueChange={(v) => setSelectedProfile(v)}
|
||||
>
|
||||
<SelectOption value="all">All profiles</SelectOption>
|
||||
{profiles.map((profile) => (
|
||||
<SelectOption key={profile.name} value={profile.name}>
|
||||
{profileLabel(profile.name)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<Card>
|
||||
@@ -367,10 +433,12 @@ export default function CronPage() {
|
||||
const title = getJobTitle(job);
|
||||
const hasName = Boolean(getJobName(job));
|
||||
const deliver = asText(job.deliver);
|
||||
const profile = getJobProfile(job);
|
||||
const jobKey = getJobKey(job);
|
||||
|
||||
return (
|
||||
<Card key={job.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Card key={jobKey}>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
@@ -379,6 +447,7 @@ export default function CronPage() {
|
||||
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
|
||||
{state}
|
||||
</Badge>
|
||||
<Badge tone="outline">{profileLabel(profile)}</Badge>
|
||||
{deliver && deliver !== "local" && (
|
||||
<Badge tone="outline">{deliver}</Badge>
|
||||
)}
|
||||
@@ -436,7 +505,7 @@ export default function CronPage() {
|
||||
size="icon"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => jobDelete.requestDelete(job.id)}
|
||||
onClick={() => jobDelete.requestDelete(jobKey)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
|
||||
@@ -537,13 +537,16 @@ export default function EnvPage() {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
setAfterTitle(
|
||||
<nav className="flex items-center gap-1" aria-label="Jump to section">
|
||||
<nav
|
||||
className="flex shrink-0 flex-nowrap items-center gap-1"
|
||||
aria-label="Jump to section"
|
||||
>
|
||||
{sections.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => scrollTo(s.id)}
|
||||
className="cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||
className="shrink-0 cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
|
||||
@@ -46,6 +46,12 @@ const LINE_COLORS: Record<string, string> = {
|
||||
const toOptions = <T extends string>(values: readonly T[]) =>
|
||||
values.map((v) => ({ value: v, label: v }));
|
||||
|
||||
const filterGroupClass =
|
||||
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
|
||||
|
||||
const segmentedClass =
|
||||
"w-fit max-w-full flex-wrap justify-start self-start";
|
||||
|
||||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||
@@ -87,7 +93,7 @@ export default function LogsPage() {
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
@@ -145,39 +151,43 @@ export default function LogsPage() {
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||
<PluginSlot name="logs:top" />
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t.logs.title}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
className="flex min-w-0 max-w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-3"
|
||||
>
|
||||
<FilterGroup label={t.logs.file}>
|
||||
<FilterGroup label={t.logs.file} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
options={toOptions(FILES)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.level}>
|
||||
<FilterGroup label={t.logs.level} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
options={toOptions(LEVELS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.component}>
|
||||
<FilterGroup label={t.logs.component} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={component}
|
||||
onChange={setComponent}
|
||||
options={toOptions(COMPONENTS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.lines}>
|
||||
<FilterGroup label={t.logs.lines} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={String(lineCount)}
|
||||
onChange={(v) =>
|
||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||
@@ -190,7 +200,7 @@ export default function LogsPage() {
|
||||
</FilterGroup>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
@@ -206,7 +216,7 @@ export default function LogsPage() {
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]"
|
||||
className="max-w-full min-h-[400px] max-h-[calc(100vh-220px)] overflow-auto p-4 font-mono-ui text-xs leading-5 break-words"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
|
||||
@@ -336,7 +336,9 @@ function ModelCard({
|
||||
)?.task ?? null;
|
||||
|
||||
return (
|
||||
<Card className={isMain ? "ring-1 ring-primary/40" : undefined}>
|
||||
<Card
|
||||
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -666,22 +668,20 @@ function ModelSettingsPanel({
|
||||
).length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">Model Settings</CardTitle>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
applies to new sessions
|
||||
</span>
|
||||
</div>
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="min-w-0 pb-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">Model Settings</CardTitle>
|
||||
<span className="max-w-full min-w-0 text-[10px] text-muted-foreground [overflow-wrap:anywhere]">
|
||||
applies to new sessions
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<CardContent className="min-w-0 space-y-3 pt-3">
|
||||
{/* Main row */}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Star className="h-3 w-3 text-primary" />
|
||||
@@ -698,14 +698,14 @@ function ModelSettingsPanel({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPicker({ kind: "main" })}
|
||||
className="text-xs"
|
||||
className="shrink-0 self-start text-xs sm:self-center"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auxiliary tasks summary + open modal */}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Cpu className="h-3 w-3 text-muted-foreground" />
|
||||
@@ -723,7 +723,7 @@ function ModelSettingsPanel({
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setAuxModalOpen(true)}
|
||||
className="text-xs"
|
||||
className="shrink-0 self-start text-xs sm:self-center"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
@@ -827,7 +827,7 @@ export default function ModelsPage() {
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
@@ -864,10 +864,10 @@ export default function ModelsPage() {
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
||||
<PluginSlot name="models:top" />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="grid min-w-0 gap-6 lg:grid-cols-2">
|
||||
<ModelSettingsPanel
|
||||
aux={aux}
|
||||
refreshKey={saveKey}
|
||||
@@ -875,10 +875,12 @@ export default function ModelsPage() {
|
||||
/>
|
||||
|
||||
{data && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<Stats
|
||||
items={
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardContent className="min-w-0 py-6">
|
||||
<div className="min-w-0 max-w-full [&_div.grid]:grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||
<Stats
|
||||
className="min-w-0"
|
||||
items={
|
||||
showTokens
|
||||
? [
|
||||
{
|
||||
@@ -920,6 +922,7 @@ export default function ModelsPage() {
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!showTokens && (
|
||||
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||
Token & cost analytics are hidden because the local counts
|
||||
@@ -953,7 +956,7 @@ export default function ModelsPage() {
|
||||
{data && (
|
||||
<>
|
||||
{data.models.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.models.map((m, i) => (
|
||||
<ModelCard
|
||||
key={`${m.model}:${m.provider}`}
|
||||
|
||||
@@ -60,16 +60,18 @@ export default function PluginsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="shrink-0 gap-2"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.refreshDashboard}
|
||||
</Button>,
|
||||
<div className="flex w-full min-w-0 justify-start">
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="w-max max-w-full shrink-0 gap-2"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.refreshDashboard}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
|
||||
@@ -413,32 +415,20 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-semibold">{row.name}</span>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge tone="outline">
|
||||
{t.pluginsPage.sourceBadge}: {row.source}
|
||||
</Badge>
|
||||
|
||||
<span className="truncate font-semibold">{row.name}</span>
|
||||
<Badge tone="outline">v{row.version || "—"}</Badge>
|
||||
|
||||
<Badge tone="outline">
|
||||
{t.pluginsPage.sourceBadge}: {row.source}
|
||||
</Badge>
|
||||
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
||||
|
||||
|
||||
<Badge tone="outline">v{row.version || "—"}</Badge>
|
||||
|
||||
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
||||
|
||||
{row.auth_required ? (
|
||||
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{row.description ? (
|
||||
|
||||
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
|
||||
{row.description}
|
||||
</p>
|
||||
{row.auth_required ? (
|
||||
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -544,6 +534,12 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{row.description ? (
|
||||
<p className="min-w-0 w-full text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case break-words">
|
||||
{row.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{dm?.slots?.length ? (
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
||||
import spinners from "unicode-animations";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ProfileInfo } from "@/lib/api";
|
||||
@@ -21,6 +22,35 @@ import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
/** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */
|
||||
function ProfilesLoadingSpinner() {
|
||||
const { frames, interval } = spinners.braille;
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(
|
||||
() => setFrameIndex((i) => (i + 1) % frames.length),
|
||||
interval,
|
||||
);
|
||||
return () => window.clearInterval(id);
|
||||
}, [frames.length, interval]);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
|
||||
>
|
||||
{frames[frameIndex]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilesPage() {
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -199,8 +229,14 @@ export default function ProfilesPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="flex items-center justify-center py-24"
|
||||
>
|
||||
<span className="sr-only">{t.common.loading}</span>
|
||||
|
||||
<ProfilesLoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -318,7 +354,7 @@ export default function ProfilesPage() {
|
||||
const isEditingSoul = editingSoulFor === p.name;
|
||||
return (
|
||||
<Card key={p.name}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
{isRenaming ? (
|
||||
|
||||
@@ -83,7 +83,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
||||
parts.push(snippet.slice(last));
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
|
||||
<p className="mt-0.5 min-w-0 max-w-full truncate text-xs text-muted-foreground/80">
|
||||
{parts}
|
||||
</p>
|
||||
);
|
||||
@@ -296,24 +296,24 @@ function SessionRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border overflow-hidden transition-colors ${
|
||||
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
|
||||
session.is_active
|
||||
? "border-success/30 bg-success/[0.03]"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
|
||||
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`shrink-0 ${sourceInfo.color}`}>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
className={`min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
>
|
||||
{hasTitle
|
||||
? session.title
|
||||
@@ -322,71 +322,70 @@ function SessionRow({
|
||||
: t.sessions.untitledSession}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<Badge tone="success" className="text-[10px] shrink-0">
|
||||
<Badge tone="success" className="shrink-0 text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[120px] sm:max-w-[180px]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
|
||||
{(session.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>
|
||||
<span className="shrink-0">
|
||||
{session.message_count} {t.common.msgs}
|
||||
</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>
|
||||
<span className="shrink-0">
|
||||
{session.tool_call_count} {t.common.tools}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span>{timeAgo(session.last_active)}</span>
|
||||
<span className="shrink-0">{timeAgo(session.last_active)}</span>
|
||||
</div>
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge tone="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
{resumeInChatEnabled && (
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="outline" className="text-[10px]">
|
||||
{session.source ?? "local"}
|
||||
</Badge>
|
||||
{resumeInChatEnabled && (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-success"
|
||||
aria-label={t.sessions.resumeInChat}
|
||||
title={t.sessions.resumeInChat}
|
||||
aria-label={t.sessions.deleteSession}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
aria-label={t.sessions.deleteSession}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-background/50 p-4">
|
||||
<div className="min-w-0 border-t border-border bg-background/50 p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-xl text-primary" />
|
||||
@@ -624,7 +623,7 @@ export default function SessionsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
|
||||
<PluginSlot name="sessions:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
@@ -732,28 +731,28 @@ export default function SessionsPage() {
|
||||
)}
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="min-w-0 truncate text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
<CardContent className="grid min-w-0 gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<span className="font-medium text-sm truncate">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="min-w-0 break-words text-xs text-muted-foreground">
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
@@ -762,15 +761,15 @@ export default function SessionsPage() {
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<span className="text-xs text-muted-foreground/70 truncate">
|
||||
<p className="min-w-0 max-w-full text-xs leading-snug text-muted-foreground/70 [overflow-wrap:anywhere]">
|
||||
{s.preview}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
tone="outline"
|
||||
className="text-[10px] shrink-0 self-start sm:self-center"
|
||||
className="shrink-0 self-start text-[10px] sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
@@ -795,7 +794,7 @@ export default function SessionsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function SkillsPage() {
|
||||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 pr-7 text-xs"
|
||||
className="h-8 rounded-none pl-8 pr-7 text-xs"
|
||||
placeholder={t.common.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -256,12 +256,7 @@ export default function SkillsPage() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-0">
|
||||
<div
|
||||
className={`
|
||||
flex flex-col
|
||||
border border-border bg-muted/20
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col rounded-none border border-border bg-muted/20">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
@@ -309,7 +304,7 @@ export default function SkillsPage() {
|
||||
onClick={() =>
|
||||
setActiveCategory(isActive ? null : key)
|
||||
}
|
||||
className="rounded-sm px-2 py-1 text-[11px]"
|
||||
className="rounded-none px-2 py-1 text-[11px]"
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
@@ -333,7 +328,7 @@ export default function SkillsPage() {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isSearching ? (
|
||||
<Card>
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
@@ -372,7 +367,7 @@ export default function SkillsPage() {
|
||||
</Card>
|
||||
) : view === "skills" ? (
|
||||
/* Skills list */
|
||||
<Card>
|
||||
<Card className="rounded-none">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
@@ -417,7 +412,7 @@ export default function SkillsPage() {
|
||||
/* Toolsets grid */
|
||||
<>
|
||||
{filteredToolsets.length === 0 ? (
|
||||
<Card>
|
||||
<Card className="rounded-none">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t.skills.noToolsetsMatch}
|
||||
</CardContent>
|
||||
@@ -431,7 +426,7 @@ export default function SkillsPage() {
|
||||
ts.name;
|
||||
|
||||
return (
|
||||
<Card key={ts.name} className="relative">
|
||||
<Card key={ts.name} className="relative rounded-none">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
@@ -536,7 +531,7 @@ function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-sm whitespace-nowrap px-2.5 py-1.5",
|
||||
"rounded-none whitespace-nowrap px-2.5 py-1.5",
|
||||
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
|
||||
active && "bg-foreground/90 text-background hover:text-background",
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ThemeLayer,
|
||||
ThemeLayout,
|
||||
ThemeLayoutVariant,
|
||||
ThemeListEntry,
|
||||
ThemePalette,
|
||||
ThemeTypography,
|
||||
} from "./types";
|
||||
@@ -311,7 +312,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
/** All selectable themes (shown in the picker). Starts with just the
|
||||
* built-ins; the API call below merges in user themes. */
|
||||
const [availableThemes, setAvailableThemes] = useState<ThemeSummary[]>(() =>
|
||||
const [availableThemes, setAvailableThemes] = useState<ThemeListEntry[]>(() =>
|
||||
Object.values(BUILTIN_THEMES).map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
@@ -429,15 +430,8 @@ const ThemeContext = createContext<ThemeContextValue>({
|
||||
});
|
||||
|
||||
interface ThemeContextValue {
|
||||
availableThemes: ThemeSummary[];
|
||||
availableThemes: ThemeListEntry[];
|
||||
setTheme: (name: string) => void;
|
||||
theme: DashboardTheme;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
interface ThemeSummary {
|
||||
description: string;
|
||||
label: string;
|
||||
name: string;
|
||||
definition?: DashboardTheme;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { ThemeProvider, useTheme } from "./context";
|
||||
export { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
export type { DashboardTheme, ThemeLayer, ThemeListResponse, ThemePalette } from "./types";
|
||||
export type { DashboardTheme, ThemeLayer, ThemeListEntry, ThemeListResponse, ThemePalette } from "./types";
|
||||
|
||||
+21
-2
@@ -862,13 +862,32 @@ class BatchRunner:
|
||||
"last_updated": None
|
||||
}
|
||||
|
||||
# Prepare configuration for workers
|
||||
# Prepare configuration for workers.
|
||||
#
|
||||
# ``self.api_key`` may be a zero-arg callable (Azure Foundry Entra ID
|
||||
# bearer provider returned by ``agent.azure_identity_adapter``). Such
|
||||
# closures are not safely picklable across the multiprocessing.Pool
|
||||
# boundary. Drop the callable here and let each worker rebuild its
|
||||
# own provider via ``resolve_runtime_provider()``, which reads
|
||||
# ``model.auth_mode`` from ``config.yaml`` and constructs a fresh
|
||||
# token provider in the worker process (azure-identity caches
|
||||
# in-process so each worker gets its own short-lived cache).
|
||||
if callable(self.api_key) and not isinstance(self.api_key, str):
|
||||
worker_api_key = None
|
||||
print(
|
||||
"ℹ️ Detected Entra ID bearer provider — workers will rebuild "
|
||||
"credentials from config.yaml in each process.",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
worker_api_key = self.api_key
|
||||
|
||||
config = {
|
||||
"distribution": self.distribution,
|
||||
"model": self.model,
|
||||
"max_iterations": self.max_iterations,
|
||||
"base_url": self.base_url,
|
||||
"api_key": self.api_key,
|
||||
"api_key": worker_api_key,
|
||||
"verbose": self.verbose,
|
||||
"ephemeral_system_prompt": self.ephemeral_system_prompt,
|
||||
"log_prefix_chars": self.log_prefix_chars,
|
||||
|
||||
@@ -30,6 +30,7 @@ model:
|
||||
# "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings)
|
||||
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
|
||||
# "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
|
||||
# "azure-foundry" - Microsoft Foundry / Azure OpenAI (API key or Entra ID)
|
||||
# "lmstudio" - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1)
|
||||
#
|
||||
# Local servers (LM Studio, Ollama, vLLM, llama.cpp):
|
||||
@@ -45,6 +46,14 @@ model:
|
||||
# api_key: "your-key-here" # Uncomment to set here instead of .env
|
||||
base_url: "https://openrouter.ai/api/v1"
|
||||
|
||||
# Azure Foundry keyless auth example:
|
||||
# provider: "azure-foundry"
|
||||
# base_url: "https://<resource>.openai.azure.com/openai/v1"
|
||||
# auth_mode: "entra_id" # DefaultAzureCredential: az login, managed identity, workload identity, etc.
|
||||
# default: "gpt-4o" # Deployment/model name
|
||||
# entra:
|
||||
# scope: "https://ai.azure.com/.default" # Optional; this is the default.
|
||||
|
||||
# ── Token limits — two settings, easy to confuse ──────────────────────────
|
||||
#
|
||||
# context_length: TOTAL context window (input + output tokens combined).
|
||||
|
||||
@@ -655,9 +655,58 @@ except Exception:
|
||||
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
|
||||
# close TCP transports bound to dead worker loops — producing
|
||||
# "Event loop is closed" / "Press ENTER to continue..." errors.
|
||||
#
|
||||
# We install a sys.meta_path finder that defers the actual import + patch
|
||||
# until ``openai._base_client`` is first loaded by the rest of the codebase.
|
||||
# Eagerly importing it here (the old approach) cost ~166ms / ~30MB on every
|
||||
# cold CLI start because openai's type tree (responses/*, graders/*) is huge.
|
||||
# The finder approach pays nothing until the SDK is genuinely needed and
|
||||
# still guarantees the patch is applied before any AsyncOpenAI instance can
|
||||
# be constructed (the import-then-instantiate ordering is enforced by
|
||||
# Python's import system).
|
||||
try:
|
||||
from agent.auxiliary_client import neuter_async_httpx_del
|
||||
neuter_async_httpx_del()
|
||||
import sys as _httpx_neuter_sys
|
||||
import importlib.util as _httpx_neuter_imp_util
|
||||
|
||||
class _AsyncHttpxDelNeuter:
|
||||
"""Defer ``AsyncHttpxClientWrapper.__del__`` neutering until import.
|
||||
|
||||
Saves ~166ms on cold CLI start where openai is never used (e.g.
|
||||
``hermes --help`` paths inside the chat command flow). See
|
||||
``agent.auxiliary_client.neuter_async_httpx_del`` for full rationale
|
||||
on why ``__del__`` must be a no-op.
|
||||
"""
|
||||
|
||||
_armed = True
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if not self._armed or fullname != "openai._base_client":
|
||||
return None
|
||||
# Disarm before delegating so the recursive find_spec call
|
||||
# below doesn't loop through us.
|
||||
self._armed = False
|
||||
try:
|
||||
_httpx_neuter_sys.meta_path.remove(self)
|
||||
except ValueError:
|
||||
pass
|
||||
spec = _httpx_neuter_imp_util.find_spec(fullname)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
_orig_exec = spec.loader.exec_module
|
||||
|
||||
def _patched_exec(module):
|
||||
_orig_exec(module)
|
||||
try:
|
||||
cls = getattr(module, "AsyncHttpxClientWrapper", None)
|
||||
if cls is not None:
|
||||
cls.__del__ = lambda self: None # type: ignore[assignment]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
spec.loader.exec_module = _patched_exec # type: ignore[method-assign]
|
||||
return spec
|
||||
|
||||
_httpx_neuter_sys.meta_path.insert(0, _AsyncHttpxDelNeuter())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -940,6 +989,37 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
|
||||
return info
|
||||
|
||||
|
||||
def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> bool:
|
||||
"""Return whether a worktree has commits not reachable from any remote branch.
|
||||
|
||||
``git log HEAD --not --remotes`` compares against remote-tracking refs under
|
||||
``refs/remotes/*``. If a repo has no remote-tracking refs yet, there is no
|
||||
usable remote baseline to compare against, so treat it as having no
|
||||
"unpushed" commits.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
remote_refs = subprocess.run(
|
||||
["git", "for-each-ref", "--format=%(refname)", "refs/remotes"],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
|
||||
)
|
||||
if remote_refs.returncode != 0:
|
||||
return True
|
||||
if not remote_refs.stdout.strip():
|
||||
return False
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return True
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
"""Remove a worktree and its branch on exit.
|
||||
|
||||
@@ -962,18 +1042,7 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
if not Path(wt_path).exists():
|
||||
return
|
||||
|
||||
# Check for unpushed commits — commits reachable from HEAD but not
|
||||
# from any remote branch. These represent real work the agent did
|
||||
# but didn't push.
|
||||
has_unpushed = False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
|
||||
capture_output=True, text=True, timeout=10, cwd=wt_path,
|
||||
)
|
||||
has_unpushed = bool(result.stdout.strip())
|
||||
except Exception:
|
||||
has_unpushed = True # Assume unpushed on error — don't delete
|
||||
has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10)
|
||||
|
||||
if has_unpushed:
|
||||
print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
|
||||
@@ -1121,15 +1190,8 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
|
||||
if not force:
|
||||
# 24h–72h tier: only remove if no unpushed commits
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
|
||||
capture_output=True, text=True, timeout=5, cwd=str(entry),
|
||||
)
|
||||
if result.stdout.strip():
|
||||
continue # Has unpushed commits — skip
|
||||
except Exception:
|
||||
continue # Can't check — skip
|
||||
if _worktree_has_unpushed_commits(str(entry), timeout=5):
|
||||
continue # Has unpushed commits or can't check — skip
|
||||
|
||||
# Safe to remove
|
||||
try:
|
||||
@@ -1569,7 +1631,14 @@ def _rich_text_from_ansi(text: str) -> _RichText:
|
||||
def _strip_markdown_syntax(text: str) -> str:
|
||||
"""Best-effort markdown marker removal for plain-text display."""
|
||||
plain = _rich_text_from_ansi(text or "").plain
|
||||
plain = re.sub(r"^\s{0,3}(?:[-*_]\s*){3,}$", "", plain, flags=re.MULTILINE)
|
||||
# Avoid stripping cron-style expressions like "* * * * *" as if they were
|
||||
# Markdown horizontal rules. CommonMark treats three or more "*" as an HR,
|
||||
# but in Hermes output it's common to display cron schedules verbatim.
|
||||
#
|
||||
# Keep the behavior for "-" / "_" HR markers, and only strip "*" HR lines
|
||||
# when there are exactly 3 asterisks (with optional whitespace).
|
||||
plain = re.sub(r"^\s{0,3}(?:[-_]\s*){3,}$", "", plain, flags=re.MULTILINE)
|
||||
plain = re.sub(r"^\s{0,3}(?:\*\s*){3}\s*$", "", plain, flags=re.MULTILINE)
|
||||
plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE)
|
||||
# Preserve blockquotes, lists, and checkboxes because they carry structure.
|
||||
plain = re.sub(r"(```+|~~~+)", "", plain)
|
||||
@@ -1580,7 +1649,9 @@ def _strip_markdown_syntax(text: str) -> str:
|
||||
plain = re.sub(r"(?<!\w)___([^_]+)___(?!\w)", r"\1", plain)
|
||||
plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", plain)
|
||||
plain = re.sub(r"(?<!\w)__([^_]+)__(?!\w)", r"\1", plain)
|
||||
plain = re.sub(r"\*([^*]+)\*", r"\1", plain)
|
||||
# Only strip `*emphasis*` markers when the inner text is non-whitespace.
|
||||
# This avoids corrupting cron expressions like "* * * * *".
|
||||
plain = re.sub(r"\*([^\s*][^*]*?[^\s*])\*", r"\1", plain)
|
||||
plain = re.sub(r"(?<!\w)_([^_]+)_(?!\w)", r"\1", plain)
|
||||
plain = re.sub(r"~~([^~]+)~~", r"\1", plain)
|
||||
plain = re.sub(r"\n{3,}", "\n\n", plain)
|
||||
@@ -1785,7 +1856,16 @@ def _cprint(text: str):
|
||||
# direct prompt_toolkit print is safe and matches existing behavior
|
||||
# (spinner frames, streamed tokens, tool activity prefixes, …).
|
||||
if app is None or not getattr(app, "_is_running", False):
|
||||
_pt_print(_PT_ANSI(text))
|
||||
try:
|
||||
_pt_print(_PT_ANSI(text))
|
||||
except Exception:
|
||||
# Fallback when stdout is not a real console (e.g. subprocess
|
||||
# worker logging to a file). prompt_toolkit raises
|
||||
# NoConsoleScreenBufferError (Windows) or OSError (other).
|
||||
try:
|
||||
print(text)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -1817,13 +1897,26 @@ def _cprint(text: str):
|
||||
# prompt, prints, and redraws. Fire-and-forget — if scheduling
|
||||
# fails we fall back to a direct print so the line isn't lost.
|
||||
def _schedule():
|
||||
# run_in_terminal() may return either:
|
||||
# • a coroutine / Future (prompt_toolkit ≥ 3.0) — must be scheduled
|
||||
# via ensure_future so the coroutine is actually awaited; calling
|
||||
# it bare would leave it unawaited and silently drop the output
|
||||
# (fixes #23185 Bug A).
|
||||
# • None (some mocks / older PT builds) — just call the inner
|
||||
# function directly since PT already executed it synchronously.
|
||||
# Do NOT fall back to a bare _pt_print when ensure_future raises,
|
||||
# because run_in_terminal already invoked the lambda in that case
|
||||
# (the mock path), which would double-print the line.
|
||||
try:
|
||||
run_in_terminal(lambda: _pt_print(_PT_ANSI(text)))
|
||||
import asyncio as _aio
|
||||
import inspect as _inspect
|
||||
coro = run_in_terminal(lambda: _pt_print(_PT_ANSI(text)))
|
||||
if coro is not None and (_inspect.isawaitable(coro) or _inspect.iscoroutine(coro)):
|
||||
_aio.ensure_future(coro)
|
||||
# else: run_in_terminal ran the lambda synchronously; nothing more
|
||||
# to do (double-scheduling would print twice).
|
||||
except Exception:
|
||||
try:
|
||||
_pt_print(_PT_ANSI(text))
|
||||
except Exception:
|
||||
pass
|
||||
pass # best-effort; the line may already have been printed
|
||||
|
||||
try:
|
||||
loop.call_soon_threadsafe(_schedule)
|
||||
@@ -2416,8 +2509,13 @@ from agent.skill_commands import (
|
||||
build_skill_invocation_message,
|
||||
build_preloaded_skills_prompt,
|
||||
)
|
||||
from agent.skill_bundles import (
|
||||
get_skill_bundles,
|
||||
build_bundle_invocation_message,
|
||||
)
|
||||
|
||||
_skill_commands = scan_skill_commands()
|
||||
_skill_bundles = get_skill_bundles()
|
||||
|
||||
|
||||
def _get_plugin_cmd_handler_names() -> set:
|
||||
@@ -2830,6 +2928,11 @@ class HermesCLI:
|
||||
# process_command() when the user runs /exit --delete or /quit --delete.
|
||||
# Ported from google-gemini/gemini-cli#19332.
|
||||
self._delete_session_on_exit = False
|
||||
# /update: when set, run() executes relaunch() after prompt_toolkit
|
||||
# has fully exited and cleaned up terminal modes. Set by
|
||||
# _handle_update_command() so the relaunch happens on the main thread,
|
||||
# not the background process_loop thread.
|
||||
self._pending_relaunch: list[str] | None = None
|
||||
self._last_ctrl_c_time = 0
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
@@ -4226,7 +4329,13 @@ class HermesCLI:
|
||||
resolved_acp_command = runtime.get("command")
|
||||
resolved_acp_args = list(runtime.get("args") or [])
|
||||
resolved_credential_pool = runtime.get("credential_pool")
|
||||
if not isinstance(api_key, str) or not api_key:
|
||||
# A callable api_key is a bearer-token provider (Azure Foundry
|
||||
# Entra ID — ``azure_identity_adapter.build_token_provider``).
|
||||
# The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and
|
||||
# invokes it before every request. Skip the string-only validation
|
||||
# and placeholder substitution for callables.
|
||||
_is_callable_provider = callable(api_key) and not isinstance(api_key, str)
|
||||
if not _is_callable_provider and (not isinstance(api_key, str) or not api_key):
|
||||
# Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often
|
||||
# don't require authentication. When a base_url IS configured but
|
||||
# no API key was found, use a placeholder so the OpenAI SDK
|
||||
@@ -5492,6 +5601,17 @@ class HermesCLI:
|
||||
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
|
||||
)
|
||||
|
||||
_bundles_now = get_skill_bundles()
|
||||
if _bundles_now:
|
||||
_cprint(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(_bundles_now)} installed):")
|
||||
for cmd, info in sorted(_bundles_now.items()):
|
||||
skill_count = len(info.get("skills", []))
|
||||
desc = info.get("description") or f"Load {skill_count} skills"
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] "
|
||||
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
|
||||
)
|
||||
|
||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
|
||||
@@ -5680,7 +5800,15 @@ class HermesCLI:
|
||||
config_path = project_config_path
|
||||
config_status = "(loaded)" if config_path.exists() else "(not found)"
|
||||
|
||||
api_key_display = '********' + self.api_key[-4:] if self.api_key and len(self.api_key) > 4 else 'Not set!'
|
||||
# ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer
|
||||
# provider). Never invoke it; just identify the auth surface.
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
if is_token_provider(self.api_key):
|
||||
api_key_display = "Microsoft Entra ID"
|
||||
elif isinstance(self.api_key, str) and len(self.api_key) > 12:
|
||||
api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}"
|
||||
else:
|
||||
api_key_display = "Not set!"
|
||||
|
||||
print()
|
||||
title = "(^_^) Configuration"
|
||||
@@ -7891,6 +8019,9 @@ class HermesCLI:
|
||||
self._handle_copy_command(cmd_original)
|
||||
elif canonical == "debug":
|
||||
self._handle_debug_command()
|
||||
elif canonical == "update":
|
||||
if self._handle_update_command():
|
||||
return False
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "image":
|
||||
@@ -7907,6 +8038,8 @@ class HermesCLI:
|
||||
elif canonical == "reload-skills":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_skills()
|
||||
elif canonical == "bundles":
|
||||
self._handle_bundles_command(cmd_original)
|
||||
elif canonical == "browser":
|
||||
self._handle_browser_command(cmd_original)
|
||||
elif canonical == "plugins":
|
||||
@@ -8043,6 +8176,30 @@ class HermesCLI:
|
||||
_cprint(str(result))
|
||||
except Exception as e:
|
||||
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
|
||||
# Skill bundles take precedence over individual skills — /<bundle>
|
||||
# loads multiple skills at once. Rescans cheaply when files change.
|
||||
elif base_cmd in get_skill_bundles():
|
||||
user_instruction = cmd_original[len(base_cmd):].strip()
|
||||
bundle_result = build_bundle_invocation_message(
|
||||
base_cmd, user_instruction, task_id=self.session_id
|
||||
)
|
||||
if bundle_result:
|
||||
msg, loaded_names, missing = bundle_result
|
||||
bundle_info = get_skill_bundles()[base_cmd]
|
||||
print(
|
||||
f"\n⚡ Loading bundle: {bundle_info['name']} "
|
||||
f"({len(loaded_names)} skills)"
|
||||
)
|
||||
if missing:
|
||||
ChatConsole().print(
|
||||
f"[yellow]Skipped missing skills: {', '.join(missing)}[/]"
|
||||
)
|
||||
if hasattr(self, '_pending_input'):
|
||||
self._pending_input.put(msg)
|
||||
else:
|
||||
ChatConsole().print(
|
||||
f"[bold red]Failed to load bundle for {base_cmd}[/]"
|
||||
)
|
||||
# Check for skill slash commands (/gif-search, /axolotl, etc.)
|
||||
elif base_cmd in _skill_commands:
|
||||
user_instruction = cmd_original[len(base_cmd):].strip()
|
||||
@@ -8062,7 +8219,7 @@ class HermesCLI:
|
||||
# that execution-time resolution agrees with tab-completion.
|
||||
from hermes_cli.commands import COMMANDS
|
||||
typed_base = cmd_lower.split()[0]
|
||||
all_known = set(COMMANDS) | set(_skill_commands)
|
||||
all_known = set(COMMANDS) | set(_skill_commands) | set(get_skill_bundles())
|
||||
matches = [c for c in all_known if c.startswith(typed_base)]
|
||||
if len(matches) > 1:
|
||||
# Prefer an exact match (typed the full command name)
|
||||
@@ -8263,6 +8420,44 @@ class HermesCLI:
|
||||
"""
|
||||
return try_launch_chrome_debug(port, system)
|
||||
|
||||
def _handle_bundles_command(self, cmd: str) -> None:
|
||||
"""In-session ``/bundles`` — show installed skill bundles.
|
||||
|
||||
Mirrors ``hermes bundles list`` but renders inside the running
|
||||
CLI so users can discover what's available without dropping out
|
||||
of their session. Bundles are loaded via ``/<bundle-name>``.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_bundles import list_bundles, _bundles_dir
|
||||
except Exception as exc:
|
||||
_cprint(f"\033[1;31mBundle subsystem unavailable: {exc}{_RST}")
|
||||
return
|
||||
|
||||
bundles = list_bundles()
|
||||
if not bundles:
|
||||
_cprint(" No skill bundles installed.")
|
||||
_cprint(
|
||||
f" {_DIM}Create one with: hermes bundles create "
|
||||
f"<name> --skill <s1> --skill <s2>{_RST}"
|
||||
)
|
||||
_cprint(f" {_DIM}Directory: {_bundles_dir()}{_RST}")
|
||||
return
|
||||
|
||||
_cprint(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(bundles)} installed):")
|
||||
for info in bundles:
|
||||
skill_count = len(info.get("skills", []))
|
||||
desc = info.get("description") or f"Load {skill_count} skills"
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]/{info['slug']:<20}[/] "
|
||||
f"[dim]-[/] {_escape(desc)} [dim]({skill_count} skills)[/]"
|
||||
)
|
||||
for s in info.get("skills", []):
|
||||
ChatConsole().print(f" [dim]· {_escape(s)}[/]")
|
||||
_cprint(
|
||||
f"\n {_DIM}Invoke a bundle with /<slug>. "
|
||||
f"Manage with `hermes bundles`.{_RST}"
|
||||
)
|
||||
|
||||
def _handle_browser_command(self, cmd: str):
|
||||
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
|
||||
import platform as _plat
|
||||
@@ -9126,6 +9321,7 @@ class HermesCLI:
|
||||
None,
|
||||
approx_tokens=approx_tokens,
|
||||
focus_topic=focus_topic or None,
|
||||
force=True,
|
||||
)
|
||||
self.conversation_history = compressed
|
||||
# _compress_context ends the old session and creates a new child
|
||||
@@ -9172,6 +9368,58 @@ class HermesCLI:
|
||||
args = SimpleNamespace(lines=200, expire=7, local=False)
|
||||
run_debug_share(args)
|
||||
|
||||
def _handle_update_command(self) -> bool:
|
||||
"""Handle /update — update Hermes Agent to the latest version.
|
||||
|
||||
In the classic CLI this exits the session and relaunches as
|
||||
``hermes update`` so the user sees update output directly and gets
|
||||
the new version on next launch.
|
||||
|
||||
Returns ``True`` when the update was confirmed (caller should trigger
|
||||
app exit so the relaunch is deferred to the main thread after
|
||||
prompt_toolkit cleans up terminal modes). Returns ``False`` / falsy
|
||||
when cancelled.
|
||||
"""
|
||||
from hermes_cli.config import is_managed, format_managed_message
|
||||
|
||||
if is_managed():
|
||||
print(f" ✗ {format_managed_message('update Hermes Agent')}")
|
||||
return False
|
||||
|
||||
# Use the prompt_toolkit-native modal so the confirmation panel
|
||||
# renders properly above the composer and avoids raw input() races
|
||||
# with the prompt_toolkit event loop (same pattern as
|
||||
# _confirm_destructive_slash).
|
||||
choices = [
|
||||
("once", "Update Now", "exit the current session and update Hermes Agent"),
|
||||
("cancel", "Cancel", "keep the current session"),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="⚕ Update Hermes Agent",
|
||||
detail="This will exit the current session and run `hermes update`.",
|
||||
choices=choices,
|
||||
)
|
||||
if raw is None:
|
||||
print(" 🟡 /update cancelled.")
|
||||
return False
|
||||
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||
if choice != "once":
|
||||
print(" 🟡 /update cancelled.")
|
||||
return False
|
||||
|
||||
print()
|
||||
print(" ⚕ Launching update...")
|
||||
print()
|
||||
|
||||
# Store the relaunch args so run() can exec them from the main thread
|
||||
# after prompt_toolkit exits and restores terminal modes. Calling
|
||||
# relaunch() directly here (from the process_loop daemon thread) would
|
||||
# skip terminal cleanup on POSIX (execvp replaces the process mid-TUI)
|
||||
# and only exit the worker thread on Windows (subprocess.run +
|
||||
# sys.exit inside a non-main thread does not exit the process).
|
||||
self._pending_relaunch = ["update"]
|
||||
return True
|
||||
|
||||
def _show_usage(self):
|
||||
"""Show rate limits (if available) and session token usage."""
|
||||
if not self.agent:
|
||||
@@ -12633,6 +12881,7 @@ class HermesCLI:
|
||||
_completer = SlashCommandCompleter(
|
||||
skill_commands_provider=lambda: get_skill_commands(),
|
||||
command_filter=cli_ref._command_available,
|
||||
skill_bundles_provider=lambda: get_skill_bundles(),
|
||||
)
|
||||
input_area = TextArea(
|
||||
height=Dimension(min=1, max=8, preferred=1),
|
||||
@@ -13677,7 +13926,31 @@ class HermesCLI:
|
||||
time.sleep(_grace)
|
||||
except Exception:
|
||||
pass # never block signal handling
|
||||
raise KeyboardInterrupt()
|
||||
# Prefer a clean prompt_toolkit exit over `raise KeyboardInterrupt()`.
|
||||
# Raising KBI from a signal handler unwinds into whatever Python
|
||||
# frame the interpreter happens to be running — typically an
|
||||
# `await asyncio.sleep()` inside prompt_toolkit's
|
||||
# `_poll_output_size` coroutine. The KBI becomes a Task
|
||||
# exception, prompt_toolkit's `_handle_exception` prints
|
||||
# "Unhandled exception in event loop" + the full traceback, and
|
||||
# parks the terminal on "Press ENTER to continue..." (#13710
|
||||
# variant — same root cause, different surface).
|
||||
#
|
||||
# `app.exit()` scheduled via `call_soon_threadsafe` lets the
|
||||
# event loop unwind normally; `app.run()` returns and our
|
||||
# existing `except (EOFError, KeyboardInterrupt, BrokenPipeError)`
|
||||
# block at the bottom of the input loop handles the rest.
|
||||
try:
|
||||
from prompt_toolkit.application.current import get_app_or_none
|
||||
_app = get_app_or_none()
|
||||
if _app is not None:
|
||||
_loop = getattr(_app, "loop", None)
|
||||
if _loop is not None:
|
||||
_loop.call_soon_threadsafe(_app.exit)
|
||||
return # clean unwind — no traceback, no ENTER pause
|
||||
except Exception:
|
||||
pass
|
||||
raise KeyboardInterrupt() # fallback for non-prompt_toolkit contexts
|
||||
|
||||
try:
|
||||
import signal as _signal
|
||||
@@ -13879,6 +14152,15 @@ class HermesCLI:
|
||||
_run_cleanup()
|
||||
self._print_exit_summary()
|
||||
|
||||
# Deferred relaunch: /update sets _pending_relaunch so the exec
|
||||
# happens here — after prompt_toolkit has exited and fully restored
|
||||
# terminal modes — rather than from the background process_loop
|
||||
# thread (which would skip terminal cleanup on POSIX and only exit
|
||||
# the worker thread on Windows).
|
||||
if getattr(self, '_pending_relaunch', None):
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(self._pending_relaunch, preserve_inherited=False)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
|
||||
@@ -128,6 +128,9 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state = "scheduled" if normalized.get("enabled", True) else "paused"
|
||||
normalized["state"] = state
|
||||
|
||||
profile = _coerce_job_text(normalized.get("profile")).strip()
|
||||
normalized["profile"] = profile or None
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -479,6 +482,30 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _normalize_profile(profile: Optional[str]) -> Optional[str]:
|
||||
"""Normalize and validate an optional cron job profile name.
|
||||
|
||||
Empty / None disables per-job profile selection. Otherwise the profile name
|
||||
is canonicalized with the same rules as ``hermes -p`` and must refer to an
|
||||
existing profile at create/update time. ``default`` is the built-in root
|
||||
profile and is always valid.
|
||||
"""
|
||||
if profile is None:
|
||||
return None
|
||||
raw = str(profile).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
|
||||
|
||||
normalized = normalize_profile_name(raw)
|
||||
# resolve_profile_env validates the canonical name and checks that named
|
||||
# profiles exist. Store only the stable profile id, not the filesystem path,
|
||||
# so profile directories can move with the Hermes root.
|
||||
resolve_profile_env(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def create_job(
|
||||
prompt: Optional[str],
|
||||
schedule: str,
|
||||
@@ -495,6 +522,7 @@ def create_job(
|
||||
context_from: Optional[Union[str, List[str]]] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
workdir: Optional[str] = None,
|
||||
profile: Optional[str] = None,
|
||||
no_agent: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -536,6 +564,11 @@ def create_job(
|
||||
With ``no_agent=True``, ``workdir`` is still applied as the
|
||||
script's cwd so relative paths inside the script behave
|
||||
predictably.
|
||||
profile: Optional Hermes profile name. When set, the job runs with
|
||||
that profile's HERMES_HOME so profile-specific config,
|
||||
credentials, scripts, skills, and memory paths resolve
|
||||
consistently. ``default`` selects the root profile; empty /
|
||||
None preserves the scheduler's existing behaviour.
|
||||
no_agent: When True, skip the agent entirely — run ``script`` on schedule
|
||||
and deliver its stdout directly. Empty stdout = silent (no
|
||||
delivery). Requires ``script`` to be set. Ideal for classic
|
||||
@@ -573,6 +606,7 @@ def create_job(
|
||||
normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None
|
||||
normalized_toolsets = normalized_toolsets or None
|
||||
normalized_workdir = _normalize_workdir(workdir)
|
||||
normalized_profile = _normalize_profile(profile)
|
||||
normalized_no_agent = bool(no_agent)
|
||||
|
||||
# no_agent jobs are meaningless without a script — the script IS the job.
|
||||
@@ -627,6 +661,7 @@ def create_job(
|
||||
"origin": origin, # Tracks where job was created for "origin" delivery
|
||||
"enabled_toolsets": normalized_toolsets,
|
||||
"workdir": normalized_workdir,
|
||||
"profile": normalized_profile,
|
||||
}
|
||||
|
||||
jobs = load_jobs()
|
||||
@@ -707,6 +742,15 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
|
||||
# Validate / normalize profile if present in updates. Empty string or
|
||||
# None both mean "clear the field" (restore old behaviour).
|
||||
if "profile" in updates:
|
||||
_profile = updates["profile"]
|
||||
if _profile is None or _profile == "" or _profile is False:
|
||||
updates["profile"] = None
|
||||
else:
|
||||
updates["profile"] = _normalize_profile(_profile)
|
||||
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
|
||||
|
||||
+150
-17
@@ -17,6 +17,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
|
||||
# fcntl is Unix-only; on Windows use msvcrt for file locking
|
||||
try:
|
||||
@@ -36,6 +37,7 @@ from typing import List, Optional
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
from hermes_cli.config import load_config, _expand_env_vars
|
||||
from hermes_time import now as _hermes_now
|
||||
|
||||
@@ -145,6 +147,71 @@ def _get_lock_paths() -> tuple[Path, Path]:
|
||||
return lock_dir, lock_dir / ".tick.lock"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _job_profile_context(job_id: str, profile: Optional[str]):
|
||||
"""Temporarily run a job under a specific Hermes profile.
|
||||
|
||||
Cron jobs are stored and scheduled by the profile running the scheduler, but
|
||||
an individual job can opt into a different runtime profile. While active,
|
||||
the scheduler's test/override hook and a context-local Hermes home override
|
||||
both point at the resolved profile directory so _get_hermes_home(),
|
||||
.env/config loading, script resolution, AIAgent construction, and downstream
|
||||
get_hermes_home() callers agree on the same home.
|
||||
|
||||
Some existing provider/config paths still load profile .env values through
|
||||
os.environ, so profile jobs also snapshot and restore the process
|
||||
environment on exit. tick() runs profile jobs sequentially to keep that
|
||||
temporary mutation isolated from other scheduled jobs.
|
||||
"""
|
||||
raw_profile = str(profile or "").strip()
|
||||
if not raw_profile:
|
||||
yield None
|
||||
return
|
||||
|
||||
global _hermes_home
|
||||
prior_override = _hermes_home
|
||||
env_snapshot = os.environ.copy()
|
||||
|
||||
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
normalized_profile = normalize_profile_name(raw_profile)
|
||||
try:
|
||||
profile_home = Path(resolve_profile_env(normalized_profile)).resolve()
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Job '%s': configured profile %r no longer valid (%s) — "
|
||||
"falling back to scheduler default",
|
||||
job_id, raw_profile, exc,
|
||||
)
|
||||
yield None
|
||||
return
|
||||
|
||||
override_token = None
|
||||
try:
|
||||
override_token = set_hermes_home_override(profile_home)
|
||||
_hermes_home = profile_home
|
||||
logger.info(
|
||||
"Job '%s': using Hermes profile '%s' (%s)",
|
||||
job_id,
|
||||
normalized_profile,
|
||||
profile_home,
|
||||
)
|
||||
yield normalized_profile
|
||||
finally:
|
||||
_hermes_home = prior_override
|
||||
if override_token is not None:
|
||||
reset_hermes_home_override(override_token)
|
||||
# Delta-based restore: remove added keys, restore changed keys.
|
||||
# Avoids a brief window where other threads see an empty env.
|
||||
added = set(os.environ.keys()) - set(env_snapshot.keys())
|
||||
for k in added:
|
||||
os.environ.pop(k, None)
|
||||
for k, v in env_snapshot.items():
|
||||
if os.environ.get(k) != v:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _resolve_origin(job: dict) -> Optional[dict]:
|
||||
"""Extract origin info from a job, preserving any extra routing metadata.
|
||||
|
||||
@@ -226,10 +293,23 @@ def _get_home_target_chat_id(platform_name: str) -> str:
|
||||
|
||||
|
||||
def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
|
||||
"""Return the optional thread/topic ID for a platform home target."""
|
||||
"""Return the optional thread/topic ID for a platform home target.
|
||||
|
||||
Telegram-only override: ``TELEGRAM_CRON_THREAD_ID`` takes precedence over
|
||||
``TELEGRAM_HOME_CHANNEL_THREAD_ID`` for cron delivery. When topic mode is
|
||||
enabled, deliveries that land in the root DM (thread_id unset) end up in
|
||||
the system-only lobby where the user cannot reply — the gateway returns
|
||||
the lobby reminder and drops ``reply_to_message_id`` (#24409). Pointing
|
||||
cron at a dedicated topic via this env var lets replies work as expected
|
||||
without changing the lobby invariant.
|
||||
"""
|
||||
env_var = _resolve_home_env_var(platform_name)
|
||||
if not env_var:
|
||||
return None
|
||||
if platform_name.lower() == "telegram":
|
||||
cron_thread = os.getenv("TELEGRAM_CRON_THREAD_ID", "").strip()
|
||||
if cron_thread:
|
||||
return cron_thread
|
||||
value = os.getenv(f"{env_var}_THREAD_ID", "").strip()
|
||||
if not value:
|
||||
legacy = _LEGACY_HOME_TARGET_ENV_VARS.get(env_var)
|
||||
@@ -612,6 +692,19 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
job["id"], platform_name, chat_id, err,
|
||||
)
|
||||
adapter_ok = False # fall through to standalone path
|
||||
elif (
|
||||
send_result
|
||||
and thread_id
|
||||
and getattr(send_result, "raw_response", None)
|
||||
and send_result.raw_response.get("thread_fallback")
|
||||
):
|
||||
requested_thread_id = send_result.raw_response.get("requested_thread_id") or thread_id
|
||||
msg = (
|
||||
f"configured thread_id {requested_thread_id} for "
|
||||
f"{platform_name}:{chat_id} was not found; delivered without thread_id"
|
||||
)
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
|
||||
# Send extracted media files as native attachments via the live adapter
|
||||
if adapter_ok and media_files:
|
||||
@@ -732,8 +825,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
(success, output) — on failure *output* contains the error message so the
|
||||
LLM can report the problem to the user.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
scripts_dir = _get_hermes_home() / "scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
scripts_dir_resolved = scripts_dir.resolve()
|
||||
@@ -785,13 +876,27 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
else:
|
||||
argv = [sys.executable, str(path)]
|
||||
|
||||
run_env = os.environ.copy()
|
||||
run_env["HERMES_HOME"] = str(_get_hermes_home())
|
||||
try:
|
||||
from hermes_constants import get_subprocess_home
|
||||
|
||||
profile_home = get_subprocess_home()
|
||||
if profile_home:
|
||||
run_env["HOME"] = profile_home
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=script_timeout,
|
||||
cwd=str(path.parent),
|
||||
env=run_env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
stdout = (result.stdout or "").strip()
|
||||
stderr = (result.stderr or "").strip()
|
||||
@@ -958,7 +1063,12 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
parts = []
|
||||
skipped: list[str] = []
|
||||
for skill_name in skill_names:
|
||||
loaded = json.loads(skill_view(skill_name))
|
||||
try:
|
||||
loaded = json.loads(skill_view(skill_name))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Cron job '%s': skill '%s' returned invalid JSON, skipping", job.get("name", job.get("id")), skill_name)
|
||||
skipped.append(skill_name)
|
||||
continue
|
||||
if not loaded.get("success"):
|
||||
error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
|
||||
logger.warning("Cron job '%s': skill not found, skipping — %s", job.get("name", job.get("id")), error)
|
||||
@@ -1022,6 +1132,13 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict) -> str:
|
||||
|
||||
|
||||
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"""Execute a single cron job, applying any per-job profile override."""
|
||||
job_id = job["id"]
|
||||
with _job_profile_context(job_id, job.get("profile")):
|
||||
return _run_job_impl(job)
|
||||
|
||||
|
||||
def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"""
|
||||
Execute a single cron job.
|
||||
|
||||
@@ -1258,8 +1375,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
# .cursorrules from the job's project dir, AND
|
||||
# - the terminal, file, and code-exec tools run commands from there.
|
||||
#
|
||||
# tick() serializes workdir-jobs outside the parallel pool, so mutating
|
||||
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
|
||||
# tick() serializes jobs that mutate process-global runtime state (workdir
|
||||
# and/or profile jobs) outside the parallel pool, so mutating
|
||||
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
|
||||
# jobs we leave TERMINAL_CWD untouched — preserves the original behaviour
|
||||
# (skip_context_files=True, tools use whatever cwd the scheduler has).
|
||||
_job_workdir = (job.get("workdir") or "").strip() or None
|
||||
@@ -1753,7 +1871,10 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
||||
# If the agent responded with [SILENT], skip delivery (but
|
||||
# output is already saved above). Failed jobs always deliver.
|
||||
deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}"
|
||||
should_deliver = bool(deliver_content)
|
||||
# Treat whitespace-only final responses the same as empty
|
||||
# responses: do not deliver a blank message, and let the
|
||||
# empty-response guard below mark the run as a soft failure.
|
||||
should_deliver = bool(deliver_content.strip())
|
||||
if should_deliver and success and SILENT_MARKER in deliver_content.strip().upper():
|
||||
logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER)
|
||||
should_deliver = False
|
||||
@@ -1769,7 +1890,7 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
||||
# Treat empty final_response as a soft failure so last_status
|
||||
# is not "ok" — the agent ran but produced nothing useful.
|
||||
# (issue #8585)
|
||||
if success and not final_response:
|
||||
if success and not final_response.strip():
|
||||
success = False
|
||||
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
|
||||
|
||||
@@ -1781,17 +1902,26 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
||||
mark_job_run(job["id"], False, str(e))
|
||||
return False
|
||||
|
||||
# Partition due jobs: those with a per-job workdir mutate
|
||||
# os.environ["TERMINAL_CWD"] inside run_job, which is process-global —
|
||||
# so they MUST run sequentially to avoid corrupting each other. Jobs
|
||||
# without a workdir leave env untouched and stay parallel-safe.
|
||||
workdir_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()]
|
||||
parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()]
|
||||
# Partition due jobs: jobs with a per-job workdir and/or profile touch
|
||||
# process-global runtime state inside run_job. Workdir jobs temporarily
|
||||
# set os.environ["TERMINAL_CWD"]; profile jobs use a context-local
|
||||
# Hermes home override, scheduler _hermes_home hook, and temporary
|
||||
# profile .env load into os.environ with snapshot/restore. They MUST run
|
||||
# sequentially to avoid corrupting each other. Jobs without either field
|
||||
# stay parallel-safe.
|
||||
sequential_jobs = [
|
||||
j for j in due_jobs
|
||||
if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
|
||||
]
|
||||
parallel_jobs = [
|
||||
j for j in due_jobs
|
||||
if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
|
||||
]
|
||||
|
||||
_results: list = []
|
||||
|
||||
# Sequential pass for workdir jobs.
|
||||
for job in workdir_jobs:
|
||||
# Sequential pass for env/context-mutating jobs.
|
||||
for job in sequential_jobs:
|
||||
_ctx = contextvars.copy_context()
|
||||
_results.append(_ctx.run(_process_job, job))
|
||||
|
||||
@@ -1823,7 +1953,10 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
||||
return sum(_results)
|
||||
finally:
|
||||
if fcntl:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
elif msvcrt:
|
||||
try:
|
||||
msvcrt.locking(lock_fd.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
+51
-7
@@ -322,15 +322,21 @@ class PlatformConfig:
|
||||
if "home_channel" in data:
|
||||
home_channel = HomeChannel.from_dict(data["home_channel"])
|
||||
|
||||
# gateway_restart_notification may be bridged into extra via the
|
||||
# shared-key loop in load_gateway_config(); check both top-level
|
||||
# and extra so YAML ``discord: gateway_restart_notification: false``
|
||||
# works without needing a separate platforms: block.
|
||||
_grn = data.get("gateway_restart_notification")
|
||||
if _grn is None:
|
||||
_grn = data.get("extra", {}).get("gateway_restart_notification")
|
||||
|
||||
return cls(
|
||||
enabled=_coerce_bool(data.get("enabled"), False),
|
||||
token=data.get("token"),
|
||||
api_key=data.get("api_key"),
|
||||
home_channel=home_channel,
|
||||
reply_to_mode=data.get("reply_to_mode", "first"),
|
||||
gateway_restart_notification=_coerce_bool(
|
||||
data.get("gateway_restart_notification"), True
|
||||
),
|
||||
gateway_restart_notification=_coerce_bool(_grn, True),
|
||||
extra=data.get("extra", {}),
|
||||
)
|
||||
|
||||
@@ -352,12 +358,13 @@ class StreamingConfig:
|
||||
# Transport selection:
|
||||
# "auto" — prefer native streaming-draft updates when the platform
|
||||
# supports them (Telegram sendMessageDraft, Bot API 9.5+);
|
||||
# fall back to edit-based when not. Recommended.
|
||||
# fall back to edit-based when not.
|
||||
# "draft" — explicitly request native drafts; falls back to edit when
|
||||
# the platform/chat doesn't support them.
|
||||
# "edit" — progressive editMessageText only (legacy behaviour).
|
||||
# "edit" — progressive editMessageText only (legacy/default
|
||||
# behaviour).
|
||||
# "off" — disable streaming entirely.
|
||||
transport: str = "auto"
|
||||
transport: str = "edit"
|
||||
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
|
||||
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
|
||||
cursor: str = DEFAULT_STREAMING_CURSOR
|
||||
@@ -386,7 +393,7 @@ class StreamingConfig:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=_coerce_bool(data.get("enabled"), False),
|
||||
transport=data.get("transport", "auto"),
|
||||
transport=data.get("transport", "edit"),
|
||||
edit_interval=_coerce_float(
|
||||
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
|
||||
),
|
||||
@@ -821,10 +828,16 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["reply_in_thread"] = platform_cfg["reply_in_thread"]
|
||||
if "require_mention" in platform_cfg:
|
||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||
if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg:
|
||||
bridged["allowed_chats"] = platform_cfg["allowed_chats"]
|
||||
if plat == Platform.TELEGRAM and "allowed_topics" in platform_cfg:
|
||||
bridged["allowed_topics"] = platform_cfg["allowed_topics"]
|
||||
if "free_response_channels" in platform_cfg:
|
||||
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
|
||||
if "mention_patterns" in platform_cfg:
|
||||
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
||||
if "exclusive_bot_mentions" in platform_cfg:
|
||||
bridged["exclusive_bot_mentions"] = platform_cfg["exclusive_bot_mentions"]
|
||||
if "dm_policy" in platform_cfg:
|
||||
bridged["dm_policy"] = platform_cfg["dm_policy"]
|
||||
if "allow_from" in platform_cfg:
|
||||
@@ -849,6 +862,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()}
|
||||
else:
|
||||
bridged["channel_prompts"] = channel_prompts
|
||||
if "gateway_restart_notification" in platform_cfg:
|
||||
bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"]
|
||||
enabled_was_explicit = "enabled" in platform_cfg
|
||||
if not bridged and not enabled_was_explicit:
|
||||
continue
|
||||
@@ -989,12 +1004,24 @@ def load_gateway_config() -> GatewayConfig:
|
||||
# Telegram settings → env vars (env vars take precedence)
|
||||
telegram_cfg = yaml_cfg.get("telegram", {})
|
||||
if isinstance(telegram_cfg, dict):
|
||||
# Bridge top-level legacy `telegram.disable_topic_auto_rename` into
|
||||
# gateway.platforms.telegram.extra so the runtime config sees it.
|
||||
# Read as a runtime-config flag, not env-var (no need for env override).
|
||||
if "disable_topic_auto_rename" in telegram_cfg:
|
||||
_tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||
_tg_extra = _tg_plat.setdefault("extra", {})
|
||||
_tg_extra.setdefault(
|
||||
"disable_topic_auto_rename",
|
||||
telegram_cfg["disable_topic_auto_rename"],
|
||||
)
|
||||
# Prefer telegram.require_mention; fall back to the top-level shorthand.
|
||||
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
|
||||
if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
|
||||
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
||||
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
|
||||
if "exclusive_bot_mentions" in telegram_cfg and not os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS"):
|
||||
os.environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] = str(telegram_cfg["exclusive_bot_mentions"]).lower()
|
||||
if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"):
|
||||
os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower()
|
||||
frc = telegram_cfg.get("free_response_chats")
|
||||
@@ -1008,6 +1035,11 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
|
||||
allowed_topics = telegram_cfg.get("allowed_topics")
|
||||
if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"):
|
||||
if isinstance(allowed_topics, list):
|
||||
allowed_topics = ",".join(str(v) for v in allowed_topics)
|
||||
os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics)
|
||||
ignored_threads = telegram_cfg.get("ignored_threads")
|
||||
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
|
||||
if isinstance(ignored_threads, list):
|
||||
@@ -1053,6 +1085,12 @@ def load_gateway_config() -> GatewayConfig:
|
||||
extra = {}
|
||||
plat_data["extra"] = extra
|
||||
extra[_telegram_extra_key] = telegram_cfg[_telegram_extra_key]
|
||||
if _telegram_extra:
|
||||
_plat_data, _plat_extra = _ensure_platform_extra_dict(
|
||||
platforms_data, Platform.TELEGRAM.value
|
||||
)
|
||||
for _telegram_extra_key, _telegram_extra_value in _telegram_extra.items():
|
||||
_plat_extra.setdefault(_telegram_extra_key, _telegram_extra_value)
|
||||
|
||||
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
|
||||
if isinstance(whatsapp_cfg, dict):
|
||||
@@ -1080,6 +1118,12 @@ def load_gateway_config() -> GatewayConfig:
|
||||
gaf = ",".join(str(v) for v in gaf)
|
||||
os.environ["WHATSAPP_GROUP_ALLOWED_USERS"] = str(gaf)
|
||||
|
||||
# Signal settings → env vars (env vars take precedence)
|
||||
signal_cfg = yaml_cfg.get("signal", {})
|
||||
if isinstance(signal_cfg, dict):
|
||||
if "require_mention" in signal_cfg and not os.getenv("SIGNAL_REQUIRE_MENTION"):
|
||||
os.environ["SIGNAL_REQUIRE_MENTION"] = str(signal_cfg["require_mention"]).lower()
|
||||
|
||||
# DingTalk settings → env vars (env vars take precedence)
|
||||
dingtalk_cfg = yaml_cfg.get("dingtalk", {})
|
||||
if isinstance(dingtalk_cfg, dict):
|
||||
|
||||
+45
-11
@@ -45,10 +45,10 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
|
||||
|
||||
Most platforms route threaded sends with a generic ``thread_id`` metadata
|
||||
value. Telegram private-chat topics created through Hermes' DM-topic helper
|
||||
are exposed in updates as ``message_thread_id`` plus a reply anchor, but
|
||||
outbound sends only render in the correct Telegram lane when the adapter
|
||||
supplies both ``message_thread_id`` and ``reply_to_message_id``. Mark those
|
||||
lanes so the Telegram adapter can avoid the known-bad partial routes.
|
||||
are exposed in updates as ``message_thread_id`` plus a reply anchor. Live
|
||||
user-message replies route with ``message_thread_id`` + ``reply_to_message_id``;
|
||||
synthetic/resumed sends that have no reply anchor fall back to Telegram's
|
||||
``direct_messages_topic_id`` when the Bot API supports it.
|
||||
"""
|
||||
thread_id = getattr(source, "thread_id", None)
|
||||
if thread_id is None:
|
||||
@@ -56,6 +56,9 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
|
||||
metadata = {"thread_id": thread_id}
|
||||
if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm":
|
||||
metadata["telegram_dm_topic_reply_fallback"] = True
|
||||
tid = str(thread_id)
|
||||
if tid and tid not in {"", "1"}:
|
||||
metadata["direct_messages_topic_id"] = tid
|
||||
anchor = reply_to_message_id or getattr(source, "message_id", None)
|
||||
if anchor is not None:
|
||||
metadata["telegram_reply_to_message_id"] = str(anchor)
|
||||
@@ -67,10 +70,9 @@ def _reply_anchor_for_event(event) -> str | None:
|
||||
|
||||
Telegram forum/supergroup topics should be routed by topic metadata, not by
|
||||
replying to the triggering message. Hermes-created Telegram private-chat
|
||||
topic lanes are different: Bot API sends reject their ``message_thread_id``
|
||||
and do not route with ``direct_messages_topic_id``. Those lanes only remain
|
||||
visible when sent with both the private topic thread id and a reply to the
|
||||
triggering user message.
|
||||
topic lanes prefer replying to the triggering user message so the answer
|
||||
stays attached to the active lane; synthetic/resumed sends fall back to
|
||||
``direct_messages_topic_id`` metadata when no message id is available.
|
||||
"""
|
||||
source = getattr(event, "source", None)
|
||||
platform = _platform_name(getattr(source, "platform", None))
|
||||
@@ -835,6 +837,26 @@ SUPPORTED_DOCUMENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image document types
|
||||
#
|
||||
# Image extensions that platforms may deliver as "documents" rather than
|
||||
# native photo attachments (Telegram users uploading via the file picker,
|
||||
# clients that wrap stickers/screenshots as files, etc.). When we see one
|
||||
# of these, we route the bytes through the image cache and the normal
|
||||
# vision/photo handling path instead of rejecting them as unsupported
|
||||
# documents.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUPPORTED_IMAGE_DOCUMENT_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
@@ -2137,7 +2159,7 @@ class BasePlatformAdapter(ABC):
|
||||
# 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`"',;:)\]}]|$)|\S+)[`"']?'''
|
||||
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`"',;:)\]}]|$))[`"']?'''
|
||||
)
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
@@ -3185,13 +3207,25 @@ class BasePlatformAdapter(ABC):
|
||||
logger.warning("[%s] Auto-TTS failed: %s", self.name, tts_err)
|
||||
|
||||
# Play TTS audio before text (voice-first experience)
|
||||
_tts_caption_delivered = False
|
||||
if _tts_path and Path(_tts_path).exists():
|
||||
try:
|
||||
await self.play_tts(
|
||||
telegram_tts_caption = None
|
||||
if (
|
||||
self.platform == Platform.TELEGRAM
|
||||
and text_content
|
||||
and text_content[:1024] == text_content
|
||||
):
|
||||
telegram_tts_caption = text_content
|
||||
tts_result = await self.play_tts(
|
||||
chat_id=event.source.chat_id,
|
||||
audio_path=_tts_path,
|
||||
caption=telegram_tts_caption,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
_tts_caption_delivered = bool(
|
||||
telegram_tts_caption and getattr(tts_result, "success", False)
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.remove(_tts_path)
|
||||
@@ -3199,7 +3233,7 @@ class BasePlatformAdapter(ABC):
|
||||
pass
|
||||
|
||||
# Send the text portion
|
||||
if text_content:
|
||||
if text_content and not _tts_caption_delivered:
|
||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
||||
_reply_anchor = _reply_anchor_for_event(event)
|
||||
# Mark final response messages for notification delivery.
|
||||
|
||||
@@ -774,7 +774,14 @@ class DingTalkAdapter(BasePlatformAdapter):
|
||||
elif mapped == "audio":
|
||||
media_types.append("audio")
|
||||
if msg_type == MessageType.TEXT:
|
||||
msg_type = MessageType.AUDIO
|
||||
# DingTalk's "voice" rich-text item is a
|
||||
# native voice note — route through STT.
|
||||
# "audio" comes from file uploads only;
|
||||
# keep those as AUDIO (no auto-STT).
|
||||
if item_type == "voice":
|
||||
msg_type = MessageType.VOICE
|
||||
else:
|
||||
msg_type = MessageType.AUDIO
|
||||
elif mapped == "video":
|
||||
media_types.append("video")
|
||||
if msg_type == MessageType.TEXT:
|
||||
@@ -1395,6 +1402,16 @@ class _IncomingHandler(
|
||||
self._adapter = adapter
|
||||
self._loop = loop
|
||||
|
||||
def pre_start(self) -> None:
|
||||
"""No-op pre-start hook required by dingtalk-stream SDK.
|
||||
|
||||
The SDK calls ``pre_start()`` on every registered handler before
|
||||
opening the WebSocket connection. Without this method, the SDK
|
||||
raises ``AttributeError: '_IncomingHandler' object has no
|
||||
attribute 'pre_start'`` and kills the stream connection.
|
||||
"""
|
||||
return
|
||||
|
||||
async def process(self, message: "CallbackMessage"):
|
||||
"""Called by dingtalk-stream (>=0.20) when a message arrives.
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ def check_discord_requirements() -> bool:
|
||||
Intents = _Intents
|
||||
commands = _commands
|
||||
DISCORD_AVAILABLE = True
|
||||
_define_discord_view_classes()
|
||||
return True
|
||||
|
||||
|
||||
@@ -3601,6 +3602,24 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return 32 * 1024 * 1024
|
||||
return max(0, value)
|
||||
|
||||
@staticmethod
|
||||
def _is_discord_voice_message_attachment(att: Any) -> bool:
|
||||
"""Return True when a Discord audio attachment is a native voice note."""
|
||||
marker = getattr(att, "is_voice_message", None)
|
||||
if marker is not None:
|
||||
if callable(marker):
|
||||
try:
|
||||
return bool(marker())
|
||||
except Exception as exc:
|
||||
logger.debug("[Discord] is_voice_message() failed for attachment: %s", exc)
|
||||
return False
|
||||
return bool(marker)
|
||||
|
||||
return (
|
||||
getattr(att, "duration", None) is not None
|
||||
and getattr(att, "waveform", None) is not None
|
||||
)
|
||||
|
||||
def _discord_free_response_channels(self) -> set:
|
||||
"""Return Discord channel IDs where no bot mention is required.
|
||||
|
||||
@@ -4541,7 +4560,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
elif att.content_type.startswith("video/"):
|
||||
msg_type = MessageType.VIDEO
|
||||
elif att.content_type.startswith("audio/"):
|
||||
msg_type = MessageType.AUDIO
|
||||
if self._is_discord_voice_message_attachment(att):
|
||||
msg_type = MessageType.VOICE
|
||||
else:
|
||||
msg_type = MessageType.AUDIO
|
||||
else:
|
||||
doc_ext = ""
|
||||
if att.filename:
|
||||
@@ -4949,7 +4971,17 @@ def _component_check_auth(
|
||||
return False
|
||||
|
||||
|
||||
if DISCORD_AVAILABLE:
|
||||
def _define_discord_view_classes() -> None:
|
||||
"""Register Discord UI view classes as module globals.
|
||||
|
||||
Called at module load (when discord.py is pre-installed) and also from
|
||||
check_discord_requirements() after a lazy install, so view classes are
|
||||
always defined whenever DISCORD_AVAILABLE is True. Without this,
|
||||
ExecApprovalView and siblings are only defined at import time; a later
|
||||
lazy install sets DISCORD_AVAILABLE=True but leaves the classes
|
||||
undefined, causing NameError on the first button interaction.
|
||||
"""
|
||||
global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView
|
||||
|
||||
class ExecApprovalView(discord.ui.View):
|
||||
"""
|
||||
@@ -5649,3 +5681,7 @@ if DISCORD_AVAILABLE:
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
|
||||
if DISCORD_AVAILABLE:
|
||||
_define_discord_view_classes()
|
||||
|
||||
@@ -380,6 +380,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._require_mention: bool = os.getenv(
|
||||
"MATRIX_REQUIRE_MENTION", "true"
|
||||
).lower() not in {"false", "0", "no"}
|
||||
self._thread_require_mention: bool = self._parse_thread_require_mention(config)
|
||||
free_rooms_raw = config.extra.get("free_response_rooms")
|
||||
if free_rooms_raw is None:
|
||||
free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
|
||||
@@ -468,6 +469,27 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._processed_events_set.add(event_id)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_thread_require_mention(config) -> bool:
|
||||
"""Parse thread_require_mention from config.extra or env var.
|
||||
|
||||
Handles both YAML booleans and string values (``\"true\"``, ``\"false\"``,
|
||||
``\"yes\"``, ``\"no\"``, ``\"on\"``, ``\"off\"``, ``\"1\"``, ``\"0\"``).
|
||||
Falls back to ``MATRIX_THREAD_REQUIRE_MENTION`` env var, default ``false``.
|
||||
Mirrors Discord adapter's parsing pattern.
|
||||
"""
|
||||
configured = config.extra.get("thread_require_mention")
|
||||
if configured is not None:
|
||||
if isinstance(configured, bool):
|
||||
return configured
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() not in {"false", "0", "no", "off"}
|
||||
# int, float, etc. — truthiness fallback
|
||||
return bool(configured)
|
||||
return os.getenv(
|
||||
"MATRIX_THREAD_REQUIRE_MENTION", "false"
|
||||
).lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# E2EE helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1701,6 +1723,21 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return None
|
||||
|
||||
# Thread-level @mention gating: even in a bot-participated thread,
|
||||
# require @mention when thread_require_mention is enabled.
|
||||
# Prevents infinite reply loops in multi-agent shared rooms
|
||||
# where multiple bots all participate in the same thread.
|
||||
elif (self._thread_require_mention and in_bot_thread
|
||||
and not is_free_room):
|
||||
if not is_mentioned:
|
||||
logger.debug(
|
||||
"Matrix: ignoring message %s in thread %s — "
|
||||
"no @mention (thread_require_mention=true)",
|
||||
event_id,
|
||||
thread_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# DM mention-thread.
|
||||
if is_dm and not thread_id and self._dm_mention_threads and is_mentioned:
|
||||
thread_id = event_id
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user