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:
emozilla
2026-05-20 01:35:02 -04:00
463 changed files with 36101 additions and 5318 deletions
+1
View File
@@ -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.
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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'
+13 -13
View File
@@ -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 }}
+2 -2
View File
@@ -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'
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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"
+2 -2
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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.
+4 -4
View File
@@ -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'
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+8 -6
View File
@@ -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`.
+1 -1
View File
@@ -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
View File
@@ -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
+9 -1
View File
@@ -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:
+22 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+30 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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 (
+555
View File
@@ -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",
]
+12
View File
@@ -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 "
+15 -3
View File
@@ -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,
)
+52 -3
View File
@@ -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:
+53 -4
View File
@@ -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:
+20 -5
View File
@@ -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),
+4 -1
View File
@@ -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
View File
@@ -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.
+29
View File
@@ -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
View File
@@ -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."""
+7 -2
View File
@@ -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
View File
@@ -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."""
+4 -1
View File
@@ -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(
+410
View File
@@ -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}")
+8
View File
@@ -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}]"
+6 -2
View File
@@ -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))
+14
View File
@@ -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
View File
@@ -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):
+21 -4
View File
@@ -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 {}
+4 -1
View File
@@ -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>
+497 -332
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -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",
-46
View File
@@ -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)}`)
}
+8 -6
View File
@@ -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",
)}
+4 -2
View File
@@ -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 ENZH 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,
);
}
+112 -60
View File
@@ -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;
}
+2
View File
@@ -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",
+20 -19
View File
@@ -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[];
+2
View File
@@ -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",
+4
View File
@@ -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:
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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: "ワーカーが人間の入力を求めています",
+2
View File
@@ -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: "워커가 사람의 입력을 요청함",
+2
View File
@@ -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",
+2
View File
@@ -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: "Воркер запросил вмешательство человека",
+2
View File
@@ -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",
+3
View File
@@ -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;
+2
View File
@@ -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: "Воркер запитав втручання людини",
+2
View File
@@ -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: "工作者請求人工輸入",
+2
View File
@@ -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: "工作者请求人工输入",
+16 -11
View File
@@ -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
View File
@@ -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";
+1 -1
View File
@@ -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
+7 -26
View File
@@ -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();
+5 -5
View File
@@ -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"
+95 -26
View File
@@ -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>
+5 -2
View File
@@ -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>
+19 -9
View File
@@ -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">
+27 -24
View File
@@ -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}`}
+27 -31
View File
@@ -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">
+39 -3
View File
@@ -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 ? (
+57 -58
View File
@@ -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">&#183;</span>
<span>
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</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}
+8 -13
View File
@@ -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",
)}
+3 -9
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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,
+9
View File
@@ -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).
+317 -35
View File
@@ -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:
# 24h72h 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
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+18 -1
View File
@@ -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.
+38 -2
View File
@@ -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()
+37
View File
@@ -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