Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f45b844670 | |||
| 2476beac3a | |||
| 8a0498d41e | |||
| 5f749667e2 | |||
| ee2cc327cb | |||
| e004e1e5e4 | |||
| 2fe2d943b1 | |||
| 9a4bc5508a | |||
| 5f50f3df0d | |||
| 4821b50cfe | |||
| 5bf688a30b | |||
| d3cb027e17 | |||
| b0c84756ba | |||
| bb5c3c1074 | |||
| 185e8ee942 | |||
| 024cccb9bc | |||
| 15115808b1 | |||
| c8a9e1234f | |||
| 14af4ce665 | |||
| 9ee36e0732 | |||
| 8e6f560fd3 | |||
| 281b5ca546 | |||
| f2a08f7581 | |||
| 61dc679815 | |||
| 87d3fa6f1c | |||
| 75d9811393 | |||
| e42065b1f7 | |||
| a830f25f71 | |||
| 50edbe6f46 | |||
| 4689ace7cb | |||
| 9eabc24e24 | |||
| 0d957a8d48 | |||
| 5f215b13ce | |||
| 124da27767 | |||
| 5d2f9b5d7d | |||
| 433d38da09 | |||
| a0105a7f81 | |||
| 01ad0aacaf | |||
| fa2bee1215 | |||
| 214ca943ac | |||
| 7d4648461a | |||
| faa15772b7 | |||
| 74c209534c | |||
| 18f585f091 | |||
| 4bf0e75ae9 | |||
| a3c27b5cd1 | |||
| 47d4b6e31a | |||
| a1921c43cc | |||
| 912590a143 | |||
| 1285172aca | |||
| b53a091b97 | |||
| b5128a751b | |||
| 663602f6b0 | |||
| e1027134cd | |||
| f62272b203 | |||
| 0348a69c51 | |||
| 753a071491 | |||
| e5601d1e85 | |||
| df51ad7973 | |||
| 42be5e49b0 | |||
| e0f5d39837 | |||
| 5ed1eb0d0f | |||
| be41ccd0af | |||
| e4b69bf149 | |||
| 1d8b9e6458 | |||
| e123f4ecf0 | |||
| 6085d7a93e | |||
| 3d8be2c617 | |||
| 89e8c87354 | |||
| 20c9340c34 | |||
| b2339c87e4 | |||
| 8cced33784 | |||
| 69b8fa65d4 | |||
| 5f84eac451 | |||
| b5905f0d4a | |||
| d6137453ac | |||
| a9369fc193 | |||
| e116957a63 | |||
| 391f1ca1f4 | |||
| 72dea9f4f7 | |||
| 06164a7b28 | |||
| 529eb29b6a | |||
| dbbe2d1973 | |||
| 315a11a76f | |||
| a3b9343f08 | |||
| d8c5573ffe | |||
| c69310c625 | |||
| d3a9c69e9b | |||
| a54106bbc8 | |||
| 1a4289b6b7 | |||
| 052b3449e5 | |||
| fb112d6a73 | |||
| 7444e49d4e | |||
| 93feffbcfa | |||
| b61d9b297a | |||
| 3ab97a32d1 | |||
| 1369dae226 | |||
| 7996c14795 | |||
| 4aa0a7c195 | |||
| 7428abd54e | |||
| 0f473d643d | |||
| aa94883288 | |||
| 1350d12b0b | |||
| 02ae152222 | |||
| 9cd02b1698 | |||
| 37551ee53e | |||
| a23f18cc3e | |||
| 023f5c74b1 | |||
| 2b728e1274 | |||
| 5316ce95de | |||
| a6a6cf047d | |||
| bd10acd747 | |||
| 4148e85b3a | |||
| 4462b349b2 | |||
| 4e5ebf07ea | |||
| 447d800b81 | |||
| e63364b8df | |||
| cf0852f92e | |||
| a83f669bcf | |||
| 6c78305294 | |||
| 1b9b5d2957 | |||
| 2f9243c333 | |||
| 22ddac4b14 | |||
| f3371c39a4 | |||
| 20b49b71cd | |||
| 1791324604 | |||
| 632ddf2a0a | |||
| afb9588298 | |||
| b4a8031b2e | |||
| 413ee1a286 | |||
| dad10a78d0 | |||
| 42cc905c13 | |||
| 4d3e3ff8a2 | |||
| c9d8b916d1 | |||
| a8f9c56cb4 | |||
| 0edcc57d9a | |||
| de03a332f7 | |||
| efb7d27609 | |||
| 8d76d69d48 | |||
| cfcad80ee1 | |||
| 7d884f81c4 | |||
| abefd89059 | |||
| 0169c51820 | |||
| db305bba8b | |||
| d293e0051e | |||
| 185ecc71f1 | |||
| 40bd6d4709 | |||
| d63abbc329 | |||
| 66a05e44d6 | |||
| fdfe40a48b | |||
| dd789a4fdf | |||
| c8ef786926 | |||
| 1d5e25f353 | |||
| 9e4d79b17f | |||
| 9048fd020f | |||
| 66b1142384 | |||
| 6b6fc28e85 | |||
| 54e24f7758 | |||
| b52ceccfa8 | |||
| 755f050c67 | |||
| 07a818804e | |||
| 474c725b49 | |||
| 8269f9056c | |||
| 6ce796b495 | |||
| cff29fa7fd | |||
| 2dfd73a497 | |||
| 8081425a1c | |||
| ec8243fe2a | |||
| 3d67364b8f | |||
| 38a6bada92 | |||
| 6c70ac8eef | |||
| d497387cec | |||
| 32d4048c6b | |||
| 1eab5960f0 | |||
| 74a4832b74 | |||
| fbbcfa24c5 | |||
| f223346eb7 | |||
| 57f8cf00e9 | |||
| 6649e7e746 | |||
| 32b78578e0 | |||
| 6769a0aece | |||
| d7528d43ac | |||
| a7cdd4133c | |||
| 461ef88705 | |||
| 12d745bd7e | |||
| 30307a9802 | |||
| 6ea5699e3f | |||
| c3e3a9c184 | |||
| 02df438316 | |||
| 94b26f3ec9 |
@@ -5,7 +5,9 @@
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
.venv
|
||||
**/.venv
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
|
||||
@@ -13,7 +13,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
nix-lockfile-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -36,6 +36,12 @@ jobs:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
- name: Fail if check crashed without reporting
|
||||
if: steps.check.outputs.stale != 'true' && steps.check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale)
|
||||
if: steps.check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
name: Nix Lockfile Fix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'web/package-lock.json'
|
||||
- 'web/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -19,9 +26,103 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles --apply and pushes the hash
|
||||
# update commit directly to main so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
# the paths filter above, so this cannot re-trigger itself.
|
||||
# 2. An explicit file-whitelist check before commit aborts if
|
||||
# fix-lockfiles ever modifies unexpected files.
|
||||
# 3. Job-level concurrency with cancel-in-progress: true ensures
|
||||
# back-to-back pushes collapse to the newest; ref: main checkout
|
||||
# always operates on the latest branch state.
|
||||
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
|
||||
# triggers downstream nix.yml verification.
|
||||
auto-fix-main:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
concurrency:
|
||||
group: auto-fix-main
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles -- --apply
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure only nix files were modified — prevents accidental
|
||||
# self-triggering if fix-lockfiles ever touches package files.
|
||||
unexpected="$(git diff --name-only | grep -Ev '^nix/(tui|web)\.nix$' || true)"
|
||||
if [ -n "$unexpected" ]; then
|
||||
echo "::error::Unexpected modified files: $unexpected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Record the base SHA before committing — used to detect package
|
||||
# file changes if we need to rebase after a non-fast-forward push.
|
||||
BASE_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/tui.nix nix/web.nix
|
||||
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
|
||||
-m "Source: $GITHUB_SHA" \
|
||||
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
||||
|
||||
# Retry push with rebase in case main advanced with an unrelated
|
||||
# commit during the nix build. Without this, a non-fast-forward
|
||||
# rejection silently loses the fix. If package files changed during
|
||||
# the rebase, abort — a fresh auto-fix run will handle the new state.
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
|
||||
git fetch origin main
|
||||
|
||||
# If package files changed between our base and the new main,
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
||||
'web/package-lock.json' 'web/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git rebase origin/main
|
||||
done
|
||||
echo "::error::Failed to push after 3 rebase attempts"
|
||||
exit 1
|
||||
|
||||
# ── PR fix (manual / checkbox) ─────────────────────────────────────
|
||||
# Existing behavior: run on manual dispatch OR when a task-list
|
||||
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
|
||||
fix:
|
||||
# Run on manual dispatch OR when a task-list checkbox in the sticky
|
||||
# lockfile-check comment flips from `[ ]` to `[x]`.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment'
|
||||
|
||||
@@ -38,7 +38,7 @@ hermes-agent/
|
||||
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
|
||||
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
|
||||
│ │ # webhook, api_server, ...). See ADDING_A_PLATFORM.md.
|
||||
│ └── builtin_hooks/ # Always-registered gateway hooks (boot-md, ...)
|
||||
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
|
||||
├── plugins/ # Plugin system (see "Plugins" section below)
|
||||
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
|
||||
│ ├── context_engine/ # Context-engine plugins
|
||||
|
||||
+8
-2
@@ -14,7 +14,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# that would otherwise accumulate when hermes runs as PID 1. See #15012.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
@@ -45,7 +45,13 @@ COPY --chown=hermes:hermes . .
|
||||
|
||||
# Build browser dashboard and terminal UI assets.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
cd ../ui-tui && npm run build && \
|
||||
rm -rf node_modules/@hermes/ink && \
|
||||
rm -rf packages/hermes-ink/node_modules && \
|
||||
cp -R packages/hermes-ink node_modules/@hermes/ink && \
|
||||
npm install --omit=dev --prefer-offline --no-audit --prefix node_modules/@hermes/ink && \
|
||||
rm -rf node_modules/@hermes/ink/node_modules/react && \
|
||||
node --input-type=module -e "await import('@hermes/ink')"
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
|
||||
@@ -112,6 +112,17 @@ def main() -> None:
|
||||
import acp
|
||||
from .server import HermesACPAgent
|
||||
|
||||
# MCP tool discovery from config.yaml — run before asyncio.run() so
|
||||
# it's safe to use blocking waits. (ACP also registers per-session
|
||||
# MCP servers dynamically via asyncio.to_thread inside the event
|
||||
# loop; that path is unaffected.) Moved from model_tools.py module
|
||||
# scope to avoid freezing the gateway's loop on lazy import (#16856).
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug("MCP tool discovery failed at ACP startup", exc_info=True)
|
||||
|
||||
agent = HermesACPAgent()
|
||||
try:
|
||||
asyncio.run(acp.run_agent(agent, use_unstable_protocol=True))
|
||||
|
||||
+28
-1
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict, deque
|
||||
@@ -574,6 +575,22 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
def _run_agent() -> dict:
|
||||
nonlocal previous_approval_cb, previous_interactive
|
||||
# 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
|
||||
# that land on the same reused executor thread. This call runs
|
||||
# inside a contextvars.copy_context() below, so the ContextVar
|
||||
# write is isolated from other concurrent ACP sessions.
|
||||
try:
|
||||
from gateway.session_context import (
|
||||
clear_session_vars,
|
||||
set_session_vars,
|
||||
)
|
||||
session_tokens = set_session_vars(session_key=session_id)
|
||||
except Exception:
|
||||
session_tokens = None
|
||||
clear_session_vars = None # type: ignore[assignment]
|
||||
logger.debug("Could not set ACP session context", exc_info=True)
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
@@ -607,9 +624,19 @@ class HermesACPAgent(acp.Agent):
|
||||
_terminal_tool.set_approval_callback(previous_approval_cb)
|
||||
except Exception:
|
||||
logger.debug("Could not restore approval callback", exc_info=True)
|
||||
if session_tokens is not None and clear_session_vars is not None:
|
||||
try:
|
||||
clear_session_vars(session_tokens)
|
||||
except Exception:
|
||||
logger.debug("Could not clear ACP session context", exc_info=True)
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(_executor, _run_agent)
|
||||
# Wrap the executor call in a fresh copy of the current context so
|
||||
# concurrent ACP sessions on the shared ThreadPoolExecutor don't
|
||||
# stomp on each other's ContextVar writes (HERMES_SESSION_KEY in
|
||||
# particular — used by the interactive sudo password cache scope).
|
||||
ctx = contextvars.copy_context()
|
||||
result = await loop.run_in_executor(_executor, ctx.run, _run_agent)
|
||||
except Exception:
|
||||
logger.exception("Executor error for session %s", session_id)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
+155
-78
@@ -22,10 +22,25 @@ from hermes_constants import get_hermes_home
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from utils import normalize_proxy_env_vars
|
||||
|
||||
try:
|
||||
import anthropic as _anthropic_sdk
|
||||
except ImportError:
|
||||
_anthropic_sdk = None # type: ignore[assignment]
|
||||
# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls
|
||||
# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.)
|
||||
# and the 3 usage sites (build_anthropic_client, build_anthropic_bedrock_client,
|
||||
# read_claude_code_credentials_from_keychain) are all on cold user-triggered
|
||||
# paths. Access via the `_get_anthropic_sdk()` accessor below, which caches
|
||||
# the module after the first call and returns None on ImportError.
|
||||
_anthropic_sdk: Any = ... # sentinel — None means "tried and missing"
|
||||
|
||||
|
||||
def _get_anthropic_sdk():
|
||||
"""Return the ``anthropic`` SDK module, importing lazily. None if not installed."""
|
||||
global _anthropic_sdk
|
||||
if _anthropic_sdk is ...:
|
||||
try:
|
||||
import anthropic as _sdk
|
||||
_anthropic_sdk = _sdk
|
||||
except ImportError:
|
||||
_anthropic_sdk = None
|
||||
return _anthropic_sdk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -202,19 +217,33 @@ def _forbids_sampling_params(model: str) -> bool:
|
||||
|
||||
|
||||
# Beta headers for enhanced features (sent with ALL auth types).
|
||||
# As of Opus 4.7 (2026-04-16), both of these are GA on Claude 4.6+ — the
|
||||
# As of Opus 4.7 (2026-04-16), the first two are GA on Claude 4.6+ — the
|
||||
# beta headers are still accepted (harmless no-op) but not required. Kept
|
||||
# here so older Claude (4.5, 4.1) + third-party Anthropic-compat endpoints
|
||||
# that still gate on the headers continue to get the enhanced features.
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models.
|
||||
#
|
||||
# ``context-1m-2025-08-07`` unlocks the 1M context window on Claude Opus 4.6/4.7
|
||||
# and Sonnet 4.6 when served via AWS Bedrock or Azure AI Foundry. 1M is GA on
|
||||
# native Anthropic (api.anthropic.com) for Opus 4.6+, but Bedrock/Azure still
|
||||
# gate it behind this beta header as of 2026-04 — without it Bedrock caps Opus
|
||||
# at 200K even though model_metadata.py advertises 1M. The header is a harmless
|
||||
# no-op on endpoints where 1M is GA.
|
||||
#
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models or once
|
||||
# Bedrock/Azure promote 1M to GA.
|
||||
_COMMON_BETAS = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
]
|
||||
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
|
||||
# the fine-grained tool streaming beta is present. Omit it so tool calls
|
||||
# fall back to the provider's default response path.
|
||||
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||||
# 1M context beta — see comment on _COMMON_BETAS above. Stripped for
|
||||
# Bearer-auth (MiniMax) endpoints since they host their own models and
|
||||
# unknown Anthropic beta headers risk request rejection.
|
||||
_CONTEXT_1M_BETA = "context-1m-2025-08-07"
|
||||
|
||||
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
|
||||
# significantly higher output token throughput on Opus 4.6 (~2.5x).
|
||||
@@ -228,10 +257,11 @@ _OAUTH_ONLY_BETAS = [
|
||||
"oauth-2025-04-20",
|
||||
]
|
||||
|
||||
# Claude Code identity — required for OAuth requests to be routed correctly.
|
||||
# Without these, Anthropic's infrastructure intermittently 500s OAuth traffic.
|
||||
# The version must stay reasonably current — Anthropic rejects OAuth requests
|
||||
# when the spoofed user-agent version is too far behind the actual release.
|
||||
# Claude Code version — sent on OAuth token-exchange / refresh requests
|
||||
# (platform.claude.com/v1/oauth/token) as the client's user-agent. Anthropic's
|
||||
# OAuth flow validates the UA and may reject requests with a version that's
|
||||
# too old, so detecting dynamically keeps users on a current Claude Code
|
||||
# install from hitting stale-version errors during login/refresh.
|
||||
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
|
||||
_claude_code_version_cache: Optional[str] = None
|
||||
|
||||
@@ -239,9 +269,9 @@ _claude_code_version_cache: Optional[str] = None
|
||||
def _detect_claude_code_version() -> str:
|
||||
"""Detect the installed Claude Code version, fall back to a static constant.
|
||||
|
||||
Anthropic's OAuth infrastructure validates the user-agent version and may
|
||||
reject requests with a version that's too old. Detecting dynamically means
|
||||
users who keep Claude Code updated never hit stale-version 400s.
|
||||
Used only by the OAuth token-exchange / refresh flow
|
||||
(``platform.claude.com/v1/oauth/token``). The Messages API client no
|
||||
longer sends a claude-cli user-agent.
|
||||
"""
|
||||
import subprocess as _sp
|
||||
|
||||
@@ -261,12 +291,13 @@ def _detect_claude_code_version() -> str:
|
||||
return _CLAUDE_CODE_VERSION_FALLBACK
|
||||
|
||||
|
||||
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
_MCP_TOOL_PREFIX = "mcp_"
|
||||
|
||||
|
||||
def _get_claude_code_version() -> str:
|
||||
"""Lazily detect the installed Claude Code version when OAuth headers need it."""
|
||||
"""Lazily detect the installed Claude Code version for OAuth flow headers.
|
||||
|
||||
Used only on the OAuth token-exchange and refresh endpoints
|
||||
(``platform.claude.com/v1/oauth/token``). The Messages API client does
|
||||
not send a claude-cli user-agent.
|
||||
"""
|
||||
global _claude_code_version_cache
|
||||
if _claude_code_version_cache is None:
|
||||
_claude_code_version_cache = _detect_claude_code_version()
|
||||
@@ -357,9 +388,14 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
|
||||
tool-use message triggers a connection error. Strip that beta for
|
||||
Bearer-auth endpoints while keeping all other betas intact.
|
||||
|
||||
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
|
||||
endpoints — MiniMax hosts its own models, not Claude, so the header is
|
||||
irrelevant at best and risks request rejection at worst.
|
||||
"""
|
||||
if _requires_bearer_auth(base_url):
|
||||
return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA]
|
||||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||||
return [b for b in _COMMON_BETAS if b not in _stripped]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
@@ -374,6 +410,7 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
|
||||
|
||||
Returns an anthropic.Anthropic instance.
|
||||
"""
|
||||
_anthropic_sdk = _get_anthropic_sdk()
|
||||
if _anthropic_sdk is None:
|
||||
raise ImportError(
|
||||
"The 'anthropic' package is required for the Anthropic provider. "
|
||||
@@ -430,15 +467,21 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
|
||||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
elif _is_oauth_token(api_key):
|
||||
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
||||
# Anthropic routes OAuth requests based on user-agent and headers;
|
||||
# without Claude Code's fingerprint, requests get intermittent 500s.
|
||||
all_betas = common_betas + _OAUTH_ONLY_BETAS
|
||||
# OAuth access token / setup-token → Bearer auth + OAuth-only betas.
|
||||
# The OAuth-specific beta headers are still required by Anthropic's
|
||||
# OAuth-gated Messages API path; the Claude Code user-agent / x-app
|
||||
# spoofing is deliberately NOT sent — Hermes identifies as itself.
|
||||
#
|
||||
# ``context-1m-2025-08-07`` is stripped here: Anthropic rejects
|
||||
# OAuth requests that carry it with
|
||||
# "This authentication style is incompatible with the long
|
||||
# context beta header."
|
||||
# Subscription-gated OAuth traffic gets the 200K default window.
|
||||
oauth_safe_common = [b for b in common_betas if b != _CONTEXT_1M_BETA]
|
||||
all_betas = oauth_safe_common + _OAUTH_ONLY_BETAS
|
||||
kwargs["auth_token"] = api_key
|
||||
kwargs["default_headers"] = {
|
||||
"anthropic-beta": ",".join(all_betas),
|
||||
"user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
}
|
||||
else:
|
||||
# Regular API key → x-api-key header + common betas
|
||||
@@ -456,8 +499,16 @@ def build_anthropic_bedrock_client(region: str):
|
||||
Claude feature parity: prompt caching, thinking budgets, adaptive
|
||||
thinking, fast mode — features not available via the Converse API.
|
||||
|
||||
Attaches the common Anthropic beta headers as client-level defaults so
|
||||
that Bedrock-hosted Claude models get the same enhanced features as
|
||||
native Anthropic. The ``context-1m-2025-08-07`` beta in particular
|
||||
unlocks the 1M context window for Opus 4.6/4.7 on Bedrock — without
|
||||
it, Bedrock caps these models at 200K even though the Anthropic API
|
||||
serves them with 1M natively.
|
||||
|
||||
Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).
|
||||
"""
|
||||
_anthropic_sdk = _get_anthropic_sdk()
|
||||
if _anthropic_sdk is None:
|
||||
raise ImportError(
|
||||
"The 'anthropic' package is required for the Bedrock provider. "
|
||||
@@ -473,6 +524,7 @@ def build_anthropic_bedrock_client(region: str):
|
||||
return _anthropic_sdk.AnthropicBedrock(
|
||||
aws_region=region,
|
||||
timeout=Timeout(timeout=900.0, connect=10.0),
|
||||
default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)},
|
||||
)
|
||||
|
||||
|
||||
@@ -488,9 +540,6 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
||||
|
||||
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
|
||||
"""
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
return None
|
||||
|
||||
@@ -776,17 +825,45 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
"""Resolve an Anthropic token from all available sources.
|
||||
|
||||
Priority:
|
||||
1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
||||
2. CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||
1. Hermes credential pool (``~/.hermes/auth.json`` →
|
||||
``credential_pool.anthropic``) — OAuth tokens minted by Hermes'
|
||||
own PKCE login flow. Entries are auto-refreshed when near
|
||||
expiry. Env-sourced pool entries (``source="env:..."``) are
|
||||
skipped here so the env-var priority logic below still runs.
|
||||
2. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
||||
3. CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
4. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||
— with automatic refresh if expired and a refresh token is available
|
||||
4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
||||
5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
||||
|
||||
Returns the token string or None.
|
||||
"""
|
||||
# 1. Hermes credential pool — the live source of truth for tokens
|
||||
# minted via ``hermes login anthropic`` / the dashboard PKCE flow.
|
||||
# ``select()`` picks the best available entry and refreshes it if
|
||||
# it's near expiry, so callers always get a fresh token.
|
||||
#
|
||||
# Skip env-sourced pool entries (``env:ANTHROPIC_TOKEN``, etc.) —
|
||||
# those are passthroughs of the env var, and the env-var branches
|
||||
# below have richer priority logic (``_prefer_refreshable_claude_code_token``)
|
||||
# that can upgrade a static env OAuth token to a refreshed
|
||||
# Claude Code token. Letting the pool win here would short-circuit
|
||||
# that upgrade.
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("anthropic")
|
||||
entry = pool.select()
|
||||
if entry and entry.access_token and not entry.source.startswith("env:"):
|
||||
return entry.access_token
|
||||
except Exception as exc:
|
||||
# Pool lookup is best-effort — fall through to env/file sources
|
||||
# if anything goes wrong (e.g. auth.json corruption during a
|
||||
# concurrent write).
|
||||
logger.debug("Credential-pool lookup failed for anthropic: %s", exc)
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
|
||||
# 1. Hermes-managed OAuth/setup token env var
|
||||
# 2. Hermes-managed OAuth/setup token env var
|
||||
token = os.getenv("ANTHROPIC_TOKEN", "").strip()
|
||||
if token:
|
||||
preferred = _prefer_refreshable_claude_code_token(token, creds)
|
||||
@@ -794,7 +871,7 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
return preferred
|
||||
return token
|
||||
|
||||
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
||||
# 3. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
||||
cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
||||
if cc_token:
|
||||
preferred = _prefer_refreshable_claude_code_token(cc_token, creds)
|
||||
@@ -802,12 +879,12 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
return preferred
|
||||
return cc_token
|
||||
|
||||
# 3. Claude Code credential file
|
||||
# 4. Claude Code credential file
|
||||
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
|
||||
if resolved_claude_token:
|
||||
return resolved_claude_token
|
||||
|
||||
# 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
if api_key:
|
||||
@@ -1054,6 +1131,33 @@ def _sanitize_tool_id(tool_id: str) -> str:
|
||||
return sanitized or "tool_0"
|
||||
|
||||
|
||||
def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
|
||||
"""Normalize tool schemas before sending them to Anthropic.
|
||||
|
||||
Anthropic's tool schema validator rejects nullable unions such as
|
||||
``anyOf: [{"type": "string"}, {"type": "null"}]`` that Pydantic/MCP
|
||||
commonly emits for optional fields. Tool optionality is represented by
|
||||
the parent ``required`` array, so we delegate to the shared
|
||||
``strip_nullable_unions`` helper to collapse nullable unions to the
|
||||
non-null branch while preserving metadata like description/default.
|
||||
|
||||
``keep_nullable_hint=False`` because the Anthropic validator does not
|
||||
recognize the OpenAPI-style ``nullable: true`` extension and strict
|
||||
schema-to-grammar converters may reject unknown keywords.
|
||||
"""
|
||||
if not schema:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
from tools.schema_sanitizer import strip_nullable_unions
|
||||
|
||||
normalized = strip_nullable_unions(schema, keep_nullable_hint=False)
|
||||
if not isinstance(normalized, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
if normalized.get("type") == "object" and not isinstance(normalized.get("properties"), dict):
|
||||
normalized = {**normalized, "properties": {}}
|
||||
return normalized
|
||||
|
||||
|
||||
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||||
"""Convert OpenAI tool definitions to Anthropic format."""
|
||||
if not tools:
|
||||
@@ -1064,7 +1168,9 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||||
result.append({
|
||||
"name": fn.get("name", ""),
|
||||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
|
||||
"input_schema": _normalize_tool_input_schema(
|
||||
fn.get("parameters", {"type": "object", "properties": {}})
|
||||
),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1543,8 +1649,10 @@ def build_anthropic_kwargs(
|
||||
"max_tokens too large given prompt" errors and retry with a smaller cap
|
||||
(see parse_available_output_tokens_from_error + _ephemeral_max_output_tokens).
|
||||
|
||||
When *is_oauth* is True, applies Claude Code compatibility transforms:
|
||||
system prompt prefix, tool name prefixing, and prompt sanitization.
|
||||
When *is_oauth* is True, enables the OAuth-only beta headers required by
|
||||
Anthropic's subscription-gated Messages endpoint (fast-mode branch only;
|
||||
the default headers are set by build_anthropic_client). No system-prompt
|
||||
or tool-name rewriting is performed — Hermes identifies as itself.
|
||||
|
||||
When *preserve_dots* is True, model name dots are not converted to hyphens
|
||||
(for Alibaba/DashScope anthropic-compatible endpoints: qwen3.5-plus).
|
||||
@@ -1577,45 +1685,11 @@ def build_anthropic_kwargs(
|
||||
if context_length and effective_max_tokens > context_length:
|
||||
effective_max_tokens = max(context_length - 1, 1)
|
||||
|
||||
# ── OAuth: Claude Code identity ──────────────────────────────────
|
||||
if is_oauth:
|
||||
# 1. Prepend Claude Code system prompt identity
|
||||
cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX}
|
||||
if isinstance(system, list):
|
||||
system = [cc_block] + system
|
||||
elif isinstance(system, str) and system:
|
||||
system = [cc_block, {"type": "text", "text": system}]
|
||||
else:
|
||||
system = [cc_block]
|
||||
|
||||
# 2. Sanitize system prompt — replace product name references
|
||||
# to avoid Anthropic's server-side content filters.
|
||||
for block in system:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "")
|
||||
text = text.replace("Hermes Agent", "Claude Code")
|
||||
text = text.replace("Hermes agent", "Claude Code")
|
||||
text = text.replace("hermes-agent", "claude-code")
|
||||
text = text.replace("Nous Research", "Anthropic")
|
||||
block["text"] = text
|
||||
|
||||
# 3. Prefix tool names with mcp_ (Claude Code convention)
|
||||
if anthropic_tools:
|
||||
for tool in anthropic_tools:
|
||||
if "name" in tool:
|
||||
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
|
||||
|
||||
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
|
||||
for msg in anthropic_messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "tool_use" and "name" in block:
|
||||
if not block["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
block["name"] = _MCP_TOOL_PREFIX + block["name"]
|
||||
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
||||
pass # tool_result uses ID, not name
|
||||
# OAuth requests go through Anthropic's subscription-gated Messages
|
||||
# endpoint but otherwise send the real Hermes system prompt and real
|
||||
# Hermes tool names — the only OAuth-specific wire differences are
|
||||
# Bearer auth and the _OAUTH_ONLY_BETAS header (applied in
|
||||
# build_anthropic_client and the fast-mode branch below).
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
@@ -1706,6 +1780,9 @@ def build_anthropic_kwargs(
|
||||
# extra_headers override the client-level anthropic-beta header).
|
||||
betas = list(_common_betas_for_base_url(base_url))
|
||||
if is_oauth:
|
||||
# Strip context-1m — incompatible with OAuth auth. See matching
|
||||
# comment in build_anthropic_client().
|
||||
betas = [b for b in betas if b != _CONTEXT_1M_BETA]
|
||||
betas.extend(_OAUTH_ONLY_BETAS)
|
||||
betas.append(_FAST_MODE_BETA)
|
||||
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
|
||||
|
||||
+261
-22
@@ -41,10 +41,57 @@ import threading
|
||||
import time
|
||||
from pathlib import Path # noqa: F401 — used by test mocks
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from openai import OpenAI
|
||||
# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the
|
||||
# openai SDK pulls a large type tree (~240 ms cold, including responses/*,
|
||||
# graders/*). We expose `OpenAI` here as a thin proxy that imports the SDK on
|
||||
# first call and forwards, so:
|
||||
# (a) the 15+ in-module `OpenAI(...)` construction sites work unchanged
|
||||
# (Python's function-scope name lookup resolves `OpenAI` to the proxy
|
||||
# object bound in module globals here, without triggering any import);
|
||||
# (b) external code can still do `auxiliary_client.OpenAI` or
|
||||
# `patch("agent.auxiliary_client.OpenAI", ...)` — tests see the proxy,
|
||||
# and patch replaces the module attribute as usual;
|
||||
# (c) `OpenAI` as a type annotation resolves at runtime to the proxy class
|
||||
# (which is harmless — annotations aren't type-checked at runtime).
|
||||
# See tests/agent/test_auxiliary_client.py for patch patterns this supports.
|
||||
if TYPE_CHECKING:
|
||||
from openai import OpenAI # noqa: F401 — type hints only
|
||||
|
||||
_OPENAI_CLS_CACHE: Optional[type] = None
|
||||
|
||||
|
||||
def _load_openai_cls() -> type:
|
||||
"""Import and cache ``openai.OpenAI``."""
|
||||
global _OPENAI_CLS_CACHE
|
||||
if _OPENAI_CLS_CACHE is None:
|
||||
from openai import OpenAI as _cls
|
||||
_OPENAI_CLS_CACHE = _cls
|
||||
return _OPENAI_CLS_CACHE
|
||||
|
||||
|
||||
class _OpenAIProxy:
|
||||
"""Module-level proxy that looks like the ``openai.OpenAI`` class.
|
||||
|
||||
Forwards ``OpenAI(...)`` calls and ``isinstance(x, OpenAI)`` checks to the
|
||||
real SDK class, importing the SDK lazily on first use.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return _load_openai_cls()(*args, **kwargs)
|
||||
|
||||
def __instancecheck__(self, obj):
|
||||
return isinstance(obj, _load_openai_cls())
|
||||
|
||||
def __repr__(self):
|
||||
return "<lazy openai.OpenAI proxy>"
|
||||
|
||||
|
||||
OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.config import get_hermes_home
|
||||
@@ -94,6 +141,10 @@ _PROVIDER_ALIASES = {
|
||||
"github-models": "copilot",
|
||||
"github-copilot-acp": "copilot-acp",
|
||||
"copilot-acp-agent": "copilot-acp",
|
||||
"tencent": "tencent-tokenhub",
|
||||
"tokenhub": "tencent-tokenhub",
|
||||
"tencent-cloud": "tencent-tokenhub",
|
||||
"tencentmaas": "tencent-tokenhub",
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +217,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"opencode-go": "glm-5",
|
||||
"kilocode": "google/gemini-3-flash-preview",
|
||||
"ollama-cloud": "nemotron-3-nano:30b",
|
||||
"tencent-tokenhub": "hy3-preview",
|
||||
}
|
||||
|
||||
# Vision-specific model overrides for direct providers.
|
||||
@@ -405,6 +457,33 @@ class _CodexCompletionsAdapter:
|
||||
# Note: the Codex endpoint (chatgpt.com/backend-api/codex) does NOT
|
||||
# support max_output_tokens or temperature — omit to avoid 400 errors.
|
||||
|
||||
# Translate extra_body.reasoning (chat.completions shape) into the
|
||||
# Responses API's top-level reasoning + include fields. Mirrors
|
||||
# agent/transports/codex.py::build_kwargs() so auxiliary callers
|
||||
# that configure reasoning via auxiliary.<task>.extra_body get the
|
||||
# same behavior as the main agent's Codex transport.
|
||||
extra_body = kwargs.get("extra_body") or {}
|
||||
if isinstance(extra_body, dict):
|
||||
reasoning_cfg = extra_body.get("reasoning")
|
||||
if isinstance(reasoning_cfg, dict):
|
||||
if reasoning_cfg.get("enabled") is False:
|
||||
# Reasoning explicitly disabled — do not set reasoning
|
||||
# or include. The Codex backend still thinks by
|
||||
# default, but we honor the caller's intent where the
|
||||
# API allows it.
|
||||
pass
|
||||
else:
|
||||
effort = reasoning_cfg.get("effort", "medium")
|
||||
# Codex backend rejects "minimal"; clamp to "low" to
|
||||
# match the main-agent Codex transport behavior.
|
||||
if effort == "minimal":
|
||||
effort = "low"
|
||||
resp_kwargs["reasoning"] = {
|
||||
"effort": effort,
|
||||
"summary": "auto",
|
||||
}
|
||||
resp_kwargs["include"] = ["reasoning.encrypted_content"]
|
||||
|
||||
# Tools support for auxiliary callers (e.g. skills_hub) that pass function schemas
|
||||
tools = kwargs.get("tools")
|
||||
if tools:
|
||||
@@ -634,9 +713,7 @@ class _AnthropicCompletionsAdapter:
|
||||
|
||||
response = self._client.messages.create(**anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_oauth
|
||||
)
|
||||
_nr = _transport.normalize_response(response)
|
||||
|
||||
# ToolCall already duck-types as OpenAI shape (.type, .function.name,
|
||||
# .function.arguments) via properties, so no wrapping needed.
|
||||
@@ -714,6 +791,116 @@ class AsyncAnthropicAuxiliaryClient:
|
||||
self.base_url = sync_wrapper.base_url
|
||||
|
||||
|
||||
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
|
||||
protocol instead of OpenAI chat.completions.
|
||||
|
||||
Mirrors ``hermes_cli.runtime_provider._detect_api_mode_for_url`` so the
|
||||
auxiliary client and the main agent stay in sync on transport selection.
|
||||
Covers:
|
||||
|
||||
- Any URL ending in ``/anthropic`` (MiniMax, Zhipu GLM, LiteLLM proxies,
|
||||
Anthropic-compatible gateways).
|
||||
- ``api.kimi.com/coding`` (Kimi Coding Plan — the /coding route only
|
||||
speaks Claude-Code's native Anthropic shape; ``chat.completions``
|
||||
returns 404 on Anthropic-only model aliases like ``kimi-for-coding``).
|
||||
- ``api.anthropic.com`` (native Anthropic).
|
||||
"""
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.endswith("/anthropic"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
return True
|
||||
if hostname == "api.kimi.com" and "/coding" in normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _maybe_wrap_anthropic(
|
||||
client_obj: Any,
|
||||
model: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
api_mode: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
|
||||
the endpoint actually speaks Anthropic Messages.
|
||||
|
||||
This is the single chokepoint for aux-client transport correction.
|
||||
Runs at the end of every ``resolve_provider_client`` branch so that
|
||||
api_key providers (Kimi Coding Plan), the ``custom`` endpoint, and
|
||||
future /anthropic gateways all land on the right wire format
|
||||
regardless of which branch built the client.
|
||||
|
||||
Returns ``client_obj`` unchanged when:
|
||||
|
||||
- It's already an Anthropic/Codex/Gemini/CopilotACP wrapper.
|
||||
- The endpoint is an OpenAI-wire endpoint.
|
||||
- ``api_mode`` is explicitly set to a non-Anthropic transport.
|
||||
- The ``anthropic`` SDK is not installed (falls back to OpenAI wire).
|
||||
"""
|
||||
# Already wrapped — don't double-wrap.
|
||||
if isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
# Other specialized adapters we should never re-dispatch.
|
||||
if isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
if isinstance(client_obj, GeminiNativeClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
if isinstance(client_obj, CopilotACPClient):
|
||||
return client_obj
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Explicit non-anthropic api_mode wins over URL heuristics.
|
||||
if api_mode and api_mode != "anthropic_messages":
|
||||
return client_obj
|
||||
|
||||
should_wrap = (
|
||||
api_mode == "anthropic_messages"
|
||||
or _endpoint_speaks_anthropic_messages(base_url)
|
||||
)
|
||||
if not should_wrap:
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
|
||||
"not installed — falling back to OpenAI-wire (will likely 404).",
|
||||
base_url,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
try:
|
||||
real_client = build_anthropic_client(api_key, base_url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to build Anthropic client for %s (%s) — falling back to "
|
||||
"OpenAI-wire client.", base_url, exc,
|
||||
)
|
||||
return client_obj
|
||||
|
||||
logger.debug(
|
||||
"Auxiliary transport: wrapping client in AnthropicAuxiliaryClient "
|
||||
"(model=%s, base_url=%s, api_mode=%s)",
|
||||
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
|
||||
)
|
||||
return AnthropicAuxiliaryClient(
|
||||
real_client, model, api_key, base_url, is_oauth=False,
|
||||
)
|
||||
|
||||
|
||||
def _read_nous_auth() -> Optional[dict]:
|
||||
"""Read and validate ~/.hermes/auth.json for an active Nous provider.
|
||||
|
||||
@@ -884,7 +1071,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
return OpenAI(api_key=api_key, base_url=base_url, **extra), model
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
|
||||
return _client, model
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider_id)
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
@@ -910,7 +1099,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
return OpenAI(api_key=api_key, base_url=base_url, **extra), model
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
|
||||
return _client, model
|
||||
|
||||
return None, None
|
||||
|
||||
@@ -1194,7 +1385,13 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False),
|
||||
model,
|
||||
)
|
||||
return OpenAI(api_key=custom_key, base_url=_clean_base, **_extra), model
|
||||
# URL-based anthropic detection for custom endpoints that didn't set
|
||||
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
|
||||
_fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
_fallback_client = _maybe_wrap_anthropic(
|
||||
_fallback_client, model, custom_key, custom_base, custom_mode,
|
||||
)
|
||||
return _fallback_client, model
|
||||
|
||||
|
||||
def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
|
||||
@@ -1745,8 +1942,20 @@ def resolve_provider_client(
|
||||
return True
|
||||
return False
|
||||
|
||||
def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
|
||||
"""Wrap a plain OpenAI client in CodexAuxiliaryClient if Responses API is needed."""
|
||||
def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
|
||||
api_key_str: str = ""):
|
||||
"""Wrap a plain OpenAI client in the correct transport adapter.
|
||||
|
||||
Handles two cases:
|
||||
- ``CodexAuxiliaryClient`` when the endpoint needs the Responses API
|
||||
(explicit ``api_mode=codex_responses`` or api.openai.com + codex
|
||||
model name).
|
||||
- ``AnthropicAuxiliaryClient`` when the endpoint speaks Anthropic
|
||||
Messages (explicit ``api_mode=anthropic_messages``, any ``/anthropic``
|
||||
suffix, ``api.kimi.com/coding``, or ``api.anthropic.com``).
|
||||
|
||||
Clients that are already specialized wrappers pass through unchanged.
|
||||
"""
|
||||
if _needs_codex_wrap(client_obj, base_url_str, final_model_str):
|
||||
logger.debug(
|
||||
"resolve_provider_client: wrapping client in CodexAuxiliaryClient "
|
||||
@@ -1754,7 +1963,11 @@ def resolve_provider_client(
|
||||
api_mode or "auto-detected", final_model_str,
|
||||
base_url_str[:60] if base_url_str else "")
|
||||
return CodexAuxiliaryClient(client_obj, final_model_str)
|
||||
return client_obj
|
||||
# Anthropic-wire endpoints: rewrap plain OpenAI clients so
|
||||
# chat.completions.create() is translated to /v1/messages.
|
||||
return _maybe_wrap_anthropic(
|
||||
client_obj, final_model_str, api_key_str, base_url_str, api_mode,
|
||||
)
|
||||
|
||||
# ── Auto: try all providers in priority order ────────────────────
|
||||
if provider == "auto":
|
||||
@@ -1834,7 +2047,7 @@ def resolve_provider_client(
|
||||
# ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
|
||||
if provider == "custom":
|
||||
if explicit_base_url:
|
||||
custom_base = explicit_base_url.strip()
|
||||
custom_base = _to_openai_base_url(explicit_base_url).strip()
|
||||
custom_key = (
|
||||
(explicit_api_key or "").strip()
|
||||
or os.getenv("OPENAI_API_KEY", "").strip()
|
||||
@@ -1847,7 +2060,7 @@ def resolve_provider_client(
|
||||
)
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(
|
||||
model or _read_main_model() or "gpt-4o-mini",
|
||||
model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini",
|
||||
provider,
|
||||
)
|
||||
extra = {}
|
||||
@@ -1862,7 +2075,7 @@ def resolve_provider_client(
|
||||
is_agent_turn=True, is_vision=is_vision
|
||||
)
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
|
||||
client = _wrap_if_needed(client, final_model, custom_base)
|
||||
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
# Try custom first, then codex, then API-key providers
|
||||
@@ -1872,7 +2085,8 @@ 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 "")
|
||||
client = _wrap_if_needed(client, final_model, _cbase)
|
||||
_ckey = str(getattr(client, "api_key", "") 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))
|
||||
logger.warning("resolve_provider_client: custom/main requested "
|
||||
@@ -1895,10 +2109,22 @@ def resolve_provider_client(
|
||||
entry_api_mode = (api_mode or custom_entry.get("api_mode") or "").strip()
|
||||
if custom_base:
|
||||
final_model = _normalize_resolved_model(
|
||||
model or custom_entry.get("model") or _read_main_model() or "gpt-4o-mini",
|
||||
model
|
||||
or custom_entry.get("model")
|
||||
or (main_runtime.get("model") if main_runtime else None)
|
||||
or _read_main_model()
|
||||
or "gpt-4o-mini",
|
||||
provider,
|
||||
)
|
||||
_clean_base2, _dq2 = _extract_url_query_params(custom_base)
|
||||
# anthropic_messages talks to the /anthropic surface directly;
|
||||
# OpenAI-wire paths (chat_completions / codex_responses) need the
|
||||
# /v1 equivalent. Rewrite only on the OpenAI-wire path so the
|
||||
# Anthropic fallback SDK still sees the original URL.
|
||||
if entry_api_mode == "anthropic_messages":
|
||||
openai_base = custom_base
|
||||
else:
|
||||
openai_base = _to_openai_base_url(custom_base)
|
||||
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
|
||||
_extra2 = {"default_query": _dq2} if _dq2 else {}
|
||||
logger.debug(
|
||||
"resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
|
||||
@@ -1917,7 +2143,12 @@ def resolve_provider_client(
|
||||
"installed — falling back to OpenAI-wire.",
|
||||
provider,
|
||||
)
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base2, **_extra2)
|
||||
# Fallback went OpenAI-wire after all — redo the query
|
||||
# extraction against the rewritten /v1 URL.
|
||||
_fallback_base = _to_openai_base_url(custom_base)
|
||||
_fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)
|
||||
_fb_extra = {"default_query": _fb_dq} if _fb_dq else {}
|
||||
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
sync_anthropic = AnthropicAuxiliaryClient(
|
||||
@@ -1936,7 +2167,7 @@ def resolve_provider_client(
|
||||
):
|
||||
client = CodexAuxiliaryClient(client, final_model)
|
||||
else:
|
||||
client = _wrap_if_needed(client, final_model, custom_base)
|
||||
client = _wrap_if_needed(client, final_model, openai_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
logger.warning(
|
||||
@@ -2029,8 +2260,11 @@ def resolve_provider_client(
|
||||
|
||||
# Honor api_mode for any API-key provider (e.g. direct OpenAI with
|
||||
# codex-family models). The copilot-specific wrapping above handles
|
||||
# copilot; this covers the general case (#6800).
|
||||
client = _wrap_if_needed(client, final_model, base_url)
|
||||
# copilot; this covers the general case (#6800). Also rewraps
|
||||
# Anthropic-wire endpoints (Kimi Coding Plan api.kimi.com/coding,
|
||||
# /anthropic-suffixed gateways) so named providers like kimi-coding
|
||||
# land on the right transport without needing per-provider branches.
|
||||
client = _wrap_if_needed(client, final_model, base_url, api_key)
|
||||
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
@@ -2038,7 +2272,12 @@ def resolve_provider_client(
|
||||
|
||||
if pconfig.auth_type == "external_process":
|
||||
creds = resolve_external_process_provider_credentials(provider)
|
||||
final_model = _normalize_resolved_model(model or _read_main_model(), provider)
|
||||
final_model = _normalize_resolved_model(
|
||||
model
|
||||
or (main_runtime.get("model") if main_runtime else None)
|
||||
or _read_main_model(),
|
||||
provider,
|
||||
)
|
||||
if provider == "copilot-acp":
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
base_url = str(creds.get("base_url", "")).strip()
|
||||
|
||||
@@ -291,14 +291,52 @@ def has_aws_credentials(env: Optional[Dict[str, str]] = None) -> bool:
|
||||
def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str:
|
||||
"""Resolve the AWS region for Bedrock API calls.
|
||||
|
||||
Priority: AWS_REGION → AWS_DEFAULT_REGION → us-east-1 (fallback).
|
||||
Priority:
|
||||
1. AWS_REGION env var
|
||||
2. AWS_DEFAULT_REGION env var
|
||||
3. boto3/botocore configured region (from ~/.aws/config or SSO profile)
|
||||
4. us-east-1 (hard fallback)
|
||||
|
||||
The boto3 fallback is critical for EU/AP users who configure their region
|
||||
in ~/.aws/config via a named profile rather than env vars — without it,
|
||||
live model discovery would always return us.* profile IDs regardless of
|
||||
the user's actual region.
|
||||
"""
|
||||
env = env if env is not None else os.environ
|
||||
return (
|
||||
explicit = (
|
||||
env.get("AWS_REGION", "").strip()
|
||||
or env.get("AWS_DEFAULT_REGION", "").strip()
|
||||
or "us-east-1"
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
try:
|
||||
import botocore.session
|
||||
region = botocore.session.get_session().get_config_variable("region")
|
||||
if region:
|
||||
return region
|
||||
except Exception:
|
||||
pass
|
||||
return "us-east-1"
|
||||
|
||||
|
||||
def bedrock_model_ids_or_none() -> Optional[List[str]]:
|
||||
"""Live-discover Bedrock model IDs for the active region.
|
||||
|
||||
Returns a list of model ID strings if discovery succeeds and yields
|
||||
at least one model, or ``None`` on failure / empty result. Callers
|
||||
should fall back to the static curated list when ``None`` is returned.
|
||||
|
||||
This helper consolidates the discover → extract-ids → fallback
|
||||
pattern that was previously duplicated across ``provider_model_ids``,
|
||||
``list_authenticated_providers`` section 2, and section 3.
|
||||
"""
|
||||
try:
|
||||
discovered = discover_bedrock_models(resolve_bedrock_region())
|
||||
if discovered:
|
||||
return [m["id"] for m in discovered]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -340,6 +340,8 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_summary_error = None
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
|
||||
@@ -448,6 +450,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 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
|
||||
# succeeded. Silent recovery would hide the broken config.
|
||||
self._last_aux_model_failure_error: Optional[str] = None
|
||||
self._last_aux_model_failure_model: Optional[str] = None
|
||||
|
||||
def update_from_response(self, usage: Dict[str, Any]):
|
||||
"""Update tracked token usage from API response."""
|
||||
@@ -907,10 +915,50 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
"Falling back to main model '%s' for compression.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure so callers can warn the user
|
||||
# even if the retry-on-main succeeds — a misconfigured aux
|
||||
# model is something the user needs to fix.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) # retry immediately
|
||||
|
||||
# Unknown-error best-effort retry on main model. Losing N turns of
|
||||
# context is almost always worse than one extra summary attempt, so
|
||||
# if we haven't already fallen back and the summary model differs
|
||||
# from the main model, try once more on main before entering
|
||||
# cooldown. Errors that DID match _is_model_not_found above are
|
||||
# already handled by the fast-path retry; this branch catches
|
||||
# everything else (400s, provider-specific "no route" strings,
|
||||
# aggregator rejections, etc.) where auto-retry is still safer
|
||||
# than dropping the turns.
|
||||
if (
|
||||
self.summary_model
|
||||
and self.summary_model != self.model
|
||||
and not getattr(self, "_summary_model_fallen_back", False)
|
||||
):
|
||||
self._summary_model_fallen_back = True
|
||||
logging.warning(
|
||||
"Summary model '%s' failed (%s). "
|
||||
"Retrying on main model '%s' before giving up.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure (see 404 branch above) — user
|
||||
# should know their configured model is broken even if main
|
||||
# recovers the call.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
|
||||
# Transient errors (timeout, rate limit, network) — shorter cooldown
|
||||
_transient_cooldown = 60
|
||||
self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown
|
||||
@@ -1208,6 +1256,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_summary_error = None
|
||||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
n_messages = len(messages)
|
||||
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
|
||||
_min_for_compress = self.protect_first_n + 3 + 1
|
||||
|
||||
@@ -7,7 +7,6 @@ import random
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, fields, replace
|
||||
from datetime import datetime
|
||||
@@ -456,6 +455,70 @@ class CredentialPool:
|
||||
logger.debug("Failed to sync from credentials file: %s", exc)
|
||||
return entry
|
||||
|
||||
def _sync_codex_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
|
||||
"""Sync a Codex device_code pool entry from auth.json if tokens differ.
|
||||
|
||||
When a Codex OAuth access token expires (or the ChatGPT account hits
|
||||
its 5h/weekly quota), the pool entry gets marked ``STATUS_EXHAUSTED``
|
||||
with a ``last_error_reset_at`` that can be many hours in the future.
|
||||
Meanwhile the user may run ``hermes model`` / ``hermes auth`` which
|
||||
performs a fresh device-code login and writes new tokens to
|
||||
``auth.json`` under ``_auth_store_lock``. Without this sync the pool
|
||||
entry stays frozen until ``last_error_reset_at`` elapses — even
|
||||
though fresh credentials are sitting on disk — and every request
|
||||
fails with "no available entries (all exhausted or empty)".
|
||||
|
||||
Mirrors the Nous/Anthropic resync paths above. Only applies to
|
||||
device_code-sourced entries; env/API-key-sourced entries have no
|
||||
auth.json shadow to sync from.
|
||||
"""
|
||||
if self.provider != "openai-codex" or entry.source != "device_code":
|
||||
return entry
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "openai-codex")
|
||||
if not isinstance(state, dict):
|
||||
return entry
|
||||
tokens = state.get("tokens")
|
||||
if not isinstance(tokens, dict):
|
||||
return entry
|
||||
store_access = tokens.get("access_token", "")
|
||||
store_refresh = tokens.get("refresh_token", "")
|
||||
# Adopt auth.json tokens when either side differs. Codex refresh
|
||||
# tokens are single-use too, so a fresh refresh_token from
|
||||
# another process means our entry's pair is consumed/stale.
|
||||
entry_access = entry.access_token or ""
|
||||
entry_refresh = entry.refresh_token or ""
|
||||
if store_access and (
|
||||
store_access != entry_access
|
||||
or (store_refresh and store_refresh != entry_refresh)
|
||||
):
|
||||
logger.debug(
|
||||
"Pool entry %s: syncing Codex tokens from auth.json "
|
||||
"(refreshed by another process)",
|
||||
entry.id,
|
||||
)
|
||||
field_updates: Dict[str, Any] = {
|
||||
"access_token": store_access,
|
||||
"refresh_token": store_refresh or entry.refresh_token,
|
||||
"last_status": None,
|
||||
"last_status_at": None,
|
||||
"last_error_code": None,
|
||||
"last_error_reason": None,
|
||||
"last_error_message": None,
|
||||
"last_error_reset_at": None,
|
||||
}
|
||||
if state.get("last_refresh"):
|
||||
field_updates["last_refresh"] = state["last_refresh"]
|
||||
updated = replace(entry, **field_updates)
|
||||
self._replace_entry(entry, updated)
|
||||
self._persist()
|
||||
return updated
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync Codex entry from auth.json: %s", exc)
|
||||
return entry
|
||||
|
||||
def _sync_nous_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
|
||||
"""Sync a Nous pool entry from auth.json if tokens differ.
|
||||
|
||||
@@ -788,6 +851,18 @@ class CredentialPool:
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
cleared_any = True
|
||||
# For openai-codex entries, same pattern: the user may have
|
||||
# re-authed via `hermes model` / `hermes auth` after a 429/401,
|
||||
# leaving fresh tokens on disk while the pool entry is still
|
||||
# frozen behind last_error_reset_at (can be hours in the
|
||||
# future for ChatGPT weekly windows).
|
||||
if (self.provider == "openai-codex"
|
||||
and entry.source == "device_code"
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_codex_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
cleared_any = True
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
|
||||
@@ -47,7 +47,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -223,7 +223,8 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
target = args.get("target", "")
|
||||
if action == "add":
|
||||
content = _oneline(args.get("content", ""))
|
||||
return f"+{target}: \"{content[:25]}{'...' if len(content) > 25 else ''}\""
|
||||
target_prefix = f"+{target}: " if target else "+"
|
||||
return f"{target_prefix}\"{content[:25]}{'...' if len(content) > 25 else ''}\""
|
||||
elif action == "replace":
|
||||
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
|
||||
return f"~{target}: \"{old[:20]}\""
|
||||
|
||||
@@ -91,6 +91,7 @@ class ClassifiedError:
|
||||
_BILLING_PATTERNS = [
|
||||
"insufficient credits",
|
||||
"insufficient_quota",
|
||||
"insufficient balance",
|
||||
"credit balance",
|
||||
"credits have been exhausted",
|
||||
"top up your credits",
|
||||
|
||||
@@ -30,7 +30,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
@@ -42,7 +41,6 @@ from agent import google_oauth
|
||||
from agent.gemini_schema import sanitize_gemini_tool_parameters
|
||||
from agent.google_code_assist import (
|
||||
CODE_ASSIST_ENDPOINT,
|
||||
FREE_TIER_ID,
|
||||
CodeAssistError,
|
||||
ProjectContext,
|
||||
resolve_project_context,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict
|
||||
|
||||
# Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema``
|
||||
# object, which is only a subset of OpenAPI 3.0 / JSON Schema. Strip fields
|
||||
|
||||
@@ -29,7 +29,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
@@ -49,14 +49,13 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
@@ -98,6 +97,7 @@ _DEFAULT_CLIENT_SECRET = f"GOCSPX-{_PUBLIC_CLIENT_SECRET_SUFFIX}"
|
||||
|
||||
# Regex patterns for fallback scraping from an installed gemini-cli.
|
||||
import re as _re
|
||||
from utils import atomic_replace
|
||||
_CLIENT_ID_PATTERN = _re.compile(
|
||||
r"OAUTH_CLIENT_ID\s*=\s*['\"]([0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com)['\"]"
|
||||
)
|
||||
@@ -499,7 +499,7 @@ def save_credentials(creds: GoogleCredentials) -> Path:
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
os.replace(tmp_path, path)
|
||||
atomic_replace(tmp_path, path)
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""LM Studio reasoning-effort resolution shared by the chat-completions
|
||||
transport and run_agent's iteration-limit summary path.
|
||||
|
||||
LM Studio publishes per-model ``capabilities.reasoning.allowed_options`` (e.g.
|
||||
``["off","on"]`` for toggle-style models, ``["off","minimal","low"]`` for
|
||||
graduated models). We map the user's ``reasoning_config`` onto LM Studio's
|
||||
OpenAI-compatible vocabulary, then clamp against the model's allowed set so
|
||||
the server doesn't 400 on an unsupported effort.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
# LM Studio accepts these top-level reasoning_effort values via its
|
||||
# OpenAI-compatible chat.completions endpoint.
|
||||
_LM_VALID_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
|
||||
# Toggle-style models publish allowed_options as ["off","on"] in /api/v1/models.
|
||||
# Map them onto the OpenAI-compatible request vocabulary.
|
||||
_LM_EFFORT_ALIASES = {"off": "none", "on": "medium"}
|
||||
|
||||
|
||||
def resolve_lmstudio_effort(
|
||||
reasoning_config: Optional[dict],
|
||||
allowed_options: Optional[List[str]],
|
||||
) -> Optional[str]:
|
||||
"""Return the ``reasoning_effort`` string to send to LM Studio, or ``None``.
|
||||
|
||||
``None`` means "omit the field": the user picked a level the model can't
|
||||
honor, so let LM Studio fall back to the model's declared default rather
|
||||
than silently substituting a different effort. When ``allowed_options`` is
|
||||
falsy (probe failed), skip clamping and send the resolved effort anyway.
|
||||
"""
|
||||
effort = "medium"
|
||||
if reasoning_config and isinstance(reasoning_config, dict):
|
||||
if reasoning_config.get("enabled") is False:
|
||||
effort = "none"
|
||||
else:
|
||||
raw = (reasoning_config.get("effort") or "").strip().lower()
|
||||
raw = _LM_EFFORT_ALIASES.get(raw, raw)
|
||||
if raw in _LM_VALID_EFFORTS:
|
||||
effort = raw
|
||||
if allowed_options:
|
||||
allowed = {_LM_EFFORT_ALIASES.get(opt, opt) for opt in allowed_options}
|
||||
if effort not in allowed:
|
||||
return None
|
||||
return effort
|
||||
@@ -28,7 +28,6 @@ Usage in run_agent.py:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import inspect
|
||||
|
||||
+14
-9
@@ -52,6 +52,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"gmi",
|
||||
"tencent-tokenhub",
|
||||
"custom", "local",
|
||||
# Common aliases
|
||||
"google", "google-gemini", "google-ai-studio",
|
||||
@@ -60,6 +61,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"ollama",
|
||||
"stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"mimo", "xiaomi-mimo",
|
||||
"tencent", "tokenhub", "tencent-cloud", "tencentmaas",
|
||||
"arcee-ai", "arceeai",
|
||||
"gmi-cloud", "gmicloud",
|
||||
"xai", "x-ai", "x.ai", "grok",
|
||||
@@ -208,6 +210,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
|
||||
# Kimi
|
||||
"kimi": 262144,
|
||||
# Tencent — Hy3 Preview (Hunyuan) with 256K context window
|
||||
"hy3-preview": 256000,
|
||||
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
|
||||
"nemotron": 131072,
|
||||
# Arcee
|
||||
@@ -310,6 +314,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"api.xiaomimimo.com": "xiaomi",
|
||||
"xiaomimimo.com": "xiaomi",
|
||||
"api.gmi-serving.com": "gmi",
|
||||
"tokenhub.tencentmaas.com": "tencent-tokenhub",
|
||||
"ollama.com": "ollama-cloud",
|
||||
}
|
||||
|
||||
@@ -620,8 +625,6 @@ def fetch_endpoint_model_metadata(
|
||||
if isinstance(ctx, int) and ctx > 0:
|
||||
context_length = ctx
|
||||
break
|
||||
if context_length is None:
|
||||
context_length = _extract_context_length(model)
|
||||
if context_length is not None:
|
||||
entry["context_length"] = context_length
|
||||
|
||||
@@ -1011,10 +1014,7 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = "") ->
|
||||
ctx = cfg.get("context_length")
|
||||
if ctx and isinstance(ctx, (int, float)):
|
||||
return int(ctx)
|
||||
# Fall back to max_context_length (theoretical model max)
|
||||
ctx = m.get("max_context_length") or m.get("context_length")
|
||||
if ctx and isinstance(ctx, (int, float)):
|
||||
return int(ctx)
|
||||
break
|
||||
|
||||
# LM Studio / vLLM / llama.cpp: try /v1/models/{model}
|
||||
resp = client.get(f"{server_url}/v1/models/{model}")
|
||||
@@ -1276,7 +1276,10 @@ def get_model_context_length(
|
||||
model = _strip_provider_prefix(model)
|
||||
|
||||
# 1. Check persistent cache (model+provider)
|
||||
if base_url:
|
||||
# LM Studio is excluded — its loaded context length is transient (the
|
||||
# user can reload the model with a different context_length at any time
|
||||
# via /api/v1/models/load), so a stale cached value would mask reloads.
|
||||
if base_url and provider != "lmstudio":
|
||||
cached = get_cached_context_length(model, base_url)
|
||||
if cached is not None:
|
||||
# Invalidate stale Codex OAuth cache entries: pre-PR #14935 builds
|
||||
@@ -1329,7 +1332,8 @@ def get_model_context_length(
|
||||
if is_local_endpoint(base_url):
|
||||
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
|
||||
if local_ctx and local_ctx > 0:
|
||||
save_context_length(model, base_url, local_ctx)
|
||||
if provider != "lmstudio":
|
||||
save_context_length(model, base_url, local_ctx)
|
||||
return local_ctx
|
||||
logger.info(
|
||||
"Could not detect context length for model %r at %s — "
|
||||
@@ -1419,7 +1423,8 @@ def get_model_context_length(
|
||||
if base_url and is_local_endpoint(base_url):
|
||||
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
|
||||
if local_ctx and local_ctx > 0:
|
||||
save_context_length(model, base_url, local_ctx)
|
||||
if provider != "lmstudio":
|
||||
save_context_length(model, base_url, local_ctx)
|
||||
return local_ctx
|
||||
|
||||
# 10. Default fallback — 128K
|
||||
|
||||
@@ -18,6 +18,7 @@ import os
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Mapping, Optional
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,7 +119,7 @@ def record_nous_rate_limit(
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(state, f)
|
||||
os.replace(tmp_path, path)
|
||||
atomic_replace(tmp_path, path)
|
||||
except Exception:
|
||||
# Clean up temp file on failure
|
||||
try:
|
||||
|
||||
@@ -310,6 +310,10 @@ PLATFORM_HINTS = {
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
||||
"key: value pairs over pipe tables (any tables you do emit are "
|
||||
"auto-rewritten into row-group bullets, which you can produce "
|
||||
"directly for cleaner output). "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
|
||||
+7
-3
@@ -56,8 +56,12 @@ _SENSITIVE_BODY_KEYS = frozenset({
|
||||
})
|
||||
|
||||
# Snapshot at import time so runtime env mutations (e.g. LLM-generated
|
||||
# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction mid-session.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off")
|
||||
# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction
|
||||
# mid-session. OFF by default — user must opt in via
|
||||
# `security.redact_secrets: true` in config.yaml (bridged to this env var
|
||||
# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true`
|
||||
# in ~/.hermes/.env.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# Known API key prefixes -- match the prefix + contiguous token chars
|
||||
_PREFIX_PATTERNS = [
|
||||
@@ -257,7 +261,7 @@ def redact_sensitive_text(text: str) -> str:
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Disabled when security.redact_secrets is false in config.yaml.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
@@ -76,6 +76,7 @@ except ImportError: # pragma: no cover
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -568,7 +569,7 @@ def save_allowlist(data: Dict[str, Any]) -> None:
|
||||
try:
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write(json.dumps(data, indent=2, sort_keys=True))
|
||||
os.replace(tmp_path, p)
|
||||
atomic_replace(tmp_path, p)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -30,10 +30,12 @@ def generate_title(
|
||||
assistant_response: str,
|
||||
timeout: float = 30.0,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
main_runtime: dict = None,
|
||||
) -> Optional[str]:
|
||||
"""Generate a session title from the first exchange.
|
||||
|
||||
Uses the auxiliary LLM client (cheapest/fastest available model).
|
||||
Uses the main runtime's model when available, falling back to the
|
||||
auxiliary LLM client (cheapest/fastest available model).
|
||||
Returns the title string or None on failure.
|
||||
|
||||
``failure_callback`` is invoked with ``(task, exception)`` when the
|
||||
@@ -57,6 +59,7 @@ def generate_title(
|
||||
max_tokens=500,
|
||||
temperature=0.3,
|
||||
timeout=timeout,
|
||||
main_runtime=main_runtime,
|
||||
)
|
||||
title = (response.choices[0].message.content or "").strip()
|
||||
# Clean up: remove quotes, trailing punctuation, prefixes like "Title: "
|
||||
@@ -86,6 +89,7 @@ def auto_title_session(
|
||||
user_message: str,
|
||||
assistant_response: str,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
main_runtime: dict = None,
|
||||
) -> None:
|
||||
"""Generate and set a session title if one doesn't already exist.
|
||||
|
||||
@@ -107,7 +111,7 @@ def auto_title_session(
|
||||
return
|
||||
|
||||
title = generate_title(
|
||||
user_message, assistant_response, failure_callback=failure_callback
|
||||
user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime
|
||||
)
|
||||
if not title:
|
||||
return
|
||||
@@ -126,6 +130,7 @@ def maybe_auto_title(
|
||||
assistant_response: str,
|
||||
conversation_history: list,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
main_runtime: dict = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget title generation after the first exchange.
|
||||
|
||||
@@ -147,7 +152,7 @@ def maybe_auto_title(
|
||||
thread = threading.Thread(
|
||||
target=auto_title_session,
|
||||
args=(session_db, session_id, user_message, assistant_response),
|
||||
kwargs={"failure_callback": failure_callback},
|
||||
kwargs={"failure_callback": failure_callback, "main_runtime": main_runtime},
|
||||
daemon=True,
|
||||
name="auto-title",
|
||||
)
|
||||
|
||||
@@ -85,9 +85,6 @@ class AnthropicTransport(ProviderTransport):
|
||||
from agent.anthropic_adapter import _to_plain_data
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
_MCP_PREFIX = "mcp_"
|
||||
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
reasoning_details = []
|
||||
@@ -102,13 +99,10 @@ class AnthropicTransport(ProviderTransport):
|
||||
if isinstance(block_dict, dict):
|
||||
reasoning_details.append(block_dict)
|
||||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||
name = name[len(_MCP_PREFIX):]
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=block.id,
|
||||
name=name,
|
||||
name=block.name,
|
||||
arguments=json.dumps(block.input),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,12 +12,65 @@ reasoning configuration, temperature handling, and extra_body assembly.
|
||||
import copy
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools
|
||||
from agent.prompt_builder import DEVELOPER_ROLE_MODELS
|
||||
from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse, ToolCall, Usage
|
||||
|
||||
|
||||
def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None:
|
||||
"""Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig.
|
||||
|
||||
Gemini native/cloud-code adapters do not read ``extra_body.reasoning``.
|
||||
They only inspect ``extra_body.thinking_config`` / ``thinkingConfig`` and
|
||||
then request thought parts with ``includeThoughts`` enabled.
|
||||
"""
|
||||
if reasoning_config is None or not isinstance(reasoning_config, dict):
|
||||
return None
|
||||
|
||||
if reasoning_config.get("enabled") is False:
|
||||
# Gemini can hide thought parts even when internal thinking still
|
||||
# happens; omit thinkingLevel to avoid model-specific validation quirks.
|
||||
return {"includeThoughts": False}
|
||||
|
||||
effort = str(reasoning_config.get("effort", "medium") or "medium").strip().lower()
|
||||
if effort == "none":
|
||||
return {"includeThoughts": False}
|
||||
|
||||
thinking_config: Dict[str, Any] = {"includeThoughts": True}
|
||||
normalized_model = (model or "").strip().lower()
|
||||
if normalized_model.startswith("google/"):
|
||||
normalized_model = normalized_model.split("/", 1)[1]
|
||||
|
||||
# Gemini 2.5 accepts thinkingBudget; don't guess a budget from Hermes'
|
||||
# coarse effort levels. ``includeThoughts`` alone is enough to surface
|
||||
# thought parts without risking request validation errors.
|
||||
if normalized_model.startswith("gemini-2.5-"):
|
||||
return thinking_config
|
||||
|
||||
if effort not in {"minimal", "low", "medium", "high", "xhigh"}:
|
||||
effort = "medium"
|
||||
|
||||
# Gemini 3 Flash documents low/medium/high thinking levels; Gemini 3 Pro
|
||||
# is stricter (low/high). Clamp Hermes' wider effort set to what each
|
||||
# family accepts so we never forward an undocumented level verbatim.
|
||||
if normalized_model.startswith(("gemini-3", "gemini-3.1")):
|
||||
if "flash" in normalized_model:
|
||||
if effort in {"minimal", "low"}:
|
||||
thinking_config["thinkingLevel"] = "low"
|
||||
elif effort in {"high", "xhigh"}:
|
||||
thinking_config["thinkingLevel"] = "high"
|
||||
else:
|
||||
thinking_config["thinkingLevel"] = "medium"
|
||||
elif "pro" in normalized_model:
|
||||
thinking_config["thinkingLevel"] = (
|
||||
"high" if effort in {"high", "xhigh"} else "low"
|
||||
)
|
||||
|
||||
return thinking_config
|
||||
|
||||
|
||||
class ChatCompletionsTransport(ProviderTransport):
|
||||
"""Transport for api_mode='chat_completions'.
|
||||
|
||||
@@ -101,6 +154,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
is_github_models: bool
|
||||
is_nvidia_nim: bool
|
||||
is_kimi: bool
|
||||
is_lmstudio: bool
|
||||
is_custom_provider: bool
|
||||
ollama_num_ctx: int | None
|
||||
# Provider routing
|
||||
@@ -114,6 +168,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
# Reasoning
|
||||
supports_reasoning: bool
|
||||
github_reasoning_extra: dict | None
|
||||
lmstudio_reasoning_options: list[str] | None # raw allowed_options from /api/v1/models
|
||||
# Claude on OpenRouter/Nous max output
|
||||
anthropic_max_output: int | None
|
||||
# Extra
|
||||
@@ -188,6 +243,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
anthropic_max_out = params.get("anthropic_max_output")
|
||||
is_nvidia_nim = params.get("is_nvidia_nim", False)
|
||||
is_kimi = params.get("is_kimi", False)
|
||||
is_tokenhub = params.get("is_tokenhub", False)
|
||||
reasoning_config = params.get("reasoning_config")
|
||||
|
||||
if ephemeral is not None and max_tokens_fn:
|
||||
@@ -219,12 +275,40 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
_kimi_effort = _e
|
||||
api_kwargs["reasoning_effort"] = _kimi_effort
|
||||
|
||||
# Tencent TokenHub: top-level reasoning_effort (unless thinking disabled)
|
||||
if is_tokenhub:
|
||||
_tokenhub_thinking_off = bool(
|
||||
reasoning_config
|
||||
and isinstance(reasoning_config, dict)
|
||||
and reasoning_config.get("enabled") is False
|
||||
)
|
||||
if not _tokenhub_thinking_off:
|
||||
_tokenhub_effort = "high"
|
||||
if reasoning_config and isinstance(reasoning_config, dict):
|
||||
_e = (reasoning_config.get("effort") or "").strip().lower()
|
||||
if _e in ("low", "medium", "high"):
|
||||
_tokenhub_effort = _e
|
||||
api_kwargs["reasoning_effort"] = _tokenhub_effort
|
||||
|
||||
# LM Studio: top-level reasoning_effort. Only emit when the model
|
||||
# declares reasoning support via /api/v1/models capabilities (gated
|
||||
# upstream by params["supports_reasoning"]). resolve_lmstudio_effort
|
||||
# is shared with run_agent's summary path so both stay in sync.
|
||||
if params.get("is_lmstudio", False) and params.get("supports_reasoning", False):
|
||||
_lm_effort = resolve_lmstudio_effort(
|
||||
reasoning_config,
|
||||
params.get("lmstudio_reasoning_options"),
|
||||
)
|
||||
if _lm_effort is not None:
|
||||
api_kwargs["reasoning_effort"] = _lm_effort
|
||||
|
||||
# extra_body assembly
|
||||
extra_body: Dict[str, Any] = {}
|
||||
|
||||
is_openrouter = params.get("is_openrouter", False)
|
||||
is_nous = params.get("is_nous", False)
|
||||
is_github_models = params.get("is_github_models", False)
|
||||
provider_name = str(params.get("provider_name") or "").strip().lower()
|
||||
|
||||
provider_prefs = params.get("provider_preferences")
|
||||
if provider_prefs and is_openrouter:
|
||||
@@ -240,8 +324,9 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
"type": "enabled" if _kimi_thinking_enabled else "disabled",
|
||||
}
|
||||
|
||||
# Reasoning
|
||||
if params.get("supports_reasoning", False):
|
||||
# Reasoning. LM Studio is handled above via top-level reasoning_effort,
|
||||
# so skip emitting extra_body.reasoning for it.
|
||||
if params.get("supports_reasoning", False) and not params.get("is_lmstudio", False):
|
||||
if is_github_models:
|
||||
gh_reasoning = params.get("github_reasoning_extra")
|
||||
if gh_reasoning is not None:
|
||||
@@ -277,6 +362,11 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if is_qwen:
|
||||
extra_body["vl_high_resolution_images"] = True
|
||||
|
||||
if provider_name in {"gemini", "google-gemini-cli"}:
|
||||
thinking_config = _build_gemini_thinking_config(model, reasoning_config)
|
||||
if thinking_config:
|
||||
extra_body["thinking_config"] = thinking_config
|
||||
|
||||
# Merge any pre-built extra_body additions
|
||||
additions = params.get("extra_body_additions")
|
||||
if additions:
|
||||
|
||||
@@ -8,7 +8,7 @@ streaming, or the _run_codex_stream() call path.
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse, ToolCall, Usage
|
||||
from agent.transports.types import NormalizedResponse, ToolCall
|
||||
|
||||
|
||||
class ResponsesApiTransport(ProviderTransport):
|
||||
@@ -151,8 +151,6 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
"""Normalize Codex Responses API response to NormalizedResponse."""
|
||||
from agent.codex_responses_adapter import (
|
||||
_normalize_codex_response,
|
||||
_extract_responses_message_text,
|
||||
_extract_responses_reasoning_text,
|
||||
)
|
||||
|
||||
# _normalize_codex_response returns (SimpleNamespace, finish_reason_str)
|
||||
|
||||
@@ -30,14 +30,13 @@ 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)
|
||||
# "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):
|
||||
# "custom" - Any OpenAI-compatible endpoint. Set base_url below.
|
||||
# Aliases: "lmstudio", "ollama", "vllm", "llamacpp" all map to "custom".
|
||||
# Example for LM Studio:
|
||||
# provider: "lmstudio"
|
||||
# base_url: "http://localhost:1234/v1"
|
||||
# No API key needed — local servers typically ignore auth.
|
||||
# "custom" - Any other OpenAI-compatible endpoint. Set base_url below.
|
||||
# Aliases: "ollama", "vllm", "llamacpp" all map to "custom".
|
||||
# LM Studio is first-class and uses provider: "lmstudio".
|
||||
# It works with both no-auth and auth-enabled server modes.
|
||||
#
|
||||
# Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
|
||||
provider: "auto"
|
||||
|
||||
@@ -69,7 +69,9 @@ from agent.usage_pricing import (
|
||||
format_duration_compact,
|
||||
format_token_count_compact,
|
||||
)
|
||||
from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
# NOTE: `from agent.account_usage import ...` is deliberately NOT at module
|
||||
# top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only
|
||||
# needed when the user runs `/limits`. Lazy-imported inside the handler below.
|
||||
from hermes_cli.banner import _format_context_length, format_banner_version_label
|
||||
|
||||
_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||||
@@ -5457,6 +5459,8 @@ class HermesCLI:
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=self.provider or "",
|
||||
current_base_url=self.base_url or "",
|
||||
current_model=self.model or "",
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=50,
|
||||
@@ -6232,6 +6236,8 @@ class HermesCLI:
|
||||
self._console_print(f" Status bar {state}")
|
||||
elif canonical == "verbose":
|
||||
self._toggle_verbose()
|
||||
elif canonical == "footer":
|
||||
self._handle_footer_command(cmd_original)
|
||||
elif canonical == "yolo":
|
||||
self._toggle_yolo()
|
||||
elif canonical == "reasoning":
|
||||
@@ -6859,6 +6865,58 @@ class HermesCLI:
|
||||
if self._apply_tui_skin_style():
|
||||
print(" Prompt + TUI colors updated.")
|
||||
|
||||
def _handle_footer_command(self, cmd_original: str) -> None:
|
||||
"""Toggle or inspect ``display.runtime_footer.enabled`` from the CLI.
|
||||
|
||||
Usage:
|
||||
/footer → toggle
|
||||
/footer on|off → explicit
|
||||
/footer status → show current state
|
||||
"""
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.colors import Colors as _Colors
|
||||
|
||||
# Parse arg
|
||||
arg = ""
|
||||
try:
|
||||
parts = (cmd_original or "").strip().split(None, 1)
|
||||
if len(parts) > 1:
|
||||
arg = parts[1].strip().lower()
|
||||
except Exception:
|
||||
arg = ""
|
||||
|
||||
cfg = load_config() or {}
|
||||
footer_cfg = ((cfg.get("display") or {}).get("runtime_footer") or {})
|
||||
current = bool(footer_cfg.get("enabled", False))
|
||||
fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"]
|
||||
|
||||
if arg in ("status", "?"):
|
||||
state = "ON" if current else "OFF"
|
||||
_cprint(
|
||||
f" {_Colors.BOLD}Runtime footer:{_Colors.RESET} {state}\n"
|
||||
f" Fields: {', '.join(fields)}"
|
||||
)
|
||||
return
|
||||
|
||||
if arg in ("on", "enable", "true", "1"):
|
||||
new_state = True
|
||||
elif arg in ("off", "disable", "false", "0"):
|
||||
new_state = False
|
||||
elif arg == "":
|
||||
new_state = not current
|
||||
else:
|
||||
_cprint(" Usage: /footer [on|off|status]")
|
||||
return
|
||||
|
||||
if save_config_value("display.runtime_footer.enabled", new_state):
|
||||
state = (
|
||||
f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state
|
||||
else f"{_Colors.DIM}OFF{_Colors.RESET}"
|
||||
)
|
||||
_cprint(f" Runtime footer: {state}")
|
||||
else:
|
||||
_cprint(" Failed to save runtime_footer setting to config.yaml")
|
||||
|
||||
def _toggle_verbose(self):
|
||||
"""Cycle tool progress mode: off → new → all → verbose → off."""
|
||||
cycle = ["off", "new", "all", "verbose"]
|
||||
@@ -7099,9 +7157,15 @@ class HermesCLI:
|
||||
else:
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
|
||||
|
||||
# Pass None as system_message so _compress_context rebuilds
|
||||
# the system prompt from scratch via _build_system_prompt(None).
|
||||
# Passing _cached_system_prompt caused duplication because
|
||||
# _build_system_prompt appends system_message to prompt_parts
|
||||
# which already contain the agent identity — resulting in the
|
||||
# identity block appearing twice (issue #15281).
|
||||
compressed, _ = self.agent._compress_context(
|
||||
original_history,
|
||||
self.agent._cached_system_prompt or "",
|
||||
None,
|
||||
approx_tokens=approx_tokens,
|
||||
focus_topic=focus_topic or None,
|
||||
)
|
||||
@@ -7225,6 +7289,8 @@ class HermesCLI:
|
||||
provider = getattr(agent, "provider", None) or getattr(self, "provider", None)
|
||||
base_url = getattr(agent, "base_url", None) or getattr(self, "base_url", None)
|
||||
api_key = getattr(agent, "api_key", None) or getattr(self, "api_key", None)
|
||||
# Lazy import — pulls the OpenAI SDK chain, only needed here.
|
||||
from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
account_snapshot = None
|
||||
if provider:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool:
|
||||
@@ -8814,6 +8880,13 @@ class HermesCLI:
|
||||
response,
|
||||
self.conversation_history,
|
||||
failure_callback=_title_failure_cb,
|
||||
main_runtime={
|
||||
"model": self.model,
|
||||
"provider": self.provider,
|
||||
"base_url": self.base_url,
|
||||
"api_key": self.api_key,
|
||||
"api_mode": self.api_mode,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+3
-2
@@ -21,6 +21,7 @@ from typing import Optional, Dict, List, Any, Union
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from hermes_time import now as _hermes_now
|
||||
from utils import atomic_replace
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
@@ -367,7 +368,7 @@ def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, JOBS_FILE)
|
||||
atomic_replace(tmp_path, JOBS_FILE)
|
||||
_secure_file(JOBS_FILE)
|
||||
except BaseException:
|
||||
try:
|
||||
@@ -863,7 +864,7 @@ def save_job_output(job_id: str, output: str):
|
||||
f.write(output)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, output_file)
|
||||
atomic_replace(tmp_path, output_file)
|
||||
_secure_file(output_file)
|
||||
except BaseException:
|
||||
try:
|
||||
|
||||
+3
-1
@@ -198,7 +198,9 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
|
||||
if resolved:
|
||||
parsed_chat_id, parsed_thread_id, resolved_is_explicit = _parse_target_ref(platform_key, resolved)
|
||||
if resolved_is_explicit:
|
||||
chat_id, thread_id = parsed_chat_id, parsed_thread_id
|
||||
chat_id = parsed_chat_id
|
||||
if parsed_thread_id is not None:
|
||||
thread_id = parsed_thread_id
|
||||
else:
|
||||
chat_id = resolved
|
||||
except Exception:
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Built-in boot-md hook — run ~/.hermes/BOOT.md on gateway startup.
|
||||
|
||||
This hook is always registered. It silently skips if no BOOT.md exists.
|
||||
To activate, create ``~/.hermes/BOOT.md`` with instructions for the
|
||||
agent to execute on every gateway restart.
|
||||
|
||||
Example BOOT.md::
|
||||
|
||||
# Startup Checklist
|
||||
|
||||
1. Check if any cron jobs failed overnight
|
||||
2. Send a status update to Discord #general
|
||||
3. If there are errors in /opt/app/deploy.log, summarize them
|
||||
|
||||
The agent runs in a background thread so it doesn't block gateway
|
||||
startup. If nothing needs attention, it replies with [SILENT] to
|
||||
suppress delivery.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger("hooks.boot-md")
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
HERMES_HOME = get_hermes_home()
|
||||
BOOT_FILE = HERMES_HOME / "BOOT.md"
|
||||
|
||||
|
||||
def _build_boot_prompt(content: str) -> str:
|
||||
"""Wrap BOOT.md content in a system-level instruction."""
|
||||
return (
|
||||
"You are running a startup boot checklist. Follow the BOOT.md "
|
||||
"instructions below exactly.\n\n"
|
||||
"---\n"
|
||||
f"{content}\n"
|
||||
"---\n\n"
|
||||
"Execute each instruction. If you need to send a message to a "
|
||||
"platform, use the send_message tool.\n"
|
||||
"If nothing needs attention and there is nothing to report, "
|
||||
"reply with ONLY: [SILENT]"
|
||||
)
|
||||
|
||||
|
||||
def _run_boot_agent(content: str) -> None:
|
||||
"""Spawn a one-shot agent session to execute the boot instructions."""
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
|
||||
prompt = _build_boot_prompt(content)
|
||||
agent = AIAgent(
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
max_iterations=20,
|
||||
)
|
||||
result = agent.run_conversation(prompt)
|
||||
response = result.get("final_response", "")
|
||||
if response and "[SILENT]" not in response:
|
||||
logger.info("boot-md completed: %s", response[:200])
|
||||
else:
|
||||
logger.info("boot-md completed (nothing to report)")
|
||||
except Exception as e:
|
||||
logger.error("boot-md agent failed: %s", e)
|
||||
|
||||
|
||||
async def handle(event_type: str, context: dict) -> None:
|
||||
"""Gateway startup handler — run BOOT.md if it exists."""
|
||||
if not BOOT_FILE.exists():
|
||||
return
|
||||
|
||||
content = BOOT_FILE.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
logger.info("Running BOOT.md (%d chars)", len(content))
|
||||
|
||||
# Run in a background thread so we don't block gateway startup.
|
||||
thread = threading.Thread(
|
||||
target=_run_boot_agent,
|
||||
args=(content,),
|
||||
name="boot-md",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
+6
-12
@@ -52,19 +52,13 @@ class HookRegistry:
|
||||
return list(self._loaded_hooks)
|
||||
|
||||
def _register_builtin_hooks(self) -> None:
|
||||
"""Register built-in hooks that are always active."""
|
||||
try:
|
||||
from gateway.builtin_hooks.boot_md import handle as boot_md_handle
|
||||
"""Register built-in hooks that are always active.
|
||||
|
||||
self._handlers.setdefault("gateway:startup", []).append(boot_md_handle)
|
||||
self._loaded_hooks.append({
|
||||
"name": "boot-md",
|
||||
"description": "Run ~/.hermes/BOOT.md on gateway startup",
|
||||
"events": ["gateway:startup"],
|
||||
"path": "(builtin)",
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[hooks] Could not load built-in boot-md hook: {e}", flush=True)
|
||||
Currently empty — no shipped built-in hooks. Kept as the extension
|
||||
point for future always-on gateway hooks so they drop in without
|
||||
re-plumbing discover_and_load().
|
||||
"""
|
||||
return
|
||||
|
||||
def discover_and_load(self) -> None:
|
||||
"""
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_dir
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
|
||||
@@ -59,7 +60,7 @@ def _secure_write(path: Path, data: str) -> None:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, str(path))
|
||||
atomic_replace(tmp_path, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
|
||||
+52
-10
@@ -307,9 +307,14 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
|
||||
|
||||
Returns ``(session_kwargs, request_kwargs)`` where:
|
||||
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
|
||||
- HTTP → ``({}, {"proxy": url})``
|
||||
- None → ``({}, {})``
|
||||
- With aiohttp-socks → ``({"connector": ProxyConnector(...)}, {})``
|
||||
for *all* proxy schemes (SOCKS **and** HTTP/HTTPS).
|
||||
- HTTP without aiohttp-socks → ``({}, {"proxy": url})``.
|
||||
- None → ``({}, {})``.
|
||||
|
||||
Prefer the connector path: it works transparently with libraries
|
||||
(like mautrix) that call ``session.request()`` without forwarding
|
||||
per-request ``proxy=`` kwargs.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -320,20 +325,20 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""
|
||||
if not proxy_url:
|
||||
return {}, {}
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return {}, {}
|
||||
return {}, {"proxy": proxy_url}
|
||||
return {}, {"proxy": proxy_url}
|
||||
|
||||
|
||||
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
|
||||
@@ -902,6 +907,41 @@ class MessageEvent:
|
||||
return args
|
||||
|
||||
|
||||
_PLAINTEXT_GATEWAY_RESTART_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?gateway[.!?\s]*$", re.IGNORECASE),
|
||||
re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?hermes\s+gateway[.!?\s]*$", re.IGNORECASE),
|
||||
re.compile(r"^(?:please\s+)?restart\s+hermes[.!?\s]*$", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
|
||||
"""Rewrite a tiny set of DM plaintext admin phrases into slash commands.
|
||||
|
||||
This keeps high-impact operational phrases like ``restart gateway`` out of
|
||||
the LLM/tool path, where they can trigger a self-restart from inside the
|
||||
currently running agent and leave the gateway stuck in ``draining`` while it
|
||||
waits for that same agent to finish.
|
||||
|
||||
Scope is intentionally narrow: DM text messages only, exact restart-style
|
||||
phrases only. Group chats keep natural-language semantics.
|
||||
"""
|
||||
try:
|
||||
if event is None or event.message_type != MessageType.TEXT:
|
||||
return
|
||||
text = (event.text or "").strip()
|
||||
if not text or text.startswith("/"):
|
||||
return
|
||||
source = getattr(event, "source", None)
|
||||
if getattr(source, "chat_type", None) != "dm":
|
||||
return
|
||||
for pattern in _PLAINTEXT_GATEWAY_RESTART_PATTERNS:
|
||||
if pattern.match(text):
|
||||
event.text = "/restart"
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
"""Result of sending a message."""
|
||||
@@ -2188,6 +2228,8 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
if not self._message_handler:
|
||||
return
|
||||
|
||||
coerce_plaintext_gateway_command(event)
|
||||
|
||||
session_key = build_session_key(
|
||||
event.source,
|
||||
|
||||
@@ -305,7 +305,7 @@ class VoiceReceiver:
|
||||
encrypted = bytes(payload_with_nonce[:-4])
|
||||
|
||||
try:
|
||||
import nacl.secret # noqa: delayed import – only in voice path
|
||||
import nacl.secret # noqa: E402 — delayed import, only in voice path
|
||||
box = nacl.secret.Aead(self._secret_key)
|
||||
decrypted = box.decrypt(encrypted, header, bytes(nonce))
|
||||
except Exception as e:
|
||||
@@ -813,7 +813,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
logger.info("[%s] Synced %d slash command(s) via bulk tree sync", self.name, len(synced))
|
||||
return
|
||||
|
||||
summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=30)
|
||||
# Discord's per-app command-management bucket is ~5 writes / 20 s,
|
||||
# so a mass-prune-plus-upsert reconcile (e.g. 77 orphans + 30
|
||||
# desired = 107 writes) takes several minutes of forced waits.
|
||||
# A flat 30 s budget blew up reliably under bucket pressure and
|
||||
# left slash commands broken for ~60 min until the bucket fully
|
||||
# recovered. Use a wide ceiling; the cap still guards against a
|
||||
# true hang. (#16713)
|
||||
summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600)
|
||||
logger.info(
|
||||
"[%s] Safely reconciled %d slash command(s): unchanged=%d updated=%d recreated=%d created=%d deleted=%d",
|
||||
self.name,
|
||||
@@ -825,7 +832,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
summary["deleted"],
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[%s] Slash command sync timed out after 30s", self.name)
|
||||
logger.warning(
|
||||
"[%s] Slash command sync timed out — Discord rate-limit bucket "
|
||||
"may be saturated; will retry on next reconnect",
|
||||
self.name,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
|
||||
@@ -974,7 +974,6 @@ def build_whole_comment_prompt(
|
||||
|
||||
def _resolve_model_and_runtime() -> Tuple[str, dict]:
|
||||
"""Resolve model and provider credentials, same as gateway message handling."""
|
||||
import os
|
||||
from gateway.run import _load_gateway_config, _resolve_gateway_model
|
||||
|
||||
user_config = _load_gateway_config()
|
||||
|
||||
@@ -11,10 +11,10 @@ import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, Optional
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent
|
||||
from gateway.platforms.base import MessageEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
+420
-44
@@ -11,6 +11,7 @@ Environment variables:
|
||||
MATRIX_PASSWORD Password (alternative to access token)
|
||||
MATRIX_ENCRYPTION Set "true" to enable E2EE
|
||||
MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts
|
||||
MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic
|
||||
MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
|
||||
MATRIX_HOME_ROOM Room ID for cron/notification delivery
|
||||
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
|
||||
@@ -18,6 +19,7 @@ Environment variables:
|
||||
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
|
||||
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
|
||||
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
|
||||
MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
|
||||
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
|
||||
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
|
||||
"""
|
||||
@@ -30,6 +32,8 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from html import escape as _html_escape
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Set
|
||||
@@ -95,11 +99,25 @@ from gateway.platforms.base import (
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
resolve_proxy_url,
|
||||
proxy_kwargs_for_aiohttp,
|
||||
)
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MatrixApprovalPrompt:
|
||||
"""Tracks a pending Matrix reaction-based exec approval prompt."""
|
||||
|
||||
def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False):
|
||||
self.session_key = session_key
|
||||
self.chat_id = chat_id
|
||||
self.message_id = message_id
|
||||
self.resolved = resolved
|
||||
self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id
|
||||
|
||||
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
||||
# but clients render poorly above this).
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
@@ -114,11 +132,85 @@ _CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
|
||||
_OUTBOUND_MENTION_RE = re.compile(
|
||||
r"(?<![\w/])(@[0-9A-Za-z._=/-]+:[0-9A-Za-z.-]+(?::\d+)?)"
|
||||
)
|
||||
|
||||
_E2EE_INSTALL_HINT = (
|
||||
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
|
||||
)
|
||||
|
||||
_MATRIX_IMAGE_FILENAME_EXTS = frozenset({
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
".avif",
|
||||
})
|
||||
|
||||
|
||||
def _looks_like_matrix_image_filename(text: str) -> bool:
|
||||
"""Return True when Matrix image body text is probably just a transport filename.
|
||||
|
||||
Matrix ``m.image`` events commonly populate ``content.body`` with the uploaded
|
||||
filename when the user did not add a caption. Treating that raw filename as
|
||||
user-authored text confuses downstream vision enrichment.
|
||||
"""
|
||||
candidate = str(text or "").strip()
|
||||
if not candidate or "\n" in candidate or candidate.endswith("/"):
|
||||
return False
|
||||
|
||||
name = Path(candidate).name
|
||||
if not name or name != candidate:
|
||||
return False
|
||||
|
||||
suffix = Path(name).suffix.lower()
|
||||
if not suffix:
|
||||
return False
|
||||
|
||||
guessed_type, _ = mimetypes.guess_type(name)
|
||||
if guessed_type and guessed_type.startswith("image/"):
|
||||
return True
|
||||
return suffix in _MATRIX_IMAGE_FILENAME_EXTS
|
||||
|
||||
|
||||
def _create_matrix_session(proxy_url: str | None):
|
||||
"""Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests.
|
||||
|
||||
mautrix's ``HTTPAPI._send()`` calls ``session.request()`` without forwarding
|
||||
per-request ``proxy=`` kwargs. For HTTP(S) proxies we use aiohttp's native
|
||||
``proxy=`` session parameter which sets a default for every request. For SOCKS
|
||||
we use ``aiohttp_socks.ProxyConnector`` (connector-level).
|
||||
When no proxy is configured we enable ``trust_env`` so standard env vars
|
||||
(``HTTP_PROXY`` / ``HTTPS_PROXY``) are honoured automatically.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
if not proxy_url:
|
||||
return aiohttp.ClientSession(trust_env=True)
|
||||
|
||||
if proxy_url.split("://")[0].lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
return aiohttp.ClientSession(
|
||||
connector=ProxyConnector.from_url(proxy_url, rdns=True),
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return aiohttp.ClientSession(trust_env=True)
|
||||
|
||||
return aiohttp.ClientSession(proxy=proxy_url)
|
||||
|
||||
|
||||
def _check_e2ee_deps() -> bool:
|
||||
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
|
||||
@@ -260,6 +352,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self._dm_auto_thread: bool = os.getenv(
|
||||
"MATRIX_DM_AUTO_THREAD", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
self._dm_mention_threads: bool = os.getenv(
|
||||
"MATRIX_DM_MENTION_THREADS", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
@@ -270,6 +365,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
).lower() not in ("false", "0", "no")
|
||||
self._pending_reactions: dict[tuple[str, str], str] = {}
|
||||
|
||||
# Proxy support — resolve once at init, reuse for all HTTP traffic.
|
||||
self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
|
||||
if self._proxy_url:
|
||||
logger.info("Matrix: proxy configured — %s", self._proxy_url)
|
||||
|
||||
# Text batching: merge rapid successive messages (Telegram-style).
|
||||
# Matrix clients split long messages around 4000 chars.
|
||||
self._text_batch_delay_seconds = float(
|
||||
@@ -281,6 +381,18 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
# Matrix reaction-based dangerous command approvals.
|
||||
self._approval_reaction_map = {
|
||||
"✅": "once",
|
||||
"❎": "deny",
|
||||
}
|
||||
self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {}
|
||||
self._approval_prompt_by_session: Dict[str, str] = {}
|
||||
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
|
||||
self._allowed_user_ids: Set[str] = {
|
||||
u.strip() for u in allowed_users_raw.split(",") if u.strip()
|
||||
}
|
||||
|
||||
def _is_duplicate_event(self, event_id) -> bool:
|
||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||
if not event_id:
|
||||
@@ -326,7 +438,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc)
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc, exc_info=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -342,6 +454,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.error(
|
||||
"Matrix: cannot verify device keys on server: %s — refusing E2EE",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -356,7 +469,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
await olm.share_keys()
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc)
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc, exc_info=True)
|
||||
return False
|
||||
return await self._reverify_keys_after_upload(client, local_ed25519)
|
||||
|
||||
@@ -396,6 +509,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"Try generating a new access token to get a fresh device.",
|
||||
client.device_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return await self._reverify_keys_after_upload(client, local_ed25519)
|
||||
@@ -420,9 +534,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create the HTTP API layer.
|
||||
client_session = _create_matrix_session(self._proxy_url)
|
||||
api = HTTPAPI(
|
||||
base_url=self._homeserver,
|
||||
token=self._access_token or "",
|
||||
client_session=client_session,
|
||||
)
|
||||
|
||||
# Create the client.
|
||||
@@ -465,6 +581,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.error(
|
||||
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await api.session.close()
|
||||
return False
|
||||
@@ -607,6 +724,44 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.warning(
|
||||
"Matrix: recovery key verification failed: %s", exc
|
||||
)
|
||||
else:
|
||||
# No recovery key — bootstrap cross-signing if the bot
|
||||
# has none yet. Without this, Element shows "Encrypted
|
||||
# by a device not verified by its owner" on every
|
||||
# message from this bot, indefinitely. mautrix's
|
||||
# generate_recovery_key does the full flow: generates
|
||||
# MSK/SSK/USK, uploads private keys to SSSS, publishes
|
||||
# public keys to the homeserver, and signs the current
|
||||
# device with the new SSK. Some homeservers require UIA
|
||||
# for /keys/device_signing/upload — those will need an
|
||||
# alternate path; Continuwuity and Synapse-with-shared-
|
||||
# secret accept the unauthenticated upload.
|
||||
try:
|
||||
own_xsign = await olm.get_own_cross_signing_public_keys()
|
||||
except Exception as exc:
|
||||
own_xsign = None
|
||||
logger.warning(
|
||||
"Matrix: cross-signing key lookup failed: %s", exc
|
||||
)
|
||||
if own_xsign is None:
|
||||
try:
|
||||
new_recovery_key = await olm.generate_recovery_key()
|
||||
logger.warning(
|
||||
"Matrix: bootstrapped cross-signing for %s. "
|
||||
"SAVE THIS RECOVERY KEY — set "
|
||||
"MATRIX_RECOVERY_KEY for future restarts so "
|
||||
"the bot can re-sign its device after key "
|
||||
"rotation: %s",
|
||||
client.mxid,
|
||||
new_recovery_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Matrix: cross-signing bootstrap failed "
|
||||
"(non-fatal — Element will show 'not "
|
||||
"verified by its owner'): %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
client.crypto = olm
|
||||
logger.info(
|
||||
@@ -664,6 +819,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: initial sync event dispatch error: %s", exc)
|
||||
await self._join_pending_invites(sync_data)
|
||||
else:
|
||||
logger.warning(
|
||||
"Matrix: initial sync returned unexpected type %s",
|
||||
@@ -727,17 +883,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
|
||||
|
||||
last_event_id = None
|
||||
for chunk in chunks:
|
||||
msg_content: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": chunk,
|
||||
}
|
||||
|
||||
# Convert markdown to HTML for rich rendering.
|
||||
html = self._markdown_to_html(chunk)
|
||||
if html and html != chunk:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
for i, chunk in enumerate(chunks):
|
||||
msg_content = self._build_text_message_content(chunk)
|
||||
|
||||
# Reply-to support.
|
||||
if reply_to:
|
||||
@@ -844,25 +991,21 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Edit an existing message (via m.replace)."""
|
||||
|
||||
formatted = self.format_message(content)
|
||||
new_content = self._build_text_message_content(formatted)
|
||||
msg_content: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": f"* {formatted}",
|
||||
"m.new_content": {
|
||||
"msgtype": "m.text",
|
||||
"body": formatted,
|
||||
},
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
},
|
||||
"m.new_content": new_content,
|
||||
}
|
||||
|
||||
html = self._markdown_to_html(formatted)
|
||||
if html and html != formatted:
|
||||
msg_content["m.new_content"]["format"] = "org.matrix.custom.html"
|
||||
msg_content["m.new_content"]["formatted_body"] = html
|
||||
if "m.mentions" in new_content:
|
||||
msg_content["m.mentions"] = new_content["m.mentions"]
|
||||
if "formatted_body" in new_content:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = f"* {html}"
|
||||
msg_content["formatted_body"] = f'* {new_content["formatted_body"]}'
|
||||
msg_content["m.relates_to"] = {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
}
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -895,10 +1038,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Try aiohttp first (always available), fall back to httpx
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
|
||||
async with _aiohttp.ClientSession(trust_env=True) as http:
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
|
||||
async with _aiohttp.ClientSession(**_sess_kw) as http:
|
||||
async with http.get(
|
||||
image_url, timeout=_aiohttp.ClientTimeout(total=30)
|
||||
image_url,
|
||||
timeout=_aiohttp.ClientTimeout(total=30),
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.read()
|
||||
@@ -908,8 +1053,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except ImportError:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
_httpx_kw: dict = {}
|
||||
if self._proxy_url:
|
||||
_httpx_kw["proxy"] = self._proxy_url
|
||||
async with httpx.AsyncClient(**_httpx_kw) as http:
|
||||
resp = await http.get(image_url, follow_redirects=True, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
@@ -984,6 +1131,56 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
chat_id, video_path, "m.video", caption, reply_to, metadata=metadata
|
||||
)
|
||||
|
||||
async def send_exec_approval(
|
||||
self,
|
||||
chat_id: str,
|
||||
command: str,
|
||||
session_key: str,
|
||||
description: str = "dangerous command",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> SendResult:
|
||||
"""Send a reaction-based exec approval prompt for Matrix."""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
|
||||
text = (
|
||||
"⚠️ **Dangerous command requires approval**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {description}\n\n"
|
||||
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||
"You can also click the reaction to approve:\n"
|
||||
"✅ = /approve\n"
|
||||
"❎ = /deny"
|
||||
)
|
||||
|
||||
result = await self.send(chat_id, text, metadata=metadata)
|
||||
if not result.success or not result.message_id:
|
||||
return result
|
||||
|
||||
prompt = _MatrixApprovalPrompt(
|
||||
session_key=session_key,
|
||||
chat_id=chat_id,
|
||||
message_id=result.message_id,
|
||||
)
|
||||
old_event = self._approval_prompt_by_session.get(session_key)
|
||||
if old_event:
|
||||
self._approval_prompts_by_event.pop(old_event, None)
|
||||
self._approval_prompts_by_event[result.message_id] = prompt
|
||||
self._approval_prompt_by_session[session_key] = result.message_id
|
||||
|
||||
for emoji in ("✅", "❎"):
|
||||
try:
|
||||
reaction_result = await self._send_reaction(chat_id, result.message_id, emoji)
|
||||
# Save the bot's reaction event_id for later cleanup
|
||||
if reaction_result:
|
||||
prompt.bot_reaction_events[emoji] = str(reaction_result)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to add approval reaction %s: %s", emoji, exc)
|
||||
|
||||
return result
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Pass-through — Matrix supports standard Markdown natively."""
|
||||
# Strip image markdown; media is uploaded separately.
|
||||
@@ -1115,9 +1312,15 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
next_batch = await client.sync_store.get_next_batch()
|
||||
while not self._closing:
|
||||
try:
|
||||
sync_data = await client.sync(
|
||||
since=next_batch,
|
||||
timeout=30000,
|
||||
# Wrap in asyncio.wait_for to guard against TCP-level hangs
|
||||
# that the Matrix long-poll timeout cannot catch. Long-poll
|
||||
# is 30s, so 45s gives 15s slack for network drain.
|
||||
sync_data = await asyncio.wait_for(
|
||||
client.sync(
|
||||
since=next_batch,
|
||||
timeout=30000,
|
||||
),
|
||||
timeout=45.0,
|
||||
)
|
||||
|
||||
# nio returns SyncError objects (not exceptions) for auth
|
||||
@@ -1153,6 +1356,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: sync event dispatch error: %s", exc)
|
||||
await self._join_pending_invites(sync_data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
@@ -1239,6 +1443,15 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
room_id = str(getattr(event, "room_id", ""))
|
||||
sender = str(getattr(event, "sender", ""))
|
||||
|
||||
# Diagnostic: confirm the callback is firing at all when DEBUG is on.
|
||||
# Helps users troubleshoot silent inbound issues like #5819, #7914, #12614.
|
||||
logger.debug(
|
||||
"Matrix: callback fired — event %s from %s in %s",
|
||||
getattr(event, "event_id", "?"),
|
||||
sender,
|
||||
room_id,
|
||||
)
|
||||
|
||||
# Ignore own messages (case-insensitive; also drops when our own
|
||||
# user_id hasn't been resolved yet — see _is_self_sender docstring
|
||||
# and issue #15763).
|
||||
@@ -1350,6 +1563,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
in_bot_thread = bool(thread_id and thread_id in self._threads)
|
||||
if self._require_mention and not is_free_room and not in_bot_thread:
|
||||
if not is_mentioned:
|
||||
logger.debug(
|
||||
"Matrix: ignoring message %s in %s — no @mention "
|
||||
"(set MATRIX_REQUIRE_MENTION=false to disable)",
|
||||
event_id,
|
||||
room_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# DM mention-thread.
|
||||
@@ -1362,7 +1581,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
body = self._strip_mention(body)
|
||||
|
||||
# Auto-thread.
|
||||
if not is_dm and not thread_id and self._auto_thread:
|
||||
if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)):
|
||||
thread_id = event_id
|
||||
self._threads.mark(thread_id)
|
||||
|
||||
@@ -1604,6 +1823,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return
|
||||
body, is_dm, chat_type, thread_id, display_name, source = ctx
|
||||
|
||||
if msgtype == "m.image" and _looks_like_matrix_image_filename(body):
|
||||
body = ""
|
||||
|
||||
allow_http_fallback = bool(http_url) and not is_encrypted_media
|
||||
media_urls = (
|
||||
[cached_path]
|
||||
@@ -1633,13 +1855,35 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"Matrix: invited to %s — joining",
|
||||
room_id,
|
||||
)
|
||||
await self._join_room_by_id(room_id)
|
||||
|
||||
async def _join_room_by_id(self, room_id: str) -> bool:
|
||||
"""Join a room by ID and refresh local caches on success."""
|
||||
if not room_id:
|
||||
return False
|
||||
if room_id in self._joined_rooms:
|
||||
return True
|
||||
try:
|
||||
await self._client.join_room(RoomID(room_id))
|
||||
self._joined_rooms.add(room_id)
|
||||
logger.info("Matrix: joined %s", room_id)
|
||||
await self._refresh_dm_cache()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: error joining %s: %s", room_id, exc)
|
||||
return False
|
||||
|
||||
async def _join_pending_invites(self, sync_data: Dict[str, Any]) -> None:
|
||||
"""Join rooms still present in rooms.invite after sync processing."""
|
||||
rooms = sync_data.get("rooms", {}) if isinstance(sync_data, dict) else {}
|
||||
invites = rooms.get("invite", {})
|
||||
if not isinstance(invites, dict):
|
||||
return
|
||||
for room_id in invites:
|
||||
if room_id in self._joined_rooms:
|
||||
continue
|
||||
logger.info("Matrix: reconciling pending invite for %s", room_id)
|
||||
await self._join_room_by_id(str(room_id))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reactions (send, receive, processing lifecycle)
|
||||
@@ -1754,6 +1998,51 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
room_id,
|
||||
)
|
||||
|
||||
# Check if this reaction resolves a pending approval prompt.
|
||||
prompt = self._approval_prompts_by_event.get(reacts_to)
|
||||
if prompt and not prompt.resolved:
|
||||
if room_id != prompt.chat_id:
|
||||
return
|
||||
if self._allowed_user_ids and sender not in self._allowed_user_ids:
|
||||
logger.info(
|
||||
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
|
||||
sender, reacts_to,
|
||||
)
|
||||
return
|
||||
choice = self._approval_reaction_map.get(key)
|
||||
if not choice:
|
||||
return
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
|
||||
count = resolve_gateway_approval(prompt.session_key, choice)
|
||||
if count:
|
||||
prompt.resolved = True
|
||||
self._approval_prompts_by_event.pop(reacts_to, None)
|
||||
self._approval_prompt_by_session.pop(prompt.session_key, None)
|
||||
logger.info(
|
||||
"Matrix reaction resolved %d approval(s) for session %s "
|
||||
"(choice=%s, user=%s)",
|
||||
count, prompt.session_key, choice, sender,
|
||||
)
|
||||
# Redact bot's seed reactions, leaving only the user's
|
||||
await self._redact_bot_approval_reactions(room_id, prompt)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc)
|
||||
|
||||
async def _redact_bot_approval_reactions(
|
||||
self,
|
||||
room_id: str,
|
||||
prompt: "_MatrixApprovalPrompt",
|
||||
) -> None:
|
||||
"""Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
|
||||
for emoji, evt_id in prompt.bot_reaction_events.items():
|
||||
try:
|
||||
await self.redact_message(room_id, evt_id, "approval resolved")
|
||||
logger.debug("Matrix: redacted bot reaction %s (%s)", emoji, evt_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to redact bot reaction %s: %s", emoji, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text message aggregation (handles Matrix client-side splits)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1979,11 +2268,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if not self._client or not text:
|
||||
return SendResult(success=False, error="No client or empty text")
|
||||
|
||||
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
||||
html = self._markdown_to_html(text)
|
||||
if html and html != text:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
msg_content = self._build_text_message_content(text, msgtype=msgtype)
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -2046,6 +2331,77 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Mention detection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_text_message_content(self, text: str, msgtype: str = "m.text") -> Dict[str, Any]:
|
||||
"""Build Matrix text content with HTML and outbound mention metadata."""
|
||||
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
||||
mention_user_ids = self._extract_outbound_mentions(text)
|
||||
if mention_user_ids:
|
||||
msg_content["m.mentions"] = {"user_ids": mention_user_ids}
|
||||
|
||||
html_source = self._inject_outbound_mention_links(text)
|
||||
html = self._markdown_to_html(html_source)
|
||||
if html and html != text:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
|
||||
return msg_content
|
||||
|
||||
def _extract_outbound_mentions(self, text: str) -> list[str]:
|
||||
"""Return unique Matrix user IDs mentioned in outbound text."""
|
||||
protected, _ = self._protect_outbound_mention_regions(text)
|
||||
seen: Set[str] = set()
|
||||
mentions: list[str] = []
|
||||
for match in _OUTBOUND_MENTION_RE.finditer(protected):
|
||||
user_id = match.group(1)
|
||||
if user_id not in seen:
|
||||
seen.add(user_id)
|
||||
mentions.append(user_id)
|
||||
return mentions
|
||||
|
||||
def _inject_outbound_mention_links(self, text: str) -> str:
|
||||
"""Wrap outbound Matrix mentions in markdown links outside code spans."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
protected, placeholders = self._protect_outbound_mention_regions(text)
|
||||
|
||||
linked = _OUTBOUND_MENTION_RE.sub(
|
||||
lambda match: f"[{match.group(1)}](https://matrix.to/#/{match.group(1)})",
|
||||
protected,
|
||||
)
|
||||
|
||||
for idx, original in enumerate(placeholders):
|
||||
linked = linked.replace(f"\x00MENTION_PROTECTED{idx}\x00", original)
|
||||
|
||||
return linked
|
||||
|
||||
def _protect_outbound_mention_regions(self, text: str) -> tuple[str, list[str]]:
|
||||
"""Protect markdown regions where outbound mentions should stay literal."""
|
||||
placeholders: list[str] = []
|
||||
|
||||
def _protect(fragment: str) -> str:
|
||||
idx = len(placeholders)
|
||||
placeholders.append(fragment)
|
||||
return f"\x00MENTION_PROTECTED{idx}\x00"
|
||||
|
||||
protected = re.sub(
|
||||
r"```[\s\S]*?```",
|
||||
lambda match: _protect(match.group(0)),
|
||||
text or "",
|
||||
)
|
||||
protected = re.sub(
|
||||
r"`[^`\n]+`",
|
||||
lambda match: _protect(match.group(0)),
|
||||
protected,
|
||||
)
|
||||
protected = re.sub(
|
||||
r"\[[^\]]+\]\([^)]+\)",
|
||||
lambda match: _protect(match.group(0)),
|
||||
protected,
|
||||
)
|
||||
|
||||
return protected, placeholders
|
||||
|
||||
def _is_bot_mentioned(
|
||||
self,
|
||||
body: str,
|
||||
@@ -2080,13 +2436,33 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
def _strip_mention(self, body: str) -> str:
|
||||
"""Strip the bot's full MXID (``@user:server``) from *body*.
|
||||
"""Remove explicit bot mentions from message body.
|
||||
|
||||
The bare localpart is intentionally *not* stripped — it would
|
||||
mangle file paths like ``/home/hermes/media/file.png``.
|
||||
Important: only strip explicit mention tokens (``@user:server`` or
|
||||
``@localpart``). Do NOT strip bare words matching the bot localpart,
|
||||
otherwise normal phrases like "Hermes Agent" become "Agent".
|
||||
"""
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
# Strip explicit full MXID mentions.
|
||||
if self._user_id:
|
||||
body = body.replace(self._user_id, "")
|
||||
|
||||
# Strip explicit @localpart mentions only (not bare localpart words).
|
||||
if self._user_id and ":" in self._user_id:
|
||||
localpart = self._user_id.split(":")[0].lstrip("@")
|
||||
if localpart:
|
||||
body = re.sub(
|
||||
r'(?<![\w])@' + re.escape(localpart) + r'\b',
|
||||
'',
|
||||
body,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Normalize spacing after mention removal.
|
||||
body = re.sub(r'[ \t]{2,}', ' ', body)
|
||||
body = re.sub(r'\s+([,.;:!?])', r'\1', body)
|
||||
return body.strip()
|
||||
|
||||
async def _get_display_name(self, room_id: str, user_id: str) -> str:
|
||||
|
||||
@@ -412,7 +412,6 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
|
||||
import aiohttp
|
||||
|
||||
last_exc = None
|
||||
file_data = None
|
||||
ct = "application/octet-stream"
|
||||
fname = url.rsplit("/", 1)[-1].split("?")[0] or f"{kind}.png"
|
||||
|
||||
@@ -1957,7 +1957,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
self, openid: str, content: str, reply_to: Optional[str] = None
|
||||
) -> SendResult:
|
||||
"""Send text to a C2C user via REST API."""
|
||||
msg_seq = self._next_msg_seq(reply_to or openid)
|
||||
self._next_msg_seq(reply_to or openid)
|
||||
body = self._build_text_body(content, reply_to)
|
||||
if reply_to:
|
||||
body["msg_id"] = reply_to
|
||||
@@ -1970,7 +1970,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
self, group_openid: str, content: str, reply_to: Optional[str] = None
|
||||
) -> SendResult:
|
||||
"""Send text to a group via REST API."""
|
||||
msg_seq = self._next_msg_seq(reply_to or group_openid)
|
||||
self._next_msg_seq(reply_to or group_openid)
|
||||
body = self._build_text_body(content, reply_to)
|
||||
if reply_to:
|
||||
body["msg_id"] = reply_to
|
||||
@@ -2135,11 +2135,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
|
||||
# Route
|
||||
chat_type = self._guess_chat_type(chat_id)
|
||||
target_path = (
|
||||
f"/v2/users/{chat_id}/files"
|
||||
if chat_type == "c2c"
|
||||
else f"/v2/groups/{chat_id}/files"
|
||||
)
|
||||
|
||||
if chat_type == "guild":
|
||||
# Guild channels don't support native media upload in the same way
|
||||
|
||||
@@ -84,6 +84,7 @@ from gateway.platforms.telegram_network import (
|
||||
discover_fallback_ips,
|
||||
parse_fallback_ip_env,
|
||||
)
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
def check_telegram_requirements() -> bool:
|
||||
@@ -122,12 +123,12 @@ def _strip_mdv2(text: str) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown table → code block conversion
|
||||
# Markdown table → Telegram-friendly row groups
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram's MarkdownV2 has no table syntax — '|' is just an escaped literal,
|
||||
# so pipe tables render as noisy backslash-pipe text with no alignment.
|
||||
# Wrapping the table in a fenced code block makes Telegram render it as
|
||||
# monospace preformatted text with columns intact.
|
||||
# Reformating each row into a bold heading plus bullet list keeps the content
|
||||
# readable on mobile clients while preserving the source data.
|
||||
|
||||
# Matches a GFM table delimiter row: optional outer pipes, cells containing
|
||||
# only dashes (with optional leading/trailing colons for alignment) separated
|
||||
@@ -144,13 +145,49 @@ def _is_table_row(line: str) -> bool:
|
||||
return bool(stripped) and '|' in stripped
|
||||
|
||||
|
||||
def _split_markdown_table_row(line: str) -> list[str]:
|
||||
"""Split a simple GFM table row into stripped cell values."""
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("|"):
|
||||
stripped = stripped[1:]
|
||||
if stripped.endswith("|"):
|
||||
stripped = stripped[:-1]
|
||||
return [cell.strip() for cell in stripped.split("|")]
|
||||
|
||||
|
||||
def _render_table_block_for_telegram(table_block: list[str]) -> str:
|
||||
"""Render a detected GFM table as Telegram-friendly row groups."""
|
||||
if len(table_block) < 3:
|
||||
return "\n".join(table_block)
|
||||
|
||||
headers = _split_markdown_table_row(table_block[0])
|
||||
if len(headers) < 2:
|
||||
return "\n".join(table_block)
|
||||
|
||||
rendered_rows: list[str] = []
|
||||
for index, row in enumerate(table_block[2:], start=1):
|
||||
cells = _split_markdown_table_row(row)
|
||||
if len(cells) < len(headers):
|
||||
cells.extend([""] * (len(headers) - len(cells)))
|
||||
elif len(cells) > len(headers):
|
||||
cells = cells[: len(headers)]
|
||||
|
||||
heading = next((cell for cell in cells if cell), f"Row {index}")
|
||||
rendered_rows.append(f"**{heading}**")
|
||||
rendered_rows.extend(
|
||||
f"• {header}: {value}" for header, value in zip(headers, cells)
|
||||
)
|
||||
|
||||
return "\n\n".join(rendered_rows)
|
||||
|
||||
|
||||
def _wrap_markdown_tables(text: str) -> str:
|
||||
"""Wrap GFM-style pipe tables in ``` fences so Telegram renders them.
|
||||
"""Rewrite GFM-style pipe tables into Telegram-friendly bullet groups.
|
||||
|
||||
Detected by a row containing '|' immediately followed by a delimiter
|
||||
row matching :data:`_TABLE_SEPARATOR_RE`. Subsequent pipe-containing
|
||||
non-blank lines are consumed as the table body and included in the
|
||||
wrapped block. Tables inside existing fenced code blocks are left
|
||||
non-blank lines are consumed as the table body and rewritten as
|
||||
per-row bullet groups. Tables inside existing fenced code blocks are left
|
||||
alone.
|
||||
"""
|
||||
if '|' not in text or '-' not in text:
|
||||
@@ -187,9 +224,7 @@ def _wrap_markdown_tables(text: str) -> str:
|
||||
while j < len(lines) and _is_table_row(lines[j]):
|
||||
table_block.append(lines[j])
|
||||
j += 1
|
||||
out.append('```')
|
||||
out.extend(table_block)
|
||||
out.append('```')
|
||||
out.append(_render_table_block_for_telegram(table_block))
|
||||
i = j
|
||||
continue
|
||||
|
||||
@@ -334,6 +369,49 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return {"link_preview_options": LinkPreviewOptions(is_disabled=True)}
|
||||
return {"disable_web_page_preview": True}
|
||||
|
||||
async def _drain_polling_connections(self) -> None:
|
||||
"""Reset the httpx connection pool used for getUpdates polling.
|
||||
|
||||
Network errors (especially through proxies like sing-box) can leave
|
||||
httpx connections in a half-closed state that still occupy pool slots.
|
||||
After enough reconnect cycles the pool fills up entirely, causing
|
||||
``Pool timeout: All connections in the connection pool are occupied.``
|
||||
|
||||
We reset ONLY ``_request[0]`` (the getUpdates request) — the general
|
||||
request (``_request[1]``) is left untouched so concurrent
|
||||
``send_message`` / ``edit_message`` calls are never interrupted.
|
||||
|
||||
Implementation note: accesses ``Bot._request[0]`` which is the
|
||||
get-updates ``BaseRequest`` in the PTB 22.x internal tuple
|
||||
``(get_updates_request, general_request)``. There is no public
|
||||
accessor for the polling request; review if upgrading to PTB 23+.
|
||||
"""
|
||||
if not (self._app and self._app.bot):
|
||||
return
|
||||
try:
|
||||
# PTB 22.x: _request is a (get_updates, general) tuple;
|
||||
# no public accessor exists for the polling request.
|
||||
polling_req = self._app.bot._request[0] # noqa: SLF001
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
await polling_req.shutdown()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[%s] Polling request shutdown failed (non-fatal)",
|
||||
self.name, exc_info=True,
|
||||
)
|
||||
try:
|
||||
await polling_req.initialize()
|
||||
logger.debug(
|
||||
"[%s] Polling request pool drained before reconnect", self.name
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[%s] Polling request re-initialize failed (non-fatal)",
|
||||
self.name, exc_info=True,
|
||||
)
|
||||
|
||||
async def _handle_polling_network_error(self, error: Exception) -> None:
|
||||
"""Reconnect polling after a transient network interruption.
|
||||
|
||||
@@ -379,6 +457,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self._drain_polling_connections()
|
||||
|
||||
try:
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
@@ -426,6 +506,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
await self._drain_polling_connections()
|
||||
try:
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
@@ -554,7 +635,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
_yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, config_path)
|
||||
atomic_replace(tmp_path, config_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
@@ -2080,10 +2161,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
text = content
|
||||
|
||||
# 0) Pre-wrap GFM-style pipe tables in ``` fences. Telegram can't
|
||||
# render tables natively, but fenced code blocks render as
|
||||
# monospace preformatted text with columns intact. The wrapped
|
||||
# tables then flow through step (1) below as protected regions.
|
||||
# 0) Rewrite GFM-style pipe tables into Telegram-friendly row groups
|
||||
# before the normal MarkdownV2 conversions run.
|
||||
text = _wrap_markdown_tables(text)
|
||||
|
||||
# 1) Protect fenced code blocks (``` ... ```)
|
||||
|
||||
@@ -89,6 +89,7 @@ MAX_CONSECUTIVE_FAILURES = 3
|
||||
RETRY_DELAY_SECONDS = 2
|
||||
BACKOFF_DELAY_SECONDS = 30
|
||||
SESSION_EXPIRED_ERRCODE = -14
|
||||
RATE_LIMIT_ERRCODE = -2 # iLink frequency limit — backoff and retry
|
||||
MESSAGE_DEDUP_TTL_SECONDS = 300
|
||||
|
||||
MEDIA_IMAGE = 1
|
||||
@@ -1113,7 +1114,7 @@ async def qr_login(
|
||||
class WeixinAdapter(BasePlatformAdapter):
|
||||
"""Native Hermes adapter for Weixin personal accounts."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
MAX_MESSAGE_LENGTH = 2000
|
||||
|
||||
# WeChat does not support editing sent messages — streaming must use the
|
||||
# fallback "send-final-only" path so the cursor (▉) is never left visible.
|
||||
@@ -1138,10 +1139,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
extra.get("cdn_base_url") or os.getenv("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL)
|
||||
).strip().rstrip("/")
|
||||
self._send_chunk_delay_seconds = float(
|
||||
extra.get("send_chunk_delay_seconds") or os.getenv("WEIXIN_SEND_CHUNK_DELAY_SECONDS", "0.35")
|
||||
extra.get("send_chunk_delay_seconds") or os.getenv("WEIXIN_SEND_CHUNK_DELAY_SECONDS", "1.5")
|
||||
)
|
||||
self._send_chunk_retries = int(
|
||||
extra.get("send_chunk_retries") or os.getenv("WEIXIN_SEND_CHUNK_RETRIES", "2")
|
||||
extra.get("send_chunk_retries") or os.getenv("WEIXIN_SEND_CHUNK_RETRIES", "4")
|
||||
)
|
||||
self._send_chunk_retry_delay_seconds = float(
|
||||
extra.get("send_chunk_retry_delay_seconds")
|
||||
@@ -1531,6 +1532,28 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
self.name, _safe_id(chat_id),
|
||||
)
|
||||
continue
|
||||
# Rate limit (-2) — backoff and retry
|
||||
is_rate_limited = (
|
||||
ret == RATE_LIMIT_ERRCODE
|
||||
or errcode == RATE_LIMIT_ERRCODE
|
||||
)
|
||||
if is_rate_limited:
|
||||
errmsg = resp.get("errmsg") or resp.get("msg") or "rate limited"
|
||||
# Record the error so we raise a descriptive
|
||||
# RuntimeError (instead of AssertionError) if the
|
||||
# loop exhausts with the server still rate-limiting.
|
||||
last_error = RuntimeError(
|
||||
f"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg}"
|
||||
)
|
||||
if attempt >= self._send_chunk_retries:
|
||||
break
|
||||
wait = self._send_chunk_retry_delay_seconds * 3 # 3x backoff for rate limit
|
||||
logger.warning(
|
||||
"[%s] rate limited for %s; backing off %.1fs before retry",
|
||||
self.name, _safe_id(chat_id), wait,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
errmsg = resp.get("errmsg") or resp.get("msg") or "unknown error"
|
||||
raise RuntimeError(
|
||||
f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}"
|
||||
|
||||
@@ -90,7 +90,7 @@ from gateway.platforms.yuanbao_proto import (
|
||||
encode_get_group_member_list,
|
||||
next_seq_no,
|
||||
)
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from gateway.session import build_session_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1897,7 +1897,7 @@ class OwnerCommandMiddleware(InboundMiddleware):
|
||||
return None, None, False
|
||||
|
||||
# Sender identity check: bot owner <-> push.from_account == push.bot_owner_id
|
||||
owner_id = (push or {}).get("bot_owner_id") or ""
|
||||
# owner_id = (push or {}).get("bot_owner_id") or ""
|
||||
# is_owner = bool(owner_id) and owner_id == from_account
|
||||
is_owner = True
|
||||
return cmd, cmd_line, is_owner
|
||||
|
||||
@@ -21,12 +21,10 @@ import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Any
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -19,9 +19,8 @@ yuanbao_proto.py - Yuanbao WebSocket 协议编解码(纯 Python 实现)
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
import threading
|
||||
from typing import Optional, Union
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
+445
-51
@@ -31,6 +31,12 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, Any, List
|
||||
|
||||
# account_usage imports the OpenAI SDK chain (~230 ms). Only needed by
|
||||
# /usage; we still import it at module top in the gateway because test
|
||||
# patches (tests/gateway/test_usage_command.py) target
|
||||
# `gateway.run.fetch_account_usage` as a module-level attribute. The
|
||||
# gateway is a long-running daemon, so its boot cost matters less than
|
||||
# preserving the established test-patch surface.
|
||||
from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
|
||||
# --- Agent cache tuning ---------------------------------------------------
|
||||
@@ -40,6 +46,133 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
# from _enforce_agent_cache_cap() and _session_expiry_watcher() below.
|
||||
_AGENT_CACHE_MAX_SIZE = 128
|
||||
_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h
|
||||
# Only auto-continue interrupted gateway turns while the interruption is fresh.
|
||||
# Stale tool-tail/resume markers can otherwise revive an unrelated old task
|
||||
# after a gateway restart when the user's next message starts new work.
|
||||
#
|
||||
# The freshness signal is the timestamp of the last transcript row, which
|
||||
# ``hermes_state.get_messages`` carries on every persisted message. This
|
||||
# handles the two auto-continue cases uniformly:
|
||||
# * resume_pending (gateway restart/shutdown watchdog marked the session)
|
||||
# * tool-tail (last persisted message is a tool result the agent
|
||||
# never got to reply to)
|
||||
# In both cases "when did we last do anything on this transcript" is the
|
||||
# correct freshness question, so one signal replaces two divergent ones.
|
||||
#
|
||||
# Default window: 1 hour. This comfortably covers ``agent.gateway_timeout``
|
||||
# (30 min default) plus runtime slack — a legitimate long-running turn that
|
||||
# gets interrupted near its timeout boundary and is resumed shortly after
|
||||
# is still classified fresh. Override via
|
||||
# ``config.yaml`` ``agent.gateway_auto_continue_freshness``.
|
||||
_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60
|
||||
|
||||
|
||||
def _coerce_gateway_timestamp(value: Any) -> Optional[float]:
|
||||
"""Best-effort conversion of stored gateway timestamps to epoch seconds.
|
||||
|
||||
Missing/unparseable timestamps return None so legacy transcripts keep the
|
||||
historical auto-continue behaviour instead of being silently dropped.
|
||||
Accepts: datetime, epoch seconds (int/float), epoch milliseconds (when
|
||||
the magnitude exceeds year-2286), ISO-8601 strings (with or without a
|
||||
trailing ``Z``), and numeric strings.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.timestamp()
|
||||
if isinstance(value, bool): # bool is a subclass of int — skip it
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
# Some platform events use milliseconds; Hermes state rows use seconds.
|
||||
return float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
numeric = float(text)
|
||||
return numeric / 1000.0 if numeric > 10_000_000_000 else numeric
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _auto_continue_freshness_window() -> float:
|
||||
"""Return the configured auto-continue freshness window in seconds.
|
||||
|
||||
Reads ``HERMES_AUTO_CONTINUE_FRESHNESS`` (bridged from
|
||||
``config.yaml`` ``agent.gateway_auto_continue_freshness`` at gateway
|
||||
startup, same pattern as ``HERMES_AGENT_TIMEOUT``). Falls back to the
|
||||
module default when unset or malformed. Non-positive values disable
|
||||
the freshness gate (restores the pre-fix "always fresh" behaviour for
|
||||
users who want to opt out).
|
||||
"""
|
||||
raw = os.environ.get("HERMES_AUTO_CONTINUE_FRESHNESS")
|
||||
if raw is None or raw == "":
|
||||
return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)
|
||||
|
||||
|
||||
def _is_fresh_gateway_interruption(
|
||||
value: Any,
|
||||
*,
|
||||
now: Optional[float] = None,
|
||||
window_secs: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""Return True when an interruption marker is fresh enough to auto-continue.
|
||||
|
||||
Unknown timestamps are treated as fresh for backward compatibility with
|
||||
legacy transcripts (pre-dating timestamp persistence) and with in-memory
|
||||
test scaffolding that constructs history entries without timestamps.
|
||||
|
||||
A non-positive ``window_secs`` disables the gate (always fresh), which
|
||||
restores the pre-fix behaviour for users who opt out via config.
|
||||
"""
|
||||
window = (
|
||||
float(window_secs)
|
||||
if window_secs is not None
|
||||
else float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)
|
||||
)
|
||||
if window <= 0:
|
||||
return True
|
||||
timestamp = _coerce_gateway_timestamp(value)
|
||||
if timestamp is None:
|
||||
return True
|
||||
current = time.time() if now is None else now
|
||||
return current - timestamp <= window
|
||||
|
||||
|
||||
def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:
|
||||
"""Return the ``timestamp`` of the last usable transcript row, if any.
|
||||
|
||||
Skips metadata-only rows (``session_meta``, system injections) that are
|
||||
dropped before being handed to the agent. Returns ``None`` when no
|
||||
usable row carries a timestamp — callers should treat that as "fresh"
|
||||
for backward compatibility.
|
||||
"""
|
||||
if not history:
|
||||
return None
|
||||
for msg in reversed(history):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
if not role or role in ("session_meta", "system"):
|
||||
continue
|
||||
ts = msg.get("timestamp")
|
||||
if ts is not None:
|
||||
return ts
|
||||
# First non-meta row without a timestamp — legacy transcript row.
|
||||
# Returning None lets the caller fall through to the legacy-fresh path.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSL certificate auto-detection for NixOS and other non-standard systems.
|
||||
@@ -213,6 +346,13 @@ if _config_path.exists():
|
||||
os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"])
|
||||
if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ:
|
||||
os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"])
|
||||
if (
|
||||
"gateway_auto_continue_freshness" in _agent_cfg
|
||||
and "HERMES_AUTO_CONTINUE_FRESHNESS" not in os.environ
|
||||
):
|
||||
os.environ["HERMES_AUTO_CONTINUE_FRESHNESS"] = str(
|
||||
_agent_cfg["gateway_auto_continue_freshness"]
|
||||
)
|
||||
_display_cfg = _cfg.get("display", {})
|
||||
if _display_cfg and isinstance(_display_cfg, dict):
|
||||
if "busy_input_mode" in _display_cfg and "HERMES_GATEWAY_BUSY_INPUT_MODE" not in os.environ:
|
||||
@@ -509,15 +649,31 @@ def _platform_config_key(platform: "Platform") -> str:
|
||||
|
||||
|
||||
def _load_gateway_config() -> dict:
|
||||
"""Load and parse ~/.hermes/config.yaml, returning {} on any error."""
|
||||
"""Load and parse ~/.hermes/config.yaml, returning {} on any error.
|
||||
|
||||
Uses the module-level ``_hermes_home`` (so tests that monkeypatch it
|
||||
still see their fixture) and shares the mtime-keyed raw-yaml cache
|
||||
from ``hermes_cli.config.read_raw_config`` when the paths match.
|
||||
"""
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
try:
|
||||
from hermes_cli.config import get_config_path, read_raw_config
|
||||
# Fast path: if _hermes_home agrees with the canonical config
|
||||
# location, reuse the shared cache. Otherwise fall through to a
|
||||
# direct read (keeps test fixtures with a monkeypatched
|
||||
# _hermes_home working).
|
||||
if config_path == get_config_path():
|
||||
return read_raw_config()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
logger.debug("Could not load gateway config from %s", _hermes_home / 'config.yaml')
|
||||
logger.debug("Could not load gateway config from %s", config_path)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -1137,14 +1293,14 @@ class GatewayRunner:
|
||||
|
||||
service_tier = getattr(self, "_service_tier", None)
|
||||
if not service_tier:
|
||||
route["request_overrides"] = None
|
||||
route["request_overrides"] = {}
|
||||
return route
|
||||
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(route["model"])
|
||||
except Exception:
|
||||
overrides = None
|
||||
route["request_overrides"] = overrides
|
||||
route["request_overrides"] = overrides or {}
|
||||
return route
|
||||
|
||||
async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
|
||||
@@ -3771,6 +3927,8 @@ class GatewayRunner:
|
||||
return await self._handle_yolo_command(event)
|
||||
if _cmd_def_inner.name == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
if _cmd_def_inner.name == "footer":
|
||||
return await self._handle_footer_command(event)
|
||||
|
||||
# Gateway-handled info/control commands with dedicated
|
||||
# running-agent handlers.
|
||||
@@ -3991,6 +4149,9 @@ class GatewayRunner:
|
||||
if canonical == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
|
||||
if canonical == "footer":
|
||||
return await self._handle_footer_command(event)
|
||||
|
||||
if canonical == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
|
||||
@@ -4446,9 +4607,7 @@ class GatewayRunner:
|
||||
# Read privacy.redact_pii from config (re-read per message)
|
||||
_redact_pii = False
|
||||
try:
|
||||
import yaml as _pii_yaml
|
||||
with open(_config_path, encoding="utf-8") as _pf:
|
||||
_pcfg = _pii_yaml.safe_load(_pf) or {}
|
||||
_pcfg = _load_gateway_config()
|
||||
_redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -4591,18 +4750,15 @@ class GatewayRunner:
|
||||
_hyg_model = "anthropic/claude-sonnet-4.6"
|
||||
_hyg_threshold_pct = 0.85
|
||||
_hyg_compression_enabled = True
|
||||
_hyg_hard_msg_limit = 400
|
||||
_hyg_config_context_length = None
|
||||
_hyg_provider = None
|
||||
_hyg_base_url = None
|
||||
_hyg_api_key = None
|
||||
_hyg_data = {}
|
||||
try:
|
||||
_hyg_cfg_path = _hermes_home / "config.yaml"
|
||||
if _hyg_cfg_path.exists():
|
||||
import yaml as _hyg_yaml
|
||||
with open(_hyg_cfg_path, encoding="utf-8") as _hyg_f:
|
||||
_hyg_data = _hyg_yaml.safe_load(_hyg_f) or {}
|
||||
|
||||
_hyg_data = _load_gateway_config()
|
||||
if _hyg_data:
|
||||
# Resolve model name (same logic as run_sync)
|
||||
_model_cfg = _hyg_data.get("model", {})
|
||||
if isinstance(_model_cfg, str):
|
||||
@@ -4629,6 +4785,14 @@ class GatewayRunner:
|
||||
_hyg_compression_enabled = str(
|
||||
_comp_cfg.get("enabled", True)
|
||||
).lower() in ("true", "1", "yes")
|
||||
_raw_hard_limit = _comp_cfg.get("hygiene_hard_message_limit")
|
||||
if _raw_hard_limit is not None:
|
||||
try:
|
||||
_parsed = int(_raw_hard_limit)
|
||||
if _parsed > 0:
|
||||
_hyg_hard_msg_limit = _parsed
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
_hyg_model, _hyg_runtime = self._resolve_session_agent_runtime(
|
||||
@@ -4710,8 +4874,10 @@ class GatewayRunner:
|
||||
# collection, which prevents compression, which causes more
|
||||
# disconnects. 400 messages is well above normal sessions
|
||||
# but catches runaway growth before it becomes unrecoverable.
|
||||
# Threshold is configurable via
|
||||
# compression.hygiene_hard_message_limit.
|
||||
# (#2153)
|
||||
_HARD_MSG_LIMIT = 400
|
||||
_HARD_MSG_LIMIT = _hyg_hard_msg_limit
|
||||
_needs_compress = (
|
||||
_approx_tokens >= _compress_token_threshold
|
||||
or _msg_count >= _HARD_MSG_LIMIT
|
||||
@@ -4828,6 +4994,30 @@ class GatewayRunner:
|
||||
"Failed to deliver compression-failure warning to user: %s",
|
||||
_werr,
|
||||
)
|
||||
# Separately: if the user's CONFIGURED aux
|
||||
# model failed and we recovered by falling
|
||||
# back to the main model, tell them — a
|
||||
# misconfigured auxiliary.compression.model
|
||||
# is something only they can fix, and
|
||||
# silent recovery would hide it.
|
||||
elif _comp is not None and getattr(_comp, "_last_aux_model_failure_model", None):
|
||||
_aux_model = getattr(_comp, "_last_aux_model_failure_model", "")
|
||||
_aux_err = getattr(_comp, "_last_aux_model_failure_error", None) or "unknown error"
|
||||
_aux_msg = (
|
||||
f"ℹ️ Configured compression model `{_aux_model}` "
|
||||
f"failed ({_aux_err}). Recovered using your main "
|
||||
"model — context is intact — but you may want to "
|
||||
"check `auxiliary.compression.model` in config.yaml."
|
||||
)
|
||||
try:
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter and source.chat_id:
|
||||
await _adapter.send(source.chat_id, _aux_msg, metadata=_hyg_meta)
|
||||
except Exception as _werr:
|
||||
logger.warning(
|
||||
"Failed to deliver aux-model-fallback notice to user: %s",
|
||||
_werr,
|
||||
)
|
||||
finally:
|
||||
self._cleanup_agent_resources(_hyg_agent)
|
||||
|
||||
@@ -5055,6 +5245,27 @@ class GatewayRunner:
|
||||
display_reasoning = last_reasoning.strip()
|
||||
response = f"💭 **Reasoning:**\n```\n{display_reasoning}\n```\n\n{response}"
|
||||
|
||||
# Runtime-metadata footer — only on the FINAL message of the turn.
|
||||
# Off by default (display.runtime_footer.enabled=false). When
|
||||
# streaming already delivered the body, we can't mutate the sent
|
||||
# text, so we fire a separate trailing send below.
|
||||
_footer_line = ""
|
||||
try:
|
||||
from gateway.runtime_footer import build_footer_line as _bfl
|
||||
_footer_line = _bfl(
|
||||
user_config=_load_gateway_config(),
|
||||
platform_key=_platform_config_key(source.platform),
|
||||
model=agent_result.get("model"),
|
||||
context_tokens=agent_result.get("last_prompt_tokens", 0) or 0,
|
||||
context_length=agent_result.get("context_length") or None,
|
||||
cwd=os.environ.get("TERMINAL_CWD", ""),
|
||||
)
|
||||
except Exception as _footer_err:
|
||||
logger.debug("runtime_footer build failed: %s", _footer_err)
|
||||
_footer_line = ""
|
||||
if _footer_line and response and not agent_result.get("already_sent"):
|
||||
response = f"{response}\n\n{_footer_line}"
|
||||
|
||||
# Emit agent:end hook
|
||||
await self.hooks.emit("agent:end", {
|
||||
**hook_ctx,
|
||||
@@ -5225,6 +5436,17 @@ class GatewayRunner:
|
||||
await self._deliver_media_from_response(
|
||||
response, event, _media_adapter,
|
||||
)
|
||||
# Streaming already delivered the body text, but the footer was
|
||||
# intentionally held back (see the `not already_sent` gate above).
|
||||
# Send it now as a small trailing message so Telegram/Discord/etc.
|
||||
# still surface the runtime metadata on the final reply.
|
||||
if _footer_line:
|
||||
try:
|
||||
_foot_adapter = self.adapters.get(source.platform)
|
||||
if _foot_adapter:
|
||||
await _foot_adapter.send(source.chat_id, _footer_line)
|
||||
except Exception as _e:
|
||||
logger.debug("trailing footer send failed: %s", _e)
|
||||
return None
|
||||
|
||||
return response
|
||||
@@ -5307,11 +5529,8 @@ class GatewayRunner:
|
||||
custom_provs = None
|
||||
|
||||
try:
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
import yaml as _info_yaml
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
data = _info_yaml.safe_load(f) or {}
|
||||
data = _load_gateway_config()
|
||||
if data:
|
||||
model_cfg = data.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
raw_ctx = model_cfg.get("context_length")
|
||||
@@ -5910,9 +6129,8 @@ class GatewayRunner:
|
||||
custom_provs = None
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
cfg = _load_gateway_config()
|
||||
if cfg:
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
current_model = model_cfg.get("default", "")
|
||||
@@ -5951,6 +6169,7 @@ class GatewayRunner:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
current_model=current_model,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=50,
|
||||
@@ -6072,6 +6291,7 @@ class GatewayRunner:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
current_model=current_model,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=5,
|
||||
@@ -6217,20 +6437,14 @@ class GatewayRunner:
|
||||
|
||||
async def _handle_personality_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /personality command - list or set a personality."""
|
||||
import yaml
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
args = event.get_command_args().strip().lower()
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
personalities = config.get("agent", {}).get("personalities", {})
|
||||
else:
|
||||
config = {}
|
||||
personalities = {}
|
||||
config = _load_gateway_config()
|
||||
personalities = config.get("agent", {}).get("personalities", {}) if config else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
personalities = {}
|
||||
@@ -6360,18 +6574,10 @@ class GatewayRunner:
|
||||
|
||||
env_key = f"{platform_name.upper()}_HOME_CHANNEL"
|
||||
|
||||
# Save to config.yaml
|
||||
# Save to .env so it persists across restarts
|
||||
try:
|
||||
import yaml
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
user_config[env_key] = chat_id
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
# Also set in the current environment so it takes effect immediately
|
||||
os.environ[env_key] = str(chat_id)
|
||||
from hermes_cli.config import save_env_value
|
||||
save_env_value(env_key, str(chat_id))
|
||||
except Exception as e:
|
||||
return f"Failed to save home channel: {e}"
|
||||
|
||||
@@ -7232,17 +7438,13 @@ class GatewayRunner:
|
||||
``display.platforms.<platform>.tool_progress`` so each channel can
|
||||
have its own verbosity level independently.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
platform_key = _platform_config_key(event.source.platform)
|
||||
|
||||
# --- check config gate ------------------------------------------------
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
user_config = _load_gateway_config()
|
||||
gate_enabled = user_config.get("display", {}).get("tool_progress_command", False)
|
||||
except Exception:
|
||||
gate_enabled = False
|
||||
@@ -7290,6 +7492,94 @@ class GatewayRunner:
|
||||
logger.warning("Failed to save tool_progress mode: %s", e)
|
||||
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
|
||||
|
||||
async def _handle_footer_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /footer command — toggle the runtime-metadata footer.
|
||||
|
||||
Usage:
|
||||
/footer → toggle on/off
|
||||
/footer on → enable globally
|
||||
/footer off → disable globally
|
||||
/footer status → show current state + fields
|
||||
|
||||
The footer is saved to ``display.runtime_footer.enabled`` (global).
|
||||
Per-platform overrides under ``display.platforms.<platform>.runtime_footer``
|
||||
are respected but not modified here — edit config.yaml directly for
|
||||
per-platform control.
|
||||
"""
|
||||
from gateway.runtime_footer import resolve_footer_config
|
||||
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
platform_key = _platform_config_key(event.source.platform)
|
||||
|
||||
# --- parse argument -------------------------------------------------
|
||||
arg = ""
|
||||
try:
|
||||
text = (getattr(event, "message", None) or "").strip()
|
||||
if text.startswith("/"):
|
||||
parts = text.split(None, 1)
|
||||
if len(parts) > 1:
|
||||
arg = parts[1].strip().lower()
|
||||
except Exception:
|
||||
arg = ""
|
||||
|
||||
# --- load config ----------------------------------------------------
|
||||
try:
|
||||
user_config: dict = _load_gateway_config()
|
||||
except Exception as e:
|
||||
return f"⚠️ Could not read config.yaml: {e}"
|
||||
|
||||
effective = resolve_footer_config(user_config, platform_key)
|
||||
|
||||
if arg in ("status", "?"):
|
||||
state = "ON" if effective["enabled"] else "OFF"
|
||||
fields = ", ".join(effective.get("fields") or [])
|
||||
return (
|
||||
f"📎 Runtime footer: **{state}**\n"
|
||||
f"Fields: `{fields}`\n"
|
||||
f"Platform: `{platform_key}`"
|
||||
)
|
||||
|
||||
if arg in ("on", "enable", "true", "1"):
|
||||
new_state = True
|
||||
elif arg in ("off", "disable", "false", "0"):
|
||||
new_state = False
|
||||
elif arg == "":
|
||||
new_state = not effective["enabled"]
|
||||
else:
|
||||
return "Usage: `/footer [on|off|status]`"
|
||||
|
||||
# --- write global flag ---------------------------------------------
|
||||
try:
|
||||
if not isinstance(user_config.get("display"), dict):
|
||||
user_config["display"] = {}
|
||||
display = user_config["display"]
|
||||
if not isinstance(display.get("runtime_footer"), dict):
|
||||
display["runtime_footer"] = {}
|
||||
display["runtime_footer"]["enabled"] = new_state
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save runtime_footer.enabled: %s", e)
|
||||
return f"⚠️ Could not save config: {e}"
|
||||
|
||||
state = "ON" if new_state else "OFF"
|
||||
example = ""
|
||||
if new_state:
|
||||
# Show a preview using current agent state if available.
|
||||
from gateway.runtime_footer import format_runtime_footer
|
||||
preview = format_runtime_footer(
|
||||
model=_resolve_gateway_model(user_config) or None,
|
||||
context_tokens=0,
|
||||
context_length=None,
|
||||
fields=effective.get("fields") or ["model", "context_pct", "cwd"],
|
||||
)
|
||||
if preview:
|
||||
example = f"\nExample: `{preview}`"
|
||||
return (
|
||||
f"📎 Runtime footer: **{state}**"
|
||||
f"{example}\n"
|
||||
f"_(saved globally — takes effect on next message)_"
|
||||
)
|
||||
|
||||
async def _handle_compress_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /compress command -- manually compress conversation context.
|
||||
|
||||
@@ -7325,7 +7615,6 @@ class GatewayRunner:
|
||||
for m in history
|
||||
if m.get("role") in ("user", "assistant") and m.get("content")
|
||||
]
|
||||
original_count = len(msgs)
|
||||
approx_tokens = estimate_messages_tokens_rough(msgs)
|
||||
|
||||
tmp_agent = AIAgent(
|
||||
@@ -7377,6 +7666,11 @@ class GatewayRunner:
|
||||
_summary_failed = bool(getattr(compressor, "_last_summary_fallback_used", False))
|
||||
_dropped_count = int(getattr(compressor, "_last_summary_dropped_count", 0) or 0)
|
||||
_summary_err = getattr(compressor, "_last_summary_error", None)
|
||||
# Separately: did the user's CONFIGURED aux model fail
|
||||
# and we recovered via main? Surface that as an info
|
||||
# note so they can fix their config.
|
||||
_aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None)
|
||||
_aux_fail_err = getattr(compressor, "_last_aux_model_failure_error", None)
|
||||
finally:
|
||||
self._cleanup_agent_resources(tmp_agent)
|
||||
lines = [f"🗜️ {summary['headline']}"]
|
||||
@@ -7392,6 +7686,13 @@ class GatewayRunner:
|
||||
"with a placeholder; earlier context is no longer recoverable. "
|
||||
"Consider checking your auxiliary.compression model configuration."
|
||||
)
|
||||
elif _aux_fail_model:
|
||||
lines.append(
|
||||
f"ℹ️ Configured compression model `{_aux_fail_model}` failed "
|
||||
f"({_aux_fail_err or 'unknown error'}). Recovered using your main "
|
||||
"model — context is intact — but you may want to check "
|
||||
"`auxiliary.compression.model` in config.yaml."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.warning("Manual compress failed: %s", e)
|
||||
@@ -8906,12 +9207,47 @@ class GatewayRunner:
|
||||
|
||||
_MAX_INTERRUPT_DEPTH = 3 # Cap recursive interrupt handling (#816)
|
||||
|
||||
# Config keys whose values MUST invalidate the gateway's cached agent
|
||||
# when they change. The agent bakes these into its compressor / context
|
||||
# handling at construction time, so a mid-running-gateway config edit
|
||||
# would otherwise be silently ignored until the user triggers a
|
||||
# different cache eviction (model switch, /reset, etc.).
|
||||
#
|
||||
# Each entry is a tuple of (section, key) read from the raw config dict.
|
||||
# Add more here as new baked-at-construction config settings are added.
|
||||
_CACHE_BUSTING_CONFIG_KEYS: tuple = (
|
||||
("model", "context_length"),
|
||||
("compression", "enabled"),
|
||||
("compression", "threshold"),
|
||||
("compression", "target_ratio"),
|
||||
("compression", "protect_last_n"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:
|
||||
"""Pull the subset of config values that must bust the agent cache.
|
||||
|
||||
Returns a flat dict keyed by 'section.key'. Missing keys and
|
||||
non-dict sections yield None values, which still contribute to
|
||||
the signature (so 'absent' vs 'present-and-null' differ).
|
||||
"""
|
||||
out: Dict[str, Any] = {}
|
||||
cfg = user_config if isinstance(user_config, dict) else {}
|
||||
for section, key in cls._CACHE_BUSTING_CONFIG_KEYS:
|
||||
section_val = cfg.get(section)
|
||||
if isinstance(section_val, dict):
|
||||
out[f"{section}.{key}"] = section_val.get(key)
|
||||
else:
|
||||
out[f"{section}.{key}"] = None
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _agent_config_signature(
|
||||
model: str,
|
||||
runtime: dict,
|
||||
enabled_toolsets: list,
|
||||
ephemeral_prompt: str,
|
||||
cache_keys: dict | None = None,
|
||||
) -> str:
|
||||
"""Compute a stable string key from agent config values.
|
||||
|
||||
@@ -8919,6 +9255,12 @@ class GatewayRunner:
|
||||
discarded and rebuilt. When it stays the same, the cached agent is
|
||||
reused — preserving the frozen system prompt and tool schemas for
|
||||
prompt cache hits.
|
||||
|
||||
``cache_keys`` is an optional flat dict of additional config values
|
||||
that should invalidate the cache when they change. Callers pass
|
||||
the output of ``_extract_cache_busting_config(user_config)`` so
|
||||
edits to model.context_length / compression.* in config.yaml are
|
||||
picked up on the next gateway message without a manual restart.
|
||||
"""
|
||||
import hashlib, json as _j
|
||||
|
||||
@@ -8929,6 +9271,8 @@ class GatewayRunner:
|
||||
_api_key = str(runtime.get("api_key", "") or "")
|
||||
_api_key_fingerprint = hashlib.sha256(_api_key.encode()).hexdigest() if _api_key else ""
|
||||
|
||||
_cache_keys_sorted = sorted((cache_keys or {}).items())
|
||||
|
||||
blob = _j.dumps(
|
||||
[
|
||||
model,
|
||||
@@ -8940,6 +9284,7 @@ class GatewayRunner:
|
||||
# reasoning_config excluded — it's set per-message on the
|
||||
# cached agent and doesn't affect system prompt or tools.
|
||||
ephemeral_prompt or "",
|
||||
_cache_keys_sorted,
|
||||
],
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
@@ -10192,6 +10537,7 @@ class GatewayRunner:
|
||||
turn_route["runtime"],
|
||||
enabled_toolsets,
|
||||
combined_ephemeral,
|
||||
cache_keys=self._extract_cache_busting_config(user_config),
|
||||
)
|
||||
agent = None
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
@@ -10258,7 +10604,7 @@ class GatewayRunner:
|
||||
agent.status_callback = _status_callback_sync
|
||||
agent.reasoning_config = reasoning_config
|
||||
agent.service_tier = self._service_tier
|
||||
agent.request_overrides = turn_route.get("request_overrides")
|
||||
agent.request_overrides = turn_route.get("request_overrides") or {}
|
||||
|
||||
_bg_review_release = threading.Event()
|
||||
_bg_review_pending: list[str] = []
|
||||
@@ -10479,6 +10825,23 @@ class GatewayRunner:
|
||||
# anything (tool, assistant with unfinished work, etc.), so we
|
||||
# give a stronger, reason-aware instruction that subsumes the
|
||||
# tool-tail case.
|
||||
#
|
||||
# Freshness gate (#16802): both branches are gated on the age
|
||||
# of the last persisted transcript row. That is the correct
|
||||
# "when did we last do anything here" signal for both the
|
||||
# resume_pending path (restart watchdog) and the tool-tail
|
||||
# path (in-flight tool loop killed). We read ``history[-1]``
|
||||
# here because ``agent_history`` has already stripped the
|
||||
# ``timestamp`` field off tool/tool_call rows for API purity
|
||||
# (see the `k != "timestamp"` filter above). Rows without a
|
||||
# timestamp (legacy transcripts) are treated as fresh so the
|
||||
# historical auto-continue behaviour is preserved.
|
||||
_freshness_window = _auto_continue_freshness_window()
|
||||
_interruption_is_fresh = _is_fresh_gateway_interruption(
|
||||
_last_transcript_timestamp(history),
|
||||
window_secs=_freshness_window,
|
||||
)
|
||||
|
||||
_resume_entry = None
|
||||
if session_key:
|
||||
try:
|
||||
@@ -10486,7 +10849,14 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
_resume_entry = None
|
||||
_is_resume_pending = bool(
|
||||
_resume_entry is not None and getattr(_resume_entry, "resume_pending", False)
|
||||
_resume_entry is not None
|
||||
and getattr(_resume_entry, "resume_pending", False)
|
||||
and _interruption_is_fresh
|
||||
)
|
||||
_has_fresh_tool_tail = bool(
|
||||
agent_history
|
||||
and agent_history[-1].get("role") == "tool"
|
||||
and _interruption_is_fresh
|
||||
)
|
||||
|
||||
if _is_resume_pending:
|
||||
@@ -10506,7 +10876,7 @@ class GatewayRunner:
|
||||
f"message below.]\n\n"
|
||||
+ message
|
||||
)
|
||||
elif agent_history and agent_history[-1].get("role") == "tool":
|
||||
elif _has_fresh_tool_tail:
|
||||
message = (
|
||||
"[System note: Your previous turn was interrupted before you could "
|
||||
"process the last tool result(s). The conversation history contains "
|
||||
@@ -10569,11 +10939,13 @@ class GatewayRunner:
|
||||
_last_prompt_toks = 0
|
||||
_input_toks = 0
|
||||
_output_toks = 0
|
||||
_context_length = 0
|
||||
_agent = agent_holder[0]
|
||||
if _agent and hasattr(_agent, "context_compressor"):
|
||||
_last_prompt_toks = getattr(_agent.context_compressor, "last_prompt_tokens", 0)
|
||||
_input_toks = getattr(_agent, "session_prompt_tokens", 0)
|
||||
_output_toks = getattr(_agent, "session_completion_tokens", 0)
|
||||
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
|
||||
_resolved_model = getattr(_agent, "model", None) if _agent else None
|
||||
|
||||
if not final_response:
|
||||
@@ -10590,6 +10962,7 @@ class GatewayRunner:
|
||||
"input_tokens": _input_toks,
|
||||
"output_tokens": _output_toks,
|
||||
"model": _resolved_model,
|
||||
"context_length": _context_length,
|
||||
}
|
||||
|
||||
# Scan tool results for MEDIA:<path> tags that need to be delivered
|
||||
@@ -10672,6 +11045,13 @@ class GatewayRunner:
|
||||
final_response,
|
||||
all_msgs,
|
||||
failure_callback=_title_failure_cb,
|
||||
main_runtime={
|
||||
"model": getattr(agent, "model", None),
|
||||
"provider": getattr(agent, "provider", None),
|
||||
"base_url": getattr(agent, "base_url", None),
|
||||
"api_key": getattr(agent, "api_key", None),
|
||||
"api_mode": getattr(agent, "api_mode", None),
|
||||
} if agent else None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -10687,6 +11067,7 @@ class GatewayRunner:
|
||||
"input_tokens": _input_toks,
|
||||
"output_tokens": _output_toks,
|
||||
"model": _resolved_model,
|
||||
"context_length": _context_length,
|
||||
"session_id": effective_session_id,
|
||||
"response_previewed": result.get("response_previewed", False),
|
||||
}
|
||||
@@ -11635,6 +12016,19 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
atexit.register(remove_pid_file)
|
||||
atexit.register(release_gateway_runtime_lock)
|
||||
|
||||
# MCP tool discovery — run in an executor so the asyncio event loop
|
||||
# stays responsive even when a configured MCP server is slow or
|
||||
# unreachable. discover_mcp_tools() uses a blocking 120s wait
|
||||
# internally; calling it from the loop thread would freeze platform
|
||||
# heartbeats (Discord shard, Telegram polling) until it returned.
|
||||
# See #16856.
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
_loop = asyncio.get_running_loop()
|
||||
await _loop.run_in_executor(None, discover_mcp_tools)
|
||||
except Exception as e:
|
||||
logger.debug("MCP tool discovery failed: %s", e)
|
||||
|
||||
# Start the gateway
|
||||
success = await runner.start()
|
||||
if not success:
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Gateway runtime-metadata footer.
|
||||
|
||||
Renders a compact footer showing runtime state (model, context %, cwd) and
|
||||
appends it to the FINAL message of an agent turn when enabled. Off by default
|
||||
to keep replies minimal.
|
||||
|
||||
Config (``~/.hermes/config.yaml``)::
|
||||
|
||||
display:
|
||||
runtime_footer:
|
||||
enabled: true # off by default
|
||||
fields: [model, context_pct, cwd] # order shown; drop any to hide
|
||||
|
||||
Per-platform overrides live under ``display.platforms.<platform>.runtime_footer``.
|
||||
Users can toggle the global setting with ``/footer on|off`` from both the CLI
|
||||
and any gateway platform.
|
||||
|
||||
The footer is appended to the final response text in ``gateway/run.py`` right
|
||||
before returning the response to the adapter send path — so it only lands on
|
||||
the final message a user sees, not on tool-progress updates or streaming
|
||||
partials. When streaming is on and the final text has already been delivered
|
||||
piecemeal, the footer is sent as a separate trailing message via
|
||||
``send_trailing_footer()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
_DEFAULT_FIELDS: tuple[str, ...] = ("model", "context_pct", "cwd")
|
||||
_SEP = " · "
|
||||
|
||||
|
||||
def _home_relative_cwd(cwd: str) -> str:
|
||||
"""Return *cwd* with ``$HOME`` collapsed to ``~``. Empty string if unset."""
|
||||
if not cwd:
|
||||
return ""
|
||||
try:
|
||||
home = os.path.expanduser("~")
|
||||
p = os.path.abspath(cwd)
|
||||
if home and (p == home or p.startswith(home + os.sep)):
|
||||
return "~" + p[len(home):]
|
||||
return p
|
||||
except Exception:
|
||||
return cwd
|
||||
|
||||
|
||||
def _model_short(model: Optional[str]) -> str:
|
||||
"""Drop ``vendor/`` prefix for readability (``openai/gpt-5.4`` → ``gpt-5.4``)."""
|
||||
if not model:
|
||||
return ""
|
||||
return model.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def resolve_footer_config(
|
||||
user_config: dict[str, Any] | None,
|
||||
platform_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve effective runtime-footer config for *platform_key*.
|
||||
|
||||
Merge order (later wins):
|
||||
1. Built-in defaults (enabled=False)
|
||||
2. ``display.runtime_footer``
|
||||
3. ``display.platforms.<platform_key>.runtime_footer``
|
||||
"""
|
||||
resolved = {"enabled": False, "fields": list(_DEFAULT_FIELDS)}
|
||||
cfg = (user_config or {}).get("display") or {}
|
||||
|
||||
global_cfg = cfg.get("runtime_footer")
|
||||
if isinstance(global_cfg, dict):
|
||||
if "enabled" in global_cfg:
|
||||
resolved["enabled"] = bool(global_cfg.get("enabled"))
|
||||
if isinstance(global_cfg.get("fields"), list) and global_cfg["fields"]:
|
||||
resolved["fields"] = [str(f) for f in global_cfg["fields"]]
|
||||
|
||||
if platform_key:
|
||||
platforms = cfg.get("platforms") or {}
|
||||
plat_cfg = platforms.get(platform_key)
|
||||
if isinstance(plat_cfg, dict):
|
||||
plat_footer = plat_cfg.get("runtime_footer")
|
||||
if isinstance(plat_footer, dict):
|
||||
if "enabled" in plat_footer:
|
||||
resolved["enabled"] = bool(plat_footer.get("enabled"))
|
||||
if isinstance(plat_footer.get("fields"), list) and plat_footer["fields"]:
|
||||
resolved["fields"] = [str(f) for f in plat_footer["fields"]]
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def format_runtime_footer(
|
||||
*,
|
||||
model: Optional[str],
|
||||
context_tokens: int,
|
||||
context_length: Optional[int],
|
||||
cwd: Optional[str] = None,
|
||||
fields: Iterable[str] = _DEFAULT_FIELDS,
|
||||
) -> str:
|
||||
"""Render the footer line, or return "" if no fields have data.
|
||||
|
||||
Fields are skipped silently when their underlying data is missing — a
|
||||
partially-populated footer is better than a line with ``?%`` or empty slots.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for field in fields:
|
||||
if field == "model":
|
||||
m = _model_short(model)
|
||||
if m:
|
||||
parts.append(m)
|
||||
elif field == "context_pct":
|
||||
if context_length and context_length > 0 and context_tokens >= 0:
|
||||
pct = max(0, min(100, round((context_tokens / context_length) * 100)))
|
||||
parts.append(f"{pct}%")
|
||||
elif field == "cwd":
|
||||
rel = _home_relative_cwd(cwd or os.environ.get("TERMINAL_CWD", ""))
|
||||
if rel:
|
||||
parts.append(rel)
|
||||
# Unknown field names are silently ignored.
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
return _SEP.join(parts)
|
||||
|
||||
|
||||
def build_footer_line(
|
||||
*,
|
||||
user_config: dict[str, Any] | None,
|
||||
platform_key: str | None,
|
||||
model: Optional[str],
|
||||
context_tokens: int,
|
||||
context_length: Optional[int],
|
||||
cwd: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Top-level entry point used by gateway/run.py.
|
||||
|
||||
Returns the footer text (empty string when disabled or no data). Callers
|
||||
append this to the final response themselves, preserving a single blank
|
||||
line of separation.
|
||||
"""
|
||||
cfg = resolve_footer_config(user_config, platform_key)
|
||||
if not cfg.get("enabled"):
|
||||
return ""
|
||||
return format_runtime_footer(
|
||||
model=model,
|
||||
context_tokens=context_tokens,
|
||||
context_length=context_length,
|
||||
cwd=cwd,
|
||||
fields=cfg.get("fields") or _DEFAULT_FIELDS,
|
||||
)
|
||||
+5
-19
@@ -62,8 +62,8 @@ from .config import (
|
||||
)
|
||||
from .whatsapp_identity import (
|
||||
canonical_whatsapp_identifier,
|
||||
normalize_whatsapp_identifier,
|
||||
)
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -705,7 +705,7 @@ class SessionStore:
|
||||
json.dump(data, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, sessions_file)
|
||||
atomic_replace(tmp_path, sessions_file)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
@@ -1257,25 +1257,11 @@ class SessionStore:
|
||||
Used by /retry, /undo, and /compress to persist modified conversation history.
|
||||
Rewrites both SQLite and legacy JSONL storage.
|
||||
"""
|
||||
# SQLite: clear old messages and re-insert
|
||||
# SQLite: replace atomically so a mid-rewrite failure doesn't leave
|
||||
# the session half-empty in the DB while JSONL still has history.
|
||||
if self._db:
|
||||
try:
|
||||
self._db.clear_messages(session_id)
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown")
|
||||
self._db.append_message(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
content=msg.get("content"),
|
||||
tool_name=msg.get("tool_name"),
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
reasoning=msg.get("reasoning") if role == "assistant" else None,
|
||||
reasoning_content=msg.get("reasoning_content") if role == "assistant" else None,
|
||||
reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
|
||||
codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
|
||||
codex_message_items=msg.get("codex_message_items") if role == "assistant" else None,
|
||||
)
|
||||
self._db.replace_messages(session_id, messages)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to rewrite transcript in DB: %s", e)
|
||||
|
||||
|
||||
+39
-4
@@ -43,6 +43,7 @@ import yaml
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -109,6 +110,12 @@ SERVICE_PROVIDER_NAMES: Dict[str, str] = {
|
||||
DEFAULT_GEMINI_CLOUDCODE_BASE_URL = "cloudcode-pa://google"
|
||||
GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 # refresh 60s before expiry
|
||||
|
||||
# LM Studio's default no-auth mode still requires *some* non-empty bearer for
|
||||
# the API-key code paths (auxiliary_client, runtime resolver) to treat the
|
||||
# provider as configured. This sentinel is sent only to LM Studio, never to
|
||||
# any remote service.
|
||||
LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Registry
|
||||
@@ -159,6 +166,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
auth_type="oauth_external",
|
||||
inference_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
|
||||
),
|
||||
"lmstudio": ProviderConfig(
|
||||
id="lmstudio",
|
||||
name="LM Studio",
|
||||
auth_type="api_key",
|
||||
inference_base_url="http://127.0.0.1:1234/v1",
|
||||
api_key_env_vars=("LM_API_KEY",),
|
||||
base_url_env_var="LM_BASE_URL",
|
||||
),
|
||||
"copilot": ProviderConfig(
|
||||
id="copilot",
|
||||
name="GitHub Copilot",
|
||||
@@ -348,6 +363,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
api_key_env_vars=("XIAOMI_API_KEY",),
|
||||
base_url_env_var="XIAOMI_BASE_URL",
|
||||
),
|
||||
"tencent-tokenhub": ProviderConfig(
|
||||
id="tencent-tokenhub",
|
||||
name="Tencent TokenHub",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://tokenhub.tencentmaas.com/v1",
|
||||
api_key_env_vars=("TOKENHUB_API_KEY",),
|
||||
base_url_env_var="TOKENHUB_BASE_URL",
|
||||
),
|
||||
"ollama-cloud": ProviderConfig(
|
||||
id="ollama-cloud",
|
||||
name="Ollama Cloud",
|
||||
@@ -820,7 +843,7 @@ def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, auth_file)
|
||||
atomic_replace(tmp_path, auth_file)
|
||||
try:
|
||||
dir_fd = os.open(str(auth_file.parent), os.O_RDONLY)
|
||||
except OSError:
|
||||
@@ -1141,11 +1164,13 @@ def resolve_provider(
|
||||
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli",
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
||||
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
|
||||
"tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub",
|
||||
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
|
||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||||
"lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio",
|
||||
# Local server aliases — route through the generic custom provider
|
||||
"lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom",
|
||||
"ollama": "custom", "ollama_cloud": "ollama-cloud",
|
||||
"vllm": "custom", "llamacpp": "custom",
|
||||
"llama.cpp": "custom", "llama-cpp": "custom",
|
||||
@@ -1192,8 +1217,11 @@ def resolve_provider(
|
||||
continue
|
||||
# GitHub tokens are commonly present for repo/tool access but should not
|
||||
# hijack inference auto-selection unless the user explicitly chooses
|
||||
# Copilot/GitHub Models as the provider.
|
||||
if pid == "copilot":
|
||||
# Copilot/GitHub Models as the provider. LM Studio is a local server
|
||||
# whose availability isn't implied by LM_API_KEY presence (it may be
|
||||
# offline, and the no-auth setup uses a placeholder value), so it
|
||||
# also requires explicit selection.
|
||||
if pid in ("copilot", "lmstudio"):
|
||||
continue
|
||||
for env_var in pconfig.api_key_env_vars:
|
||||
if has_usable_secret(os.getenv(env_var, "")):
|
||||
@@ -3471,6 +3499,13 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
||||
key_source = ""
|
||||
api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
|
||||
|
||||
# No-auth LM Studio: substitute a placeholder so runtime / auxiliary_client
|
||||
# see the local server as configured. doctor still reports unconfigured
|
||||
# because get_api_key_provider_status uses the raw secret resolver.
|
||||
if not api_key and provider_id == "lmstudio":
|
||||
api_key = LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
key_source = key_source or "default"
|
||||
|
||||
env_url = ""
|
||||
if pconfig.base_url_env_var:
|
||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
||||
|
||||
@@ -34,7 +34,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from urllib import request as urllib_request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
+152
-57
@@ -696,6 +696,78 @@ def run_quick_backup(args) -> None:
|
||||
print("No state files found to snapshot.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared full-zip backup helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
|
||||
"""Write a full zip snapshot of ``hermes_root`` to ``out_path``.
|
||||
|
||||
Uses the same exclusion rules and SQLite safe-copy as :func:`run_backup`.
|
||||
Returns the output path on success, None on failure (nothing to back up,
|
||||
or write error — caller should surface the outcome but not raise).
|
||||
"""
|
||||
files_to_add: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
|
||||
dp = Path(dirpath)
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
|
||||
for fname in filenames:
|
||||
fpath = dp / fname
|
||||
try:
|
||||
rel = fpath.relative_to(hermes_root)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if _should_exclude(rel):
|
||||
continue
|
||||
|
||||
# Skip the output zip itself if it already exists inside root.
|
||||
try:
|
||||
if fpath.resolve() == out_path.resolve():
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
files_to_add.append((fpath, rel))
|
||||
except OSError as exc:
|
||||
logger.warning("Full-zip backup: walk failed: %s", exc)
|
||||
return None
|
||||
|
||||
if not files_to_add:
|
||||
return None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for abs_path, rel_path in files_to_add:
|
||||
try:
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
try:
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
zf.write(tmp_db, arcname=str(rel_path))
|
||||
finally:
|
||||
tmp_db.unlink(missing_ok=True)
|
||||
else:
|
||||
zf.write(abs_path, arcname=str(rel_path))
|
||||
except (PermissionError, OSError, ValueError) as exc:
|
||||
logger.debug("Skipping %s in zip backup: %s", rel_path, exc)
|
||||
continue
|
||||
except OSError as exc:
|
||||
logger.warning("Full-zip backup: zip write failed: %s", exc)
|
||||
# Best-effort cleanup of partial file
|
||||
try:
|
||||
out_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
return out_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-update auto-backup
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -768,64 +840,87 @@ def create_pre_update_backup(
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
out_path = backup_dir / f"{_PRE_UPDATE_PREFIX}{stamp}.zip"
|
||||
|
||||
# Collect files (same logic as run_backup, minus the chatty progress prints)
|
||||
files_to_add: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
|
||||
dp = Path(dirpath)
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
|
||||
for fname in filenames:
|
||||
fpath = dp / fname
|
||||
try:
|
||||
rel = fpath.relative_to(hermes_root)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if _should_exclude(rel):
|
||||
continue
|
||||
|
||||
# Skip the output zip itself if it already exists
|
||||
try:
|
||||
if fpath.resolve() == out_path.resolve():
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
files_to_add.append((fpath, rel))
|
||||
except OSError as exc:
|
||||
logger.warning("Pre-update backup: walk failed: %s", exc)
|
||||
return None
|
||||
|
||||
if not files_to_add:
|
||||
return None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for abs_path, rel_path in files_to_add:
|
||||
try:
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
try:
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
zf.write(tmp_db, arcname=str(rel_path))
|
||||
finally:
|
||||
tmp_db.unlink(missing_ok=True)
|
||||
else:
|
||||
zf.write(abs_path, arcname=str(rel_path))
|
||||
except (PermissionError, OSError, ValueError) as exc:
|
||||
logger.debug("Skipping %s in pre-update backup: %s", rel_path, exc)
|
||||
continue
|
||||
except OSError as exc:
|
||||
logger.warning("Pre-update backup: zip write failed: %s", exc)
|
||||
# Best-effort cleanup of partial file
|
||||
try:
|
||||
out_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
result = _write_full_zip_backup(out_path, hermes_root)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
_prune_pre_update_backups(backup_dir, keep=keep)
|
||||
return out_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-migration auto-backup (used by `hermes claw migrate`)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PRE_MIGRATION_PREFIX = "pre-migration-"
|
||||
_PRE_MIGRATION_DEFAULT_KEEP = 5
|
||||
|
||||
|
||||
def _prune_pre_migration_backups(backup_dir: Path, keep: int) -> int:
|
||||
"""Remove oldest pre-migration backups beyond the keep limit.
|
||||
|
||||
Only touches files matching ``pre-migration-*.zip`` so other backups in
|
||||
the same directory are never touched.
|
||||
"""
|
||||
if keep < 0:
|
||||
keep = 0
|
||||
if not backup_dir.exists():
|
||||
return 0
|
||||
|
||||
backups = sorted(
|
||||
(p for p in backup_dir.iterdir()
|
||||
if p.is_file() and p.name.startswith(_PRE_MIGRATION_PREFIX) and p.suffix.lower() == ".zip"),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
deleted = 0
|
||||
for p in backups[keep:]:
|
||||
try:
|
||||
p.unlink()
|
||||
deleted += 1
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to prune pre-migration backup %s: %s", p.name, exc)
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def create_pre_migration_backup(
|
||||
hermes_home: Optional[Path] = None,
|
||||
keep: int = _PRE_MIGRATION_DEFAULT_KEEP,
|
||||
) -> Optional[Path]:
|
||||
"""Create a full zip backup of HERMES_HOME under ``backups/`` before a
|
||||
``hermes claw migrate`` apply.
|
||||
|
||||
Shares implementation with :func:`create_pre_update_backup` via
|
||||
``_write_full_zip_backup`` — same exclusions, same SQLite safe-copy,
|
||||
restorable with ``hermes import <archive>``. Writes to
|
||||
``<HERMES_HOME>/backups/pre-migration-<timestamp>.zip`` and auto-prunes
|
||||
old pre-migration backups.
|
||||
|
||||
Returns the path to the created zip, or ``None`` if nothing was found
|
||||
to back up (fresh install) or the write failed. Never raises — the
|
||||
caller decides whether to abort or proceed.
|
||||
"""
|
||||
hermes_root = hermes_home or get_default_hermes_root()
|
||||
if not hermes_root.is_dir():
|
||||
return None
|
||||
|
||||
# Reuses the shared backups/ directory so `hermes import` and the
|
||||
# update-backup listing pick up pre-migration archives too.
|
||||
backup_dir = _pre_update_backup_dir(hermes_root)
|
||||
try:
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not create pre-migration backup dir %s: %s", backup_dir, exc)
|
||||
return None
|
||||
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
out_path = backup_dir / f"{_PRE_MIGRATION_PREFIX}{stamp}.zip"
|
||||
|
||||
result = _write_full_zip_backup(out_path, hermes_root)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
_prune_pre_migration_backups(backup_dir, keep=keep)
|
||||
return out_path
|
||||
|
||||
@@ -562,7 +562,6 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
right_content = "\n".join(right_lines)
|
||||
layout_table.add_row(left_content, right_content)
|
||||
|
||||
agent_name = _skin_branding("agent_name", "Hermes Agent")
|
||||
title_color = _skin_color("banner_title", "#FFD700")
|
||||
border_color = _skin_color("banner_border", "#CD7F32")
|
||||
version_label = format_banner_version_label()
|
||||
|
||||
+67
-6
@@ -4,7 +4,8 @@ Usage:
|
||||
hermes claw migrate # Preview then migrate (always shows preview first)
|
||||
hermes claw migrate --dry-run # Preview only, no changes
|
||||
hermes claw migrate --yes # Skip confirmation prompt
|
||||
hermes claw migrate --preset full --overwrite # Full migration, overwrite conflicts
|
||||
hermes claw migrate --preset full --overwrite --migrate-secrets # Full run w/ secrets
|
||||
hermes claw migrate --no-backup # Skip pre-migration snapshot
|
||||
hermes claw cleanup # Archive leftover OpenClaw directories
|
||||
hermes claw cleanup --dry-run # Preview what would be archived
|
||||
"""
|
||||
@@ -15,6 +16,7 @@ import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
@@ -321,10 +323,13 @@ def _cmd_migrate(args):
|
||||
migrate_secrets = getattr(args, "migrate_secrets", False)
|
||||
workspace_target = getattr(args, "workspace_target", None)
|
||||
skill_conflict = getattr(args, "skill_conflict", "skip")
|
||||
no_backup = getattr(args, "no_backup", False)
|
||||
|
||||
# If using the "full" preset, secrets are included by default
|
||||
if preset == "full":
|
||||
migrate_secrets = True
|
||||
# Secrets are never included implicitly — they must be explicitly requested
|
||||
# via --migrate-secrets, even under --preset full. This mirrors OpenClaw's
|
||||
# migrate-hermes posture (two-phase: run once without secrets, rerun with
|
||||
# --include-secrets) and prevents a --preset full invocation from silently
|
||||
# importing API keys that the user may not have intended to copy.
|
||||
|
||||
print()
|
||||
print(
|
||||
@@ -431,15 +436,24 @@ def _cmd_migrate(args):
|
||||
|
||||
preview_summary = preview_report.get("summary", {})
|
||||
preview_count = preview_summary.get("migrated", 0)
|
||||
preview_conflicts = preview_summary.get("conflict", 0)
|
||||
|
||||
if preview_count == 0:
|
||||
# "Nothing to migrate" means nothing migrated AND nothing blocked by
|
||||
# conflicts. If there are conflicts, we still want to show the plan and
|
||||
# surface the refusal/--overwrite guidance instead of silently bailing.
|
||||
if preview_count == 0 and preview_conflicts == 0:
|
||||
print()
|
||||
print_info("Nothing to migrate from OpenClaw.")
|
||||
_print_migration_report(preview_report, dry_run=True)
|
||||
return
|
||||
|
||||
print()
|
||||
print_header(f"Migration Preview — {preview_count} item(s) would be imported")
|
||||
if preview_count > 0:
|
||||
print_header(f"Migration Preview — {preview_count} item(s) would be imported")
|
||||
else:
|
||||
print_header(
|
||||
f"Migration Preview — {preview_conflicts} conflict(s), nothing would be imported"
|
||||
)
|
||||
print_info("No changes have been made yet. Review the list below:")
|
||||
_print_migration_report(preview_report, dry_run=True)
|
||||
|
||||
@@ -447,6 +461,24 @@ def _cmd_migrate(args):
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# ── Phase 1b: Refuse if the plan has conflicts and --overwrite is not set ─
|
||||
# Modelled on OpenClaw's assertConflictFreePlan() — apply is a safe no-op
|
||||
# on conflicts unless the user explicitly opts in to overwriting. Without
|
||||
# this guard, the user would answer "yes, proceed" and silently end up
|
||||
# with a migration that skipped every conflicting item.
|
||||
if preview_conflicts > 0 and not overwrite:
|
||||
print()
|
||||
print_error(
|
||||
f"Plan has {preview_conflicts} conflict(s). Refusing to apply."
|
||||
)
|
||||
print_info(
|
||||
"Each conflict is an item whose target already exists in ~/.hermes/. "
|
||||
"Re-run with --overwrite to replace conflicting targets (item-level "
|
||||
"backups are written to the migration report directory)."
|
||||
)
|
||||
print_info("Or re-run with --dry-run to review the full plan.")
|
||||
return
|
||||
|
||||
# ── Phase 2: Confirm and execute ───────────────────────────
|
||||
print()
|
||||
if not auto_yes:
|
||||
@@ -458,6 +490,32 @@ def _cmd_migrate(args):
|
||||
print_info("Migration cancelled.")
|
||||
return
|
||||
|
||||
# ── Phase 2b: Pre-apply backup of the Hermes home ─────────
|
||||
# Delegates to hermes_cli.backup.create_pre_migration_backup(), which
|
||||
# shares implementation with the pre-update backup (same exclusion
|
||||
# rules, same SQLite safe-copy, zip format) so the archive is
|
||||
# restorable with `hermes import`. Mirrors OpenClaw's
|
||||
# createPreMigrationBackup posture — one atomic restore point before
|
||||
# any mutation, auto-pruned to the last 5 pre-migration zips.
|
||||
backup_archive: Optional[Path] = None
|
||||
if not no_backup:
|
||||
try:
|
||||
from hermes_cli.backup import create_pre_migration_backup, _format_size
|
||||
backup_archive = create_pre_migration_backup(hermes_home=hermes_home)
|
||||
if backup_archive:
|
||||
size_str = _format_size(backup_archive.stat().st_size)
|
||||
print()
|
||||
print_success(f"Pre-migration backup: {backup_archive} ({size_str})")
|
||||
print_info(f"Restore with: hermes import {backup_archive.name}")
|
||||
except Exception as e:
|
||||
print()
|
||||
print_error(f"Could not create pre-migration backup: {e}")
|
||||
print_info(
|
||||
"Re-run with --no-backup to skip, or free up disk space under the Hermes home."
|
||||
)
|
||||
logger.debug("Pre-migration backup error", exc_info=True)
|
||||
return
|
||||
|
||||
try:
|
||||
migrator = mod.Migrator(
|
||||
source_root=source_dir.resolve(),
|
||||
@@ -476,6 +534,9 @@ def _cmd_migrate(args):
|
||||
print()
|
||||
print_error(f"Migration failed: {e}")
|
||||
logger.debug("OpenClaw migration error", exc_info=True)
|
||||
if backup_archive:
|
||||
print_info(f"A pre-migration backup is available at: {backup_archive}")
|
||||
print_info(f"Restore with: hermes import {backup_archive.name}")
|
||||
return
|
||||
|
||||
# Print results
|
||||
|
||||
@@ -115,6 +115,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||
"Configuration", cli_only=True,
|
||||
gateway_config_gate="display.tool_progress_command"),
|
||||
CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies",
|
||||
"Configuration", args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status")),
|
||||
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
||||
"Configuration"),
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
@@ -943,6 +946,42 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
# Autocomplete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Per-process cache for /model<space> LM Studio autocomplete. Probing on
|
||||
# every keystroke would block the UI; a short TTL keeps it live without
|
||||
# hammering the server.
|
||||
_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None
|
||||
|
||||
|
||||
def _lmstudio_completion_models() -> list[str]:
|
||||
"""Locally-loaded LM Studio models for /model autocomplete (cached, gated)."""
|
||||
global _LMSTUDIO_COMPLETION_CACHE
|
||||
# Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio.
|
||||
if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")):
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store() or {}
|
||||
if "lmstudio" not in (store.get("providers") or {}) \
|
||||
and "lmstudio" not in (store.get("credential_pool") or {}):
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
now = time.time()
|
||||
if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0:
|
||||
return _LMSTUDIO_COMPLETION_CACHE[1]
|
||||
try:
|
||||
from hermes_cli.models import fetch_lmstudio_models
|
||||
models = fetch_lmstudio_models(
|
||||
api_key=os.environ.get("LM_API_KEY", ""),
|
||||
base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1",
|
||||
timeout=0.8,
|
||||
)
|
||||
except Exception:
|
||||
models = []
|
||||
_LMSTUDIO_COMPLETION_CACHE = (now, models)
|
||||
return models
|
||||
|
||||
|
||||
class SlashCommandCompleter(Completer):
|
||||
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
||||
|
||||
@@ -1366,6 +1405,19 @@ class SlashCommandCompleter(Completer):
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# LM Studio: surface locally-loaded models. Gated on the user actually
|
||||
# having LM Studio configured (env var or auth-store entry) so we
|
||||
# don't probe 127.0.0.1 on every keystroke for users who don't use it.
|
||||
for name in _lmstudio_completion_models():
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta="LM Studio",
|
||||
)
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor
|
||||
|
||||
+221
-31
@@ -30,34 +30,67 @@ logger = logging.getLogger(__name__)
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
|
||||
# (path, mtime_ns, size) -> cached expanded config dict.
|
||||
# load_config() returns a deepcopy of the cached value when the file
|
||||
# hasn't changed since the last load, skipping yaml.safe_load +
|
||||
# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).
|
||||
# save_config() + migrate_config() write via atomic_yaml_write which
|
||||
# produces a fresh inode, so stat() sees a new mtime_ns and the next
|
||||
# load repopulates automatically — no explicit invalidation hook.
|
||||
_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||||
# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
|
||||
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
|
||||
# the user's on-disk values without defaults merged in.
|
||||
_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||||
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
|
||||
# (managed by setup/provider flows directly).
|
||||
_EXTRA_ENV_KEYS = frozenset({
|
||||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL",
|
||||
"DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME",
|
||||
"TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME",
|
||||
"SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME",
|
||||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME",
|
||||
"SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME",
|
||||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||||
"DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME",
|
||||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||||
"FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME",
|
||||
"YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME",
|
||||
"WECOM_BOT_ID", "WECOM_SECRET",
|
||||
"WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
|
||||
"WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||
"WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT",
|
||||
"WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME",
|
||||
"WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
|
||||
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
|
||||
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
|
||||
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
|
||||
"BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME",
|
||||
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
|
||||
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
|
||||
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
|
||||
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
|
||||
"MATRIX_RECOVERY_KEY",
|
||||
# Langfuse observability plugin — optional tuning keys + standard SDK vars.
|
||||
# Activation is via plugins.enabled (opt-in through `hermes plugins enable
|
||||
# observability/langfuse` or `hermes tools → Langfuse`); credentials gate
|
||||
# the plugin at runtime.
|
||||
"HERMES_LANGFUSE_ENV",
|
||||
"HERMES_LANGFUSE_RELEASE",
|
||||
"HERMES_LANGFUSE_SAMPLE_RATE",
|
||||
"HERMES_LANGFUSE_MAX_CHARS",
|
||||
"HERMES_LANGFUSE_DEBUG",
|
||||
"LANGFUSE_PUBLIC_KEY",
|
||||
"LANGFUSE_SECRET_KEY",
|
||||
"LANGFUSE_BASE_URL",
|
||||
})
|
||||
import yaml
|
||||
|
||||
@@ -206,6 +239,7 @@ def get_container_exec_info() -> Optional[dict]:
|
||||
|
||||
# Re-export from hermes_constants — canonical definition lives there.
|
||||
from hermes_constants import get_hermes_home # noqa: F811,E402
|
||||
from utils import atomic_replace
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Get the main config file path."""
|
||||
@@ -389,6 +423,20 @@ DEFAULT_CONFIG = {
|
||||
# (60+ tool iterations with tiny output) before users assume the
|
||||
# bot is dead and /restart.
|
||||
"gateway_notify_interval": 180,
|
||||
# Freshness window for the gateway auto-continue note (seconds).
|
||||
# After a gateway crash/restart/SIGTERM mid-run, the next user
|
||||
# message gets a "[System note: your previous turn was
|
||||
# interrupted — process the unfinished tool result(s) first]"
|
||||
# prepended so the model picks up where it left off. That's the
|
||||
# right behaviour while the interruption is fresh, but stale
|
||||
# markers (transcript last touched hours or days ago) can revive
|
||||
# an unrelated old task when the user's next message starts new
|
||||
# work. This window is the max age of the last persisted
|
||||
# transcript row for which we still inject the continue note.
|
||||
# Default 3600s comfortably covers a long turn (gateway_timeout
|
||||
# default is 1800s) plus runtime slack. Set to 0 to disable the
|
||||
# gate and restore pre-fix behaviour (always inject).
|
||||
"gateway_auto_continue_freshness": 3600,
|
||||
# How user-attached images are presented to the main model on each turn.
|
||||
# "auto" — attach natively when the active model reports
|
||||
# supports_vision=True AND the user hasn't explicitly
|
||||
@@ -546,7 +594,7 @@ DEFAULT_CONFIG = {
|
||||
"threshold": 0.50, # compress when context usage exceeds this ratio
|
||||
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
|
||||
"protect_last_n": 20, # minimum recent messages to keep uncompressed
|
||||
|
||||
"hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count
|
||||
},
|
||||
|
||||
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
|
||||
@@ -655,6 +703,11 @@ DEFAULT_CONFIG = {
|
||||
"personality": "kawaii",
|
||||
"resume_display": "full",
|
||||
"busy_input_mode": "interrupt", # interrupt | queue | steer
|
||||
# When true, `hermes --tui` auto-resumes the most recent human-
|
||||
# facing session on launch instead of forging a fresh one.
|
||||
# Mirrors `hermes -c` muscle memory. Default off so existing
|
||||
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
|
||||
"tui_auto_resume_recent": False,
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
@@ -671,6 +724,14 @@ DEFAULT_CONFIG = {
|
||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
|
||||
# Gateway runtime-metadata footer appended to the FINAL message of a turn
|
||||
# (disabled by default to keep replies minimal). When enabled, renders
|
||||
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
|
||||
# display.platforms.<platform>.runtime_footer.
|
||||
"runtime_footer": {
|
||||
"enabled": False,
|
||||
"fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide
|
||||
},
|
||||
},
|
||||
|
||||
# Web dashboard settings
|
||||
@@ -888,6 +949,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
# Telegram platform settings (gateway mode)
|
||||
"telegram": {
|
||||
"reactions": False, # Add 👀/✅/❌ reactions to messages during processing
|
||||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
||||
},
|
||||
|
||||
@@ -942,7 +1004,7 @@ DEFAULT_CONFIG = {
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": True,
|
||||
"redact_secrets": False,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
@@ -1166,6 +1228,22 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"LM_API_KEY": {
|
||||
"description": "LM Studio bearer token for auth-enabled local servers",
|
||||
"prompt": "LM Studio API key / bearer token",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"LM_BASE_URL": {
|
||||
"description": "LM Studio base URL override",
|
||||
"prompt": "LM Studio base URL (leave empty for default)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"GLM_API_KEY": {
|
||||
"description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
|
||||
"prompt": "Z.AI / GLM API key",
|
||||
@@ -1692,6 +1770,30 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "tool",
|
||||
},
|
||||
|
||||
# ── Langfuse observability ──
|
||||
"HERMES_LANGFUSE_PUBLIC_KEY": {
|
||||
"description": "Langfuse project public key (pk-lf-...)",
|
||||
"prompt": "Langfuse public key",
|
||||
"url": "https://cloud.langfuse.com",
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"HERMES_LANGFUSE_SECRET_KEY": {
|
||||
"description": "Langfuse project secret key (sk-lf-...)",
|
||||
"prompt": "Langfuse secret key",
|
||||
"url": "https://cloud.langfuse.com",
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"HERMES_LANGFUSE_BASE_URL": {
|
||||
"description": "Langfuse server URL (default: https://cloud.langfuse.com)",
|
||||
"prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
"advanced": True,
|
||||
},
|
||||
|
||||
# ── Messaging platforms ──
|
||||
"TELEGRAM_BOT_TOKEN": {
|
||||
"description": "Telegram bot token from @BotFather",
|
||||
@@ -1839,6 +1941,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"MATRIX_DM_AUTO_THREAD": {
|
||||
"description": "Auto-create threads for DM messages in Matrix (default: false)",
|
||||
"prompt": "Auto-create threads in DMs (true/false)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"MATRIX_DEVICE_ID": {
|
||||
"description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
|
||||
"prompt": "Matrix device ID (stable across restarts)",
|
||||
@@ -2180,14 +2290,21 @@ def _normalize_custom_provider_entry(
|
||||
"baseUrl": "base_url",
|
||||
"apiMode": "api_mode",
|
||||
"keyEnv": "key_env",
|
||||
"apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant
|
||||
"defaultModel": "default_model",
|
||||
"contextLength": "context_length",
|
||||
"rateLimitDelay": "rate_limit_delay",
|
||||
}
|
||||
# api_key_env is a documented snake_case alias for key_env (see
|
||||
# website/docs/guides/azure-foundry.md). Normalize it up front so the
|
||||
# rest of the normalizer treats it as the canonical field.
|
||||
if "api_key_env" in entry and "key_env" not in entry:
|
||||
entry["key_env"] = entry["api_key_env"]
|
||||
_KNOWN_KEYS = {
|
||||
"name", "api", "url", "base_url", "api_key", "key_env",
|
||||
"name", "api", "url", "base_url", "api_key", "key_env", "api_key_env",
|
||||
"api_mode", "transport", "model", "default_model", "models",
|
||||
"context_length", "rate_limit_delay",
|
||||
"request_timeout_seconds", "stale_timeout_seconds",
|
||||
}
|
||||
for camel, snake in _CAMEL_ALIASES.items():
|
||||
if camel in entry and snake not in entry:
|
||||
@@ -2439,6 +2556,9 @@ _KNOWN_ROOT_KEYS = {
|
||||
_VALID_CUSTOM_PROVIDER_FIELDS = {
|
||||
"name", "base_url", "api_key", "api_mode", "model", "models",
|
||||
"context_length", "rate_limit_delay",
|
||||
# key_env is read at runtime by runtime_provider.py and auxiliary_client.py
|
||||
# — include it here so the set accurately describes the supported schema.
|
||||
"key_env",
|
||||
}
|
||||
|
||||
# Fields that look like they should be inside custom_providers, not at root
|
||||
@@ -2515,10 +2635,32 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
|
||||
"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1",
|
||||
))
|
||||
|
||||
# ── fallback_model must be a top-level dict with provider + model ────
|
||||
# ── fallback_model: single dict OR list of dicts (chain) ─────────────
|
||||
fb = config.get("fallback_model")
|
||||
if fb is not None:
|
||||
if not isinstance(fb, dict):
|
||||
if isinstance(fb, list):
|
||||
# Chain fallback — validate each entry
|
||||
for i, entry in enumerate(fb):
|
||||
if not isinstance(entry, dict):
|
||||
issues.append(ConfigIssue(
|
||||
"error",
|
||||
f"fallback_model[{i}] should be a dict, got {type(entry).__name__}",
|
||||
"Each entry needs provider + model",
|
||||
))
|
||||
else:
|
||||
if not entry.get("provider"):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
f"fallback_model[{i}] is missing 'provider' field",
|
||||
"Add: provider: openrouter (or another provider)",
|
||||
))
|
||||
if not entry.get("model"):
|
||||
issues.append(ConfigIssue(
|
||||
"warning",
|
||||
f"fallback_model[{i}] is missing 'model' field",
|
||||
"Add: model: <model-name>",
|
||||
))
|
||||
elif not isinstance(fb, dict):
|
||||
issues.append(ConfigIssue(
|
||||
"error",
|
||||
f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}",
|
||||
@@ -3311,25 +3453,62 @@ def read_raw_config() -> Dict[str, Any]:
|
||||
be parsed. Use this for lightweight config reads where you just need a
|
||||
single value and don't want the overhead of ``load_config()``'s deep-merge
|
||||
+ migration pipeline.
|
||||
|
||||
Cached on the config file's (mtime_ns, size) — same strategy as
|
||||
``load_config()``. Returns a deepcopy on every call since some callers
|
||||
mutate the result before passing to ``save_config()``.
|
||||
"""
|
||||
try:
|
||||
config_path = get_config_path()
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
st = config_path.stat()
|
||||
cache_key = (st.st_mtime_ns, st.st_size)
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
|
||||
path_key = str(config_path)
|
||||
cached = _RAW_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
|
||||
return data
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from ~/.hermes/config.yaml."""
|
||||
"""Load configuration from ~/.hermes/config.yaml.
|
||||
|
||||
Cached on the config file's (mtime_ns, size). Returns a deepcopy of
|
||||
the cached value when unchanged, since most call sites mutate the
|
||||
result (e.g. ``cfg["model"]["default"] = ...`` before ``save_config``).
|
||||
The cache is keyed on ``str(config_path)`` so profile switches
|
||||
(which change ``HERMES_HOME`` and therefore ``get_config_path()``)
|
||||
don't collide.
|
||||
"""
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
|
||||
path_key = str(config_path)
|
||||
|
||||
try:
|
||||
st = config_path.stat()
|
||||
cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||||
except FileNotFoundError:
|
||||
cache_key = None
|
||||
|
||||
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
if config_path.exists():
|
||||
|
||||
if cache_key is not None:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
@@ -3347,20 +3526,26 @@ def load_config() -> Dict[str, Any]:
|
||||
|
||||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
expanded = _expand_env_vars(normalized)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(expanded)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
|
||||
if cache_key is not None:
|
||||
_LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded))
|
||||
else:
|
||||
_LOAD_CONFIG_CACHE.pop(path_key, None)
|
||||
return expanded
|
||||
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# Secret redaction is OFF by default — tool output (terminal stdout,
|
||||
# read_file results, web content) passes through unmodified. Set
|
||||
# redact_secrets to true to mask strings that look like API keys, tokens,
|
||||
# and passwords before they enter the model context and logs.
|
||||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||||
# is available. Configure via security.tirith_* keys or env vars
|
||||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: false
|
||||
# redact_secrets: true
|
||||
# tirith_enabled: true
|
||||
# tirith_path: "tirith"
|
||||
# tirith_timeout: 5
|
||||
@@ -3393,11 +3578,11 @@ _FALLBACK_COMMENT = """
|
||||
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# Secret redaction is OFF by default. Set to true to mask strings that
|
||||
# look like API keys, tokens, and passwords in tool output and logs.
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: false
|
||||
# redact_secrets: true
|
||||
|
||||
# ── Fallback Model ────────────────────────────────────────────────────
|
||||
# Automatic provider failover when primary is unavailable.
|
||||
@@ -3448,7 +3633,12 @@ def save_config(config: Dict[str, Any]):
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
fb = normalized.get("fallback_model", {})
|
||||
if not fb or not isinstance(fb, dict) or not (fb.get("provider") and fb.get("model")):
|
||||
fb_is_valid = False
|
||||
if isinstance(fb, list):
|
||||
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
|
||||
elif isinstance(fb, dict):
|
||||
fb_is_valid = bool(fb.get("provider") and fb.get("model"))
|
||||
if not fb_is_valid:
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
|
||||
atomic_yaml_write(
|
||||
@@ -3574,7 +3764,7 @@ def sanitize_env_file() -> int:
|
||||
f.writelines(sanitized)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
atomic_replace(tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
@@ -3637,7 +3827,7 @@ def save_env_value(key: str, value: str):
|
||||
value = _check_non_ascii_credential(key, value)
|
||||
ensure_hermes_home()
|
||||
env_path = get_env_path()
|
||||
|
||||
|
||||
# On Windows, open() defaults to the system locale (cp1252) which can
|
||||
# cause OSError errno 22 on UTF-8 .env files.
|
||||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||||
@@ -3649,7 +3839,7 @@ def save_env_value(key: str, value: str):
|
||||
lines = f.readlines()
|
||||
# Sanitize on every read: split concatenated keys, drop stale placeholders
|
||||
lines = _sanitize_env_lines(lines)
|
||||
|
||||
|
||||
# Find and update or append
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
@@ -3657,7 +3847,7 @@ def save_env_value(key: str, value: str):
|
||||
lines[i] = f"{key}={value}\n"
|
||||
found = True
|
||||
break
|
||||
|
||||
|
||||
if not found:
|
||||
# Ensure there's a newline at the end of the file before appending
|
||||
if lines and not lines[-1].endswith("\n"):
|
||||
@@ -3677,7 +3867,7 @@ def save_env_value(key: str, value: str):
|
||||
f.writelines(lines)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
atomic_replace(tmp_path, env_path)
|
||||
# Restore original permissions before _secure_file may tighten them.
|
||||
if original_mode is not None:
|
||||
try:
|
||||
@@ -3733,7 +3923,7 @@ def remove_env_value(key: str) -> bool:
|
||||
f.writelines(new_lines)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
atomic_replace(tmp_path, env_path)
|
||||
if original_mode is not None:
|
||||
try:
|
||||
os.chmod(env_path, original_mode)
|
||||
|
||||
+2
-2
@@ -7,7 +7,6 @@ Currently supports:
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -18,6 +17,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -79,7 +79,7 @@ def _save_pending(entries: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
atomic_replace(tmp, path)
|
||||
except OSError:
|
||||
# Non-fatal — worst case the user has to run ``hermes debug delete``
|
||||
# manually.
|
||||
|
||||
@@ -13,7 +13,6 @@ automatically.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
+10
-2
@@ -57,6 +57,7 @@ _PROVIDER_ENV_HINTS = (
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
"XIAOMI_API_KEY",
|
||||
"TOKENHUB_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
@@ -343,7 +344,7 @@ def run_doctor(args):
|
||||
)
|
||||
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
|
||||
if default_model and "/" in default_model and canonical_provider and canonical_provider not in ("openrouter", "custom", "auto", "ai-gateway", "kilocode", "opencode-zen", "huggingface", "nous"):
|
||||
if default_model and "/" in default_model and canonical_provider and canonical_provider not in ("openrouter", "custom", "auto", "ai-gateway", "kilocode", "opencode-zen", "huggingface", "nous", "lmstudio"):
|
||||
check_warn(
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
|
||||
"(vendor-prefixed slugs belong to aggregators like openrouter)",
|
||||
@@ -516,7 +517,14 @@ def run_doctor(args):
|
||||
if shutil.which("codex"):
|
||||
check_ok("codex CLI")
|
||||
else:
|
||||
check_warn("codex CLI not found", "(required for openai-codex login)")
|
||||
# Native OAuth uses Hermes' own device-code flow — the Codex CLI is
|
||||
# only needed if you want to import existing tokens from
|
||||
# ~/.codex/auth.json. Downgrade to info so users running
|
||||
# `hermes auth openai-codex` aren't told they're missing something.
|
||||
check_info(
|
||||
"codex CLI not installed "
|
||||
"(optional — only required to import tokens from an existing Codex CLI login)"
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Check: Directory structure
|
||||
|
||||
@@ -7,6 +7,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
# Env var name suffixes that indicate credential values. These are the
|
||||
@@ -127,7 +128,7 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
|
||||
f.writelines(sanitized)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
atomic_replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
|
||||
@@ -2953,7 +2953,7 @@ def _setup_sms():
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk — QR scan (recommended) or manual credential entry."""
|
||||
from hermes_cli.setup import (
|
||||
prompt_choice, prompt_yes_no, print_info, print_success, print_warning,
|
||||
prompt_choice, prompt_yes_no, print_success, print_warning,
|
||||
)
|
||||
|
||||
dingtalk_platform = next(p for p in _PLATFORMS if p["key"] == "dingtalk")
|
||||
@@ -3504,7 +3504,6 @@ def _setup_qqbot():
|
||||
method_idx = prompt_choice(" How would you like to set up QQ Bot?", method_choices, 0)
|
||||
|
||||
credentials = None
|
||||
used_qr = False
|
||||
|
||||
if method_idx == 0:
|
||||
# ── QR scan-to-configure ──
|
||||
@@ -3515,8 +3514,6 @@ def _setup_qqbot():
|
||||
print()
|
||||
print_warning(" QQ Bot setup cancelled.")
|
||||
return
|
||||
if credentials:
|
||||
used_qr = True
|
||||
if not credentials:
|
||||
print_info(" QR setup did not complete. Continuing with manual input.")
|
||||
|
||||
|
||||
+1
-2
@@ -19,9 +19,8 @@ format) lives there.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def hooks_command(args) -> None:
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Learning ledger: read-only index of how Hermes has grown for this profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerItem:
|
||||
type: str
|
||||
name: str
|
||||
summary: str
|
||||
source: str
|
||||
count: int = 0
|
||||
learned_from: str | None = None
|
||||
last_used_at: float | None = None
|
||||
learned_at: float | None = None
|
||||
via: str | None = None
|
||||
|
||||
|
||||
def build_learning_ledger(db: Any = None, *, limit: int = 80) -> dict[str, Any]:
|
||||
"""Build a compact, read-only ledger from existing Hermes artifacts."""
|
||||
skill_inventory = _skill_inventory()
|
||||
items = [
|
||||
*_memory_items(),
|
||||
*_tool_usage_items(db),
|
||||
*_integration_items(),
|
||||
]
|
||||
items.sort(
|
||||
key=lambda i: (i.last_used_at or i.learned_at or 0, i.type, i.name),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for item in items:
|
||||
counts[item.type] = counts.get(item.type, 0) + 1
|
||||
|
||||
return {
|
||||
"generated_at": time.time(),
|
||||
"home": str(get_hermes_home()),
|
||||
"counts": counts,
|
||||
"items": [asdict(item) for item in items[: max(1, limit)]],
|
||||
"inventory": {"skills": skill_inventory},
|
||||
"total": len(items),
|
||||
}
|
||||
|
||||
|
||||
def _memory_items() -> list[LedgerItem]:
|
||||
try:
|
||||
from tools.memory_tool import MemoryStore, get_memory_dir
|
||||
|
||||
mem_dir = get_memory_dir()
|
||||
pairs = [
|
||||
("memory", "MEMORY.md", "agent note"),
|
||||
("user", "USER.md", "user profile"),
|
||||
]
|
||||
items: list[LedgerItem] = []
|
||||
for item_type, filename, label in pairs:
|
||||
path = mem_dir / filename
|
||||
for idx, entry in enumerate(MemoryStore._read_file(path), 1):
|
||||
items.append(
|
||||
LedgerItem(
|
||||
type=item_type,
|
||||
name=f"{label} {idx}",
|
||||
summary=_one_line(entry),
|
||||
source=str(path),
|
||||
learned_at=_mtime(path),
|
||||
)
|
||||
)
|
||||
return items
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _skill_inventory() -> int:
|
||||
try:
|
||||
from tools.skills_tool import _find_all_skills
|
||||
|
||||
return len(_find_all_skills())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _tool_usage_items(db: Any) -> list[LedgerItem]:
|
||||
if db is None or not getattr(db, "_conn", None):
|
||||
return []
|
||||
|
||||
usage: dict[tuple[str, str], LedgerItem] = {}
|
||||
|
||||
def bump(
|
||||
item_type: str,
|
||||
name: str,
|
||||
summary: str,
|
||||
ts: float | None,
|
||||
*,
|
||||
learned_from: str | None = None,
|
||||
via: str | None = None,
|
||||
):
|
||||
key = (item_type, name)
|
||||
item = usage.get(key)
|
||||
if not item:
|
||||
item = usage[key] = LedgerItem(
|
||||
type=item_type,
|
||||
name=name,
|
||||
summary=summary,
|
||||
source="state.db",
|
||||
learned_from=learned_from,
|
||||
via=via,
|
||||
)
|
||||
item.count += 1
|
||||
if ts and (not item.last_used_at or ts > item.last_used_at):
|
||||
item.last_used_at = ts
|
||||
item.learned_from = learned_from or item.learned_from
|
||||
item.via = via or item.via
|
||||
|
||||
try:
|
||||
with db._lock:
|
||||
rows = db._conn.execute(
|
||||
"""
|
||||
SELECT m.role, m.content, m.tool_calls, m.tool_name, m.timestamp,
|
||||
m.session_id, s.title, s.source AS session_source
|
||||
FROM messages m
|
||||
LEFT JOIN sessions s ON s.id = m.session_id
|
||||
WHERE m.tool_name IS NOT NULL OR m.tool_calls IS NOT NULL
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT 5000
|
||||
"""
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
for row in rows:
|
||||
ts = _float(row["timestamp"])
|
||||
tool_name = row["tool_name"]
|
||||
content = row["content"] or ""
|
||||
learned_from = row["title"] or row["session_source"] or row["session_id"]
|
||||
if tool_name == "memory":
|
||||
target = _json(content).get("target") or "memory"
|
||||
bump(str(target), f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via="memory")
|
||||
elif tool_name == "session_search":
|
||||
event = learning_event_from_tool(tool_name, {}, content)
|
||||
if event:
|
||||
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via="session_search")
|
||||
elif tool_name in {"skill_view", "skill_manage"}:
|
||||
data = _json(content)
|
||||
name = str(data.get("name") or data.get("skill") or tool_name)
|
||||
bump("skill-use", name, _skill_summary(tool_name, data), ts, learned_from=learned_from, via=tool_name)
|
||||
|
||||
for call in _tool_calls(row["tool_calls"]):
|
||||
name, args = call
|
||||
if name == "session_search":
|
||||
event = learning_event_from_tool(name, args, content)
|
||||
if event:
|
||||
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via=name)
|
||||
elif name in {"skill_view", "skill_manage"}:
|
||||
skill_name = str(
|
||||
args.get("name") or args.get("skill") or args.get("query") or name
|
||||
)
|
||||
bump("skill-use", skill_name, _skill_summary(name, args), ts, learned_from=learned_from, via=name)
|
||||
elif name == "memory":
|
||||
target = str(args.get("target") or "memory")
|
||||
bump(target, f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via=name)
|
||||
|
||||
return list(usage.values())
|
||||
|
||||
|
||||
def learning_event_from_tool(
|
||||
tool_name: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
result: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
args = args or {}
|
||||
data = _json(result)
|
||||
|
||||
if tool_name == "memory":
|
||||
target = str(args.get("target") or data.get("target") or "memory")
|
||||
content = str(args.get("content") or "").strip()
|
||||
return {
|
||||
"type": target if target in {"memory", "user"} else "memory",
|
||||
"verb": "remembered",
|
||||
"title": _memory_title(content) if content else f"{target} updated",
|
||||
"summary": "Durable memory updated",
|
||||
"source": "memory",
|
||||
"via": "memory",
|
||||
}
|
||||
|
||||
if tool_name == "session_search":
|
||||
title = _recall_title(data) or str(args.get("query") or "").strip() or "past sessions"
|
||||
return {
|
||||
"type": "recall",
|
||||
"verb": "recalled",
|
||||
"title": _one_line(title, max_len=120),
|
||||
"summary": "Past conversations recalled",
|
||||
"source": "state.db",
|
||||
"via": "session_search",
|
||||
}
|
||||
|
||||
if tool_name in {"skill_view", "skill_manage"}:
|
||||
action = str(args.get("action") or data.get("action") or "").strip().lower()
|
||||
name = str(args.get("name") or args.get("query") or data.get("name") or "skill").strip()
|
||||
verb = "updated skill" if tool_name == "skill_manage" and action in {"create", "patch", "update", "install"} else "applied skill"
|
||||
return {
|
||||
"type": "skill-use",
|
||||
"verb": verb,
|
||||
"title": _one_line(name, max_len=120),
|
||||
"summary": _skill_summary(tool_name, {**args, **(data if isinstance(data, dict) else {})}),
|
||||
"source": "skills",
|
||||
"via": tool_name,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _skill_summary(tool_name: str, data: dict[str, Any]) -> str:
|
||||
action = str(data.get("action") or "").strip().lower()
|
||||
if tool_name == "skill_manage" and action:
|
||||
return f"Skill {action.replace('_', ' ')}"
|
||||
if tool_name == "skill_manage":
|
||||
return "Skill managed"
|
||||
return "Skill reused"
|
||||
|
||||
|
||||
def _recall_title(data: Any) -> str:
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
results = data.get("results")
|
||||
if not isinstance(results, list) or not results:
|
||||
return str(data.get("query") or "").strip()
|
||||
first = results[0] if isinstance(results[0], dict) else {}
|
||||
return str(first.get("title") or first.get("preview") or data.get("query") or "").strip()
|
||||
|
||||
|
||||
def _memory_title(content: str) -> str:
|
||||
title = _one_line(content, max_len=120)
|
||||
lowered = title.lower()
|
||||
for prefix in ("the user ", "user "):
|
||||
if lowered.startswith(prefix):
|
||||
return title[len(prefix):].lstrip()
|
||||
return title
|
||||
|
||||
|
||||
def _integration_items() -> list[LedgerItem]:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
items: list[LedgerItem] = []
|
||||
provider = ((cfg.get("memory") or {}) if isinstance(cfg, dict) else {}).get(
|
||||
"provider"
|
||||
)
|
||||
if provider:
|
||||
items.append(
|
||||
LedgerItem(
|
||||
type="integration",
|
||||
name=f"{provider} memory provider",
|
||||
summary="External memory provider is configured",
|
||||
source="config.yaml",
|
||||
)
|
||||
)
|
||||
|
||||
for server in (
|
||||
sorted(((cfg.get("mcp") or {}).get("servers") or {}).keys())
|
||||
if isinstance(cfg, dict)
|
||||
else []
|
||||
):
|
||||
items.append(
|
||||
LedgerItem(
|
||||
type="integration",
|
||||
name=f"{server} MCP server",
|
||||
summary="MCP server is configured",
|
||||
source="config.yaml",
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _tool_calls(raw: str | None) -> list[tuple[str, dict[str, Any]]]:
|
||||
calls = _json(raw)
|
||||
if not isinstance(calls, list):
|
||||
return []
|
||||
|
||||
parsed = []
|
||||
for call in calls:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
fn = call.get("function") or {}
|
||||
name = call.get("name") or fn.get("name")
|
||||
args = fn.get("arguments") or call.get("arguments") or call.get("args") or {}
|
||||
if isinstance(args, str):
|
||||
args = _json(args)
|
||||
if name:
|
||||
parsed.append((str(name), args if isinstance(args, dict) else {}))
|
||||
return parsed
|
||||
|
||||
|
||||
def _json(raw: Any) -> Any:
|
||||
if not raw:
|
||||
return {}
|
||||
if isinstance(raw, (dict, list)):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _mtime(path: Path) -> float | None:
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _float(value: Any) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _one_line(text: str, *, max_len: int = 180) -> str:
|
||||
line = " ".join(str(text).split())
|
||||
return line[: max_len - 1] + "…" if len(line) > max_len else line
|
||||
+167
-37
@@ -1820,6 +1820,8 @@ def select_provider_and_model(args=None):
|
||||
"gmi",
|
||||
"nvidia",
|
||||
"ollama-cloud",
|
||||
"tencent-tokenhub",
|
||||
"lmstudio",
|
||||
):
|
||||
_model_flow_api_key_provider(config, selected_provider, current_model)
|
||||
|
||||
@@ -2046,7 +2048,11 @@ def _aux_select_for_task(task: str) -> None:
|
||||
|
||||
# Gather authenticated providers (has credentials + curated model list)
|
||||
try:
|
||||
providers = list_authenticated_providers(current_provider=current_provider)
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_model=current_model,
|
||||
current_base_url=current_base_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Could not detect authenticated providers: {exc}")
|
||||
providers = []
|
||||
@@ -4376,6 +4382,7 @@ def _model_flow_bedrock(config, current_model=""):
|
||||
def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
"""Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.)."""
|
||||
from hermes_cli.auth import (
|
||||
LMSTUDIO_NOAUTH_PLACEHOLDER,
|
||||
PROVIDER_REGISTRY,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
@@ -4410,13 +4417,20 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
try:
|
||||
import getpass
|
||||
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
if provider_id == "lmstudio":
|
||||
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
|
||||
else:
|
||||
prompt = f"{key_env} (or Enter to cancel): "
|
||||
new_key = getpass.getpass(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
if provider_id == "lmstudio":
|
||||
new_key = LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
else:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
@@ -4483,10 +4497,21 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
print(" Tier check: could not verify (proceeding anyway).")
|
||||
print()
|
||||
|
||||
# Optional base URL override
|
||||
# Optional base URL override.
|
||||
# Precedence: env var → config.yaml model.base_url → registry default.
|
||||
# Reading config.yaml prevents silently overwriting a saved remote URL
|
||||
# (e.g. a remote LM Studio endpoint) with localhost when the user just
|
||||
# presses Enter at the prompt below.
|
||||
current_base = ""
|
||||
if base_url_env:
|
||||
current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "")
|
||||
if not current_base:
|
||||
try:
|
||||
_m = load_config().get("model") or {}
|
||||
if str(_m.get("provider") or "").strip().lower() == provider_id:
|
||||
current_base = str(_m.get("base_url") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
effective_base = current_base or pconfig.inference_base_url
|
||||
|
||||
try:
|
||||
@@ -4508,8 +4533,22 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
# 2. Curated static fallback list (offline insurance)
|
||||
# 3. Live /models endpoint probe (small providers without models.dev data)
|
||||
#
|
||||
# Ollama Cloud: dedicated merged discovery (live API + models.dev + disk cache)
|
||||
if provider_id == "ollama-cloud":
|
||||
# LM Studio: live /api/v1/models probe (no models.dev catalog).
|
||||
# Ollama Cloud: merged discovery (live API + models.dev + disk cache).
|
||||
if provider_id == "lmstudio":
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.models import fetch_lmstudio_models
|
||||
|
||||
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
|
||||
try:
|
||||
model_list = fetch_lmstudio_models(api_key=api_key_for_probe, base_url=effective_base)
|
||||
except AuthError as exc:
|
||||
print(f" LM Studio rejected the request: {exc}")
|
||||
print(" Set LM_API_KEY (or update it) to match the server's bearer token.")
|
||||
model_list = []
|
||||
if model_list:
|
||||
print(f" Found {len(model_list)} model(s) from LM Studio")
|
||||
elif provider_id == "ollama-cloud":
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
|
||||
@@ -4731,7 +4770,6 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
read_claude_code_credentials,
|
||||
is_claude_code_token_valid,
|
||||
_is_oauth_token,
|
||||
_resolve_claude_code_token_from_credentials,
|
||||
)
|
||||
|
||||
cc_creds = read_claude_code_credentials()
|
||||
@@ -5213,6 +5251,93 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _warn_stale_dashboard_processes() -> None:
|
||||
"""Warn about running dashboard processes that still hold pre-update code.
|
||||
|
||||
``hermes dashboard`` is a long-lived server process commonly started and
|
||||
forgotten. When ``hermes update`` replaces files on disk, the running
|
||||
process keeps the old Python backend in memory while the JS bundle on
|
||||
disk is updated, causing a silent frontend/backend mismatch (e.g. new
|
||||
auth headers the old backend doesn't recognise → every API call 401s).
|
||||
|
||||
Unlike the gateway, the dashboard has no service manager (systemd /
|
||||
launchd), so we can only warn — we don't auto-kill user-managed
|
||||
background processes.
|
||||
"""
|
||||
patterns = [
|
||||
"hermes dashboard",
|
||||
"hermes_cli.main dashboard",
|
||||
"hermes_cli/main.py dashboard",
|
||||
]
|
||||
self_pid = os.getpid()
|
||||
dashboard_pids: list[int] = []
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
result = subprocess.run(
|
||||
["wmic", "process", "get", "ProcessId,CommandLine",
|
||||
"/FORMAT:LIST"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
current_cmd = ""
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("CommandLine="):
|
||||
current_cmd = line[len("CommandLine="):]
|
||||
elif line.startswith("ProcessId="):
|
||||
pid_str = line[len("ProcessId="):]
|
||||
if (any(p in current_cmd for p in patterns)
|
||||
and int(pid_str) != self_pid):
|
||||
try:
|
||||
dashboard_pids.append(int(pid_str))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# Linux / macOS: scan the process table via ps and match against
|
||||
# the same explicit patterns list used on Windows. Using ps
|
||||
# (rather than `pgrep -f "hermes.*dashboard"`) keeps us consistent
|
||||
# with `hermes_cli.gateway._scan_gateway_pids` and avoids the
|
||||
# greedy regex matching unrelated cmdlines that merely contain
|
||||
# both words (e.g. a chat session discussing "dashboard").
|
||||
result = subprocess.run(
|
||||
["ps", "-A", "-o", "pid=,command="],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped or "grep" in stripped:
|
||||
continue
|
||||
parts = stripped.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
try:
|
||||
pid = int(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
command = parts[1]
|
||||
if (any(p in command for p in patterns)
|
||||
and pid != self_pid):
|
||||
dashboard_pids.append(pid)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return
|
||||
|
||||
if not dashboard_pids:
|
||||
return
|
||||
|
||||
print()
|
||||
print(f"⚠ {len(dashboard_pids)} dashboard process(es) still running "
|
||||
f"with the previous version:")
|
||||
for pid in dashboard_pids:
|
||||
print(f" PID {pid}")
|
||||
print(" The running backend may not match the updated frontend,")
|
||||
print(" causing silent auth failures or empty data.")
|
||||
print(" Restart them to pick up the changes:")
|
||||
print(" kill <pid> && hermes dashboard --port <port> ...")
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
"""Update Hermes Agent by downloading a ZIP archive.
|
||||
|
||||
@@ -5347,6 +5472,7 @@ def _update_via_zip(args):
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
_warn_stale_dashboard_processes()
|
||||
|
||||
|
||||
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
||||
@@ -7048,7 +7174,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print(
|
||||
f" ⚠ {svc_name} died after restart, retrying..."
|
||||
)
|
||||
retry = subprocess.run(
|
||||
subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -7163,6 +7289,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception as e:
|
||||
logger.debug("Legacy unit check during update failed: %s", e)
|
||||
|
||||
# Warn about stale dashboard processes — the dashboard has no
|
||||
# service manager, so we can only tell the user to restart them.
|
||||
_warn_stale_dashboard_processes()
|
||||
|
||||
print()
|
||||
print("Tip: You can now select a provider and model:")
|
||||
print(" hermes model # Select provider and model")
|
||||
@@ -7811,32 +7941,12 @@ For more help on a command:
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
choices=[
|
||||
"auto",
|
||||
"openrouter",
|
||||
"nous",
|
||||
"openai-codex",
|
||||
"copilot-acp",
|
||||
"copilot",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"xai",
|
||||
"ollama-cloud",
|
||||
"huggingface",
|
||||
"zai",
|
||||
"kimi-coding",
|
||||
"kimi-coding-cn",
|
||||
"stepfun",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"kilocode",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"gmi",
|
||||
"nvidia",
|
||||
],
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto)",
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
@@ -9676,17 +9786,26 @@ Examples:
|
||||
"--preset",
|
||||
choices=["user-data", "full"],
|
||||
default="full",
|
||||
help="Migration preset (default: full). 'user-data' excludes secrets",
|
||||
help="Migration preset (default: full). Neither preset imports secrets — "
|
||||
"pass --migrate-secrets to include API keys.",
|
||||
)
|
||||
claw_migrate.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Overwrite existing files (default: skip conflicts)",
|
||||
help="Overwrite existing files (default: refuse to apply when the plan has conflicts)",
|
||||
)
|
||||
claw_migrate.add_argument(
|
||||
"--migrate-secrets",
|
||||
action="store_true",
|
||||
help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.)",
|
||||
help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.). "
|
||||
"Required even under --preset full.",
|
||||
)
|
||||
claw_migrate.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Skip the pre-migration zip snapshot of ~/.hermes/ (by default a "
|
||||
"single restore-point archive is written to ~/.hermes/backups/ "
|
||||
"before apply; restorable with 'hermes import').",
|
||||
)
|
||||
claw_migrate.add_argument(
|
||||
"--workspace-target", help="Absolute path to copy workspace instructions into"
|
||||
@@ -10101,6 +10220,17 @@ Examples:
|
||||
logger.debug(
|
||||
"plugin discovery failed at CLI startup", exc_info=True,
|
||||
)
|
||||
try:
|
||||
# MCP tool discovery — no event loop running in CLI/TUI startup,
|
||||
# so inline is safe. Moved here from model_tools.py module scope
|
||||
# to avoid freezing the gateway's event loop on its first message
|
||||
# via the same lazy import path (#16856).
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup", exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
|
||||
@@ -46,7 +46,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -54,6 +53,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli import __version__ as _HERMES_VERSION
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -190,7 +190,7 @@ def _write_disk_cache(data: dict[str, Any]) -> None:
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(data, fh, indent=2)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
atomic_replace(tmp, path)
|
||||
except OSError as exc:
|
||||
logger.info("model catalog cache write failed: %s", exc)
|
||||
|
||||
|
||||
@@ -213,10 +213,15 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
|
||||
|
||||
|
||||
def _ensure_direct_aliases() -> None:
|
||||
"""Lazy-load direct aliases on first use."""
|
||||
global DIRECT_ALIASES
|
||||
"""Lazy-load direct aliases on first use.
|
||||
|
||||
Mutates the existing DIRECT_ALIASES dict in place rather than rebinding
|
||||
the module attribute. This keeps `from hermes_cli.model_switch import
|
||||
DIRECT_ALIASES` references valid in callers — rebinding would leave them
|
||||
pointing at a stale empty dict.
|
||||
"""
|
||||
if not DIRECT_ALIASES:
|
||||
DIRECT_ALIASES = _load_direct_aliases()
|
||||
DIRECT_ALIASES.update(_load_direct_aliases())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -979,6 +984,7 @@ def list_authenticated_providers(
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int = 8,
|
||||
current_model: str = "",
|
||||
) -> List[dict]:
|
||||
"""Detect which providers have credentials and list their curated models.
|
||||
|
||||
@@ -1025,6 +1031,34 @@ def list_authenticated_providers(
|
||||
if "ollama-cloud" not in curated:
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
curated["ollama-cloud"] = fetch_ollama_cloud_models()
|
||||
# LM Studio has no static catalog — probe its native /api/v1/models
|
||||
# endpoint live so the picker reflects whatever the user has loaded.
|
||||
# Base URL precedence: LM_BASE_URL env var > active config's base_url
|
||||
# (when current provider is lmstudio) > 127.0.0.1 default.
|
||||
# On auth rejection or unreachable server, fall back to the caller-supplied
|
||||
# current model so the picker still shows something when offline / mis-keyed.
|
||||
if "lmstudio" not in curated and (
|
||||
os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL") or current_provider.strip().lower() == "lmstudio"
|
||||
):
|
||||
from hermes_cli.models import fetch_lmstudio_models
|
||||
from hermes_cli.auth import AuthError
|
||||
is_current_lmstudio = current_provider.strip().lower() == "lmstudio"
|
||||
lm_base = (
|
||||
os.environ.get("LM_BASE_URL")
|
||||
or (current_base_url if is_current_lmstudio and current_base_url else None)
|
||||
or "http://127.0.0.1:1234/v1"
|
||||
)
|
||||
try:
|
||||
live = fetch_lmstudio_models(
|
||||
api_key=os.environ.get("LM_API_KEY", ""),
|
||||
base_url=lm_base,
|
||||
timeout=1.5, # Smaller timeout for picker
|
||||
)
|
||||
except AuthError:
|
||||
live = []
|
||||
if not live and is_current_lmstudio and current_model:
|
||||
live = [current_model]
|
||||
curated["lmstudio"] = live
|
||||
|
||||
# --- 1. Check Hermes-mapped providers ---
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
@@ -1175,6 +1209,15 @@ def list_authenticated_providers(
|
||||
|
||||
if hermes_slug in {"copilot", "copilot-acp"}:
|
||||
model_ids = provider_model_ids(hermes_slug)
|
||||
# For aws_sdk providers (bedrock), use live discovery so the list
|
||||
# reflects the active region (eu.*, ap.*) not the static us.* list.
|
||||
elif overlay.auth_type == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
_ids = bedrock_model_ids_or_none()
|
||||
model_ids = _ids if _ids is not None else (curated.get(hermes_slug, []) or curated.get(pid, []))
|
||||
except Exception:
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
else:
|
||||
# Use curated list — look up by Hermes slug, fall back to overlay key
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
@@ -1237,10 +1280,30 @@ def list_authenticated_providers(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Special case: aws_sdk auth (bedrock) — no API key env vars,
|
||||
# credentials come from the boto3 credential chain (env vars,
|
||||
# ~/.aws/credentials, instance roles, etc.)
|
||||
if not _cp_has_creds and _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
_cp_has_creds = has_aws_credentials()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not _cp_has_creds:
|
||||
continue
|
||||
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
# For bedrock, use live discovery so the list reflects the active
|
||||
# region (eu.*, us.*, ap.*) instead of the hardcoded us.* static list.
|
||||
if _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
_ids = bedrock_model_ids_or_none()
|
||||
_cp_model_ids = _ids if _ids is not None else curated.get(_cp.slug, [])
|
||||
except Exception:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
else:
|
||||
_cp_model_ids = curated.get(_cp.slug, [])
|
||||
_cp_total = len(_cp_model_ids)
|
||||
_cp_top = _cp_model_ids[:max_models]
|
||||
|
||||
@@ -1312,8 +1375,23 @@ def list_authenticated_providers(
|
||||
if fb:
|
||||
models_list = list(fb)
|
||||
|
||||
# Try to probe /v1/models if URL is set (but don't block on it)
|
||||
# For now just show what we know from config
|
||||
# Prefer the endpoint's live /models list when credentials are
|
||||
# available. This keeps OpenAI-compatible relays (for example CRS)
|
||||
# in sync when the server catalog changes without requiring the
|
||||
# user to mirror every model into config.yaml.
|
||||
api_key = str(ep_cfg.get("api_key", "") or "").strip()
|
||||
if not api_key:
|
||||
key_env = str(ep_cfg.get("key_env", "") or "").strip()
|
||||
api_key = os.environ.get(key_env, "").strip() if key_env else ""
|
||||
if api_url and api_key:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
live_models = fetch_api_models(api_key, api_url)
|
||||
if live_models:
|
||||
models_list = live_models
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
results.append({
|
||||
"slug": ep_name,
|
||||
"name": display_name,
|
||||
|
||||
+413
-37
@@ -44,6 +44,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("openai/gpt-5.4-mini", ""),
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
("xiaomi/mimo-v2.5", ""),
|
||||
("tencent/hy3-preview:free", "free"),
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3-flash-preview", ""),
|
||||
@@ -106,11 +107,57 @@ def _codex_curated_models() -> list[str]:
|
||||
return _add_forward_compat_models(list(DEFAULT_CODEX_MODELS))
|
||||
|
||||
|
||||
# Static fallback for xAI when the models.dev disk cache is empty (fresh
|
||||
# install, offline first run, etc.). Mirrors the xAI-direct model IDs from
|
||||
# $HERMES_HOME/models_dev_cache.json as of 2026-04-28. Whenever xAI renames
|
||||
# or retires a model, the disk cache picks it up on the next refresh and the
|
||||
# fallback here only matters until that refresh lands.
|
||||
_XAI_STATIC_FALLBACK: list[str] = [
|
||||
"grok-4.20-0309-reasoning",
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"grok-4.20-multi-agent-0309",
|
||||
"grok-4-1-fast",
|
||||
"grok-4-1-fast-non-reasoning",
|
||||
"grok-4-fast",
|
||||
"grok-4-fast-non-reasoning",
|
||||
"grok-4",
|
||||
"grok-code-fast-1",
|
||||
]
|
||||
|
||||
|
||||
def _xai_curated_models() -> list[str]:
|
||||
"""Derive the xAI-direct curated list from models.dev disk cache.
|
||||
|
||||
Reads $HERMES_HOME/models_dev_cache.json directly (no network) so this
|
||||
runs at import time without blocking. Falls back to ``_XAI_STATIC_FALLBACK``
|
||||
when the cache is empty or unreadable. Hermes refreshes the cache from
|
||||
https://models.dev/api.json on normal use, so this list self-heals as
|
||||
xAI renames models.
|
||||
|
||||
Mirrors ``_codex_curated_models()``'s role for openai-codex.
|
||||
"""
|
||||
try:
|
||||
from agent.models_dev import _load_disk_cache
|
||||
data = _load_disk_cache()
|
||||
xai = data.get("xai") if isinstance(data, dict) else None
|
||||
models = xai.get("models") if isinstance(xai, dict) else None
|
||||
if isinstance(models, dict) and models:
|
||||
ids = [mid for mid in models.keys() if isinstance(mid, str)]
|
||||
if ids:
|
||||
return sorted(ids)
|
||||
except Exception:
|
||||
# Any failure (missing file, malformed JSON, import error)
|
||||
# falls through to the static list.
|
||||
pass
|
||||
return list(_XAI_STATIC_FALLBACK)
|
||||
|
||||
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"moonshotai/kimi-k2.6",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"xiaomi/mimo-v2.5",
|
||||
"tencent/hy3-preview",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
@@ -193,10 +240,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"glm-4.5",
|
||||
"glm-4.5-flash",
|
||||
],
|
||||
"xai": [
|
||||
"grok-4.20-reasoning",
|
||||
"grok-4-1-fast-reasoning",
|
||||
],
|
||||
"xai": _xai_curated_models(),
|
||||
"nvidia": [
|
||||
# NVIDIA flagship reasoning models
|
||||
"nvidia/nemotron-3-super-120b-a12b",
|
||||
@@ -273,6 +317,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2-flash",
|
||||
],
|
||||
"tencent-tokenhub": [
|
||||
"hy3-preview",
|
||||
],
|
||||
"arcee": [
|
||||
"trinity-large-thinking",
|
||||
"trinity-large-preview",
|
||||
@@ -350,6 +397,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
# to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat)
|
||||
# or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat).
|
||||
"alibaba": [
|
||||
"qwen3.6-plus",
|
||||
"kimi-k2.5",
|
||||
"qwen3.5-plus",
|
||||
"qwen3-coder-plus",
|
||||
@@ -720,10 +768,12 @@ class ProviderEntry(NamedTuple):
|
||||
CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
|
||||
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
ProviderEntry("lmstudio", "LM Studio", "LM Studio (local desktop app with built-in model server)"),
|
||||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, $5 free credit, no markup)"),
|
||||
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
|
||||
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"),
|
||||
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"),
|
||||
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
|
||||
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
|
||||
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
|
||||
@@ -806,6 +856,10 @@ _PROVIDER_ALIASES = {
|
||||
"huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
"tencent": "tencent-tokenhub",
|
||||
"tokenhub": "tencent-tokenhub",
|
||||
"tencent-cloud": "tencent-tokenhub",
|
||||
"tencentmaas": "tencent-tokenhub",
|
||||
"aws": "bedrock",
|
||||
"aws-bedrock": "bedrock",
|
||||
"amazon-bedrock": "bedrock",
|
||||
@@ -817,6 +871,9 @@ _PROVIDER_ALIASES = {
|
||||
"nvidia-nim": "nvidia",
|
||||
"build-nvidia": "nvidia",
|
||||
"nemotron": "nvidia",
|
||||
"lmstudio": "lmstudio",
|
||||
"lm-studio": "lmstudio",
|
||||
"lm_studio": "lmstudio",
|
||||
"ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud
|
||||
"ollama_cloud": "ollama-cloud",
|
||||
}
|
||||
@@ -1623,31 +1680,41 @@ def provider_label(provider: Optional[str]) -> str:
|
||||
|
||||
# Models that support OpenAI Priority Processing (service_tier="priority").
|
||||
# See https://openai.com/api-priority-processing/ for the canonical list.
|
||||
# Only the bare model slug is stored (no vendor prefix).
|
||||
_PRIORITY_PROCESSING_MODELS: frozenset[str] = frozenset({
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
#
|
||||
# Pattern-based matching — any OpenAI flagship model (gpt-*, o1*, o3*, o4*)
|
||||
# is assumed to support Priority Processing. service_tier=priority is silently
|
||||
# ignored by non-OpenAI endpoints (OpenRouter/Copilot/opencode-zen proxies
|
||||
# strip the field), so false positives are harmless. Codex-series models
|
||||
# (gpt-5-codex, gpt-5.3-codex, etc.) are excluded — they don't expose the
|
||||
# service_tier parameter through the Codex Responses API.
|
||||
_OPENAI_FAST_MODE_PREFIXES: tuple[str, ...] = (
|
||||
"gpt-",
|
||||
"o1",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
})
|
||||
"o4",
|
||||
)
|
||||
|
||||
|
||||
def _is_openai_fast_model(model_id: Optional[str]) -> bool:
|
||||
"""Return True if the model is an OpenAI flagship eligible for Priority Processing."""
|
||||
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||
base = raw.split(":")[0]
|
||||
if not base:
|
||||
return False
|
||||
# Exclude Codex-series — they route through the Codex Responses API
|
||||
# which doesn't accept service_tier.
|
||||
if "codex" in base:
|
||||
return False
|
||||
return any(base.startswith(prefix) for prefix in _OPENAI_FAST_MODE_PREFIXES)
|
||||
|
||||
|
||||
# Models that support Anthropic Fast Mode (speed="fast").
|
||||
# See https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
||||
# Currently only Claude Opus 4.6. Both hyphen and dot variants are stored
|
||||
# to handle native Anthropic (claude-opus-4-6) and OpenRouter (claude-opus-4.6).
|
||||
_ANTHROPIC_FAST_MODE_MODELS: frozenset[str] = frozenset({
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4.6",
|
||||
})
|
||||
#
|
||||
# Pattern-based matching — any claude-* model is eligible. The anthropic
|
||||
# adapter gates speed=fast on native Anthropic endpoints only (see
|
||||
# _is_third_party_anthropic_endpoint in agent/anthropic_adapter.py), so
|
||||
# third-party proxies that would reject the beta header are protected.
|
||||
|
||||
|
||||
def _strip_vendor_prefix(model_id: str) -> str:
|
||||
@@ -1660,20 +1727,14 @@ def _strip_vendor_prefix(model_id: str) -> str:
|
||||
|
||||
def model_supports_fast_mode(model_id: Optional[str]) -> bool:
|
||||
"""Return whether Hermes should expose the /fast toggle for this model."""
|
||||
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||
if raw in _PRIORITY_PROCESSING_MODELS:
|
||||
return True
|
||||
# Anthropic fast mode — strip date suffixes (e.g. claude-opus-4-6-20260401)
|
||||
# and OpenRouter variant tags (:fast, :beta) for matching.
|
||||
base = raw.split(":")[0]
|
||||
return base in _ANTHROPIC_FAST_MODE_MODELS
|
||||
return _is_anthropic_fast_model(model_id) or _is_openai_fast_model(model_id)
|
||||
|
||||
|
||||
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
|
||||
"""Return True if the model supports Anthropic's fast mode (speed='fast')."""
|
||||
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode."""
|
||||
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||
base = raw.split(":")[0]
|
||||
return base in _ANTHROPIC_FAST_MODE_MODELS
|
||||
return base.startswith("claude-")
|
||||
|
||||
|
||||
def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | None:
|
||||
@@ -1695,14 +1756,61 @@ def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | Non
|
||||
|
||||
|
||||
def _resolve_copilot_catalog_api_key() -> str:
|
||||
"""Best-effort GitHub token for fetching the Copilot model catalog."""
|
||||
"""Best-effort GitHub token for fetching the Copilot model catalog.
|
||||
|
||||
Resolution order:
|
||||
1. ``resolve_api_key_provider_credentials("copilot")`` — env vars
|
||||
(``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN``) plus
|
||||
the ``gh auth token`` CLI fallback.
|
||||
2. ``read_credential_pool("copilot")`` — a token (typically a
|
||||
``gho_*`` from device-code login, or a fine-grained PAT) stored in
|
||||
``auth.json`` under ``credential_pool.copilot[]``. The pool is
|
||||
populated by ``hermes auth add copilot`` and by ``_seed_from_env``
|
||||
when the env var is set in ``~/.hermes/.env``.
|
||||
|
||||
Without (2), users whose only Copilot credential is in the pool see
|
||||
the ``/model`` picker fall back to a stale hardcoded list because the
|
||||
live catalog fetch silently 401s. To avoid wedging on a malformed pool
|
||||
entry, each candidate is exchanged via ``exchange_copilot_token`` —
|
||||
only entries that actually exchange successfully are returned, so a
|
||||
later valid entry is reachable when an earlier one is unsupported.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
return str(creds.get("api_key") or "").strip()
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
if api_key:
|
||||
return api_key
|
||||
except Exception:
|
||||
return ""
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import read_credential_pool
|
||||
from hermes_cli.copilot_auth import (
|
||||
exchange_copilot_token,
|
||||
validate_copilot_token,
|
||||
)
|
||||
|
||||
for entry in read_credential_pool("copilot"):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw = str(entry.get("access_token") or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
valid, _ = validate_copilot_token(raw)
|
||||
if not valid:
|
||||
continue
|
||||
try:
|
||||
api_token, _expires_at = exchange_copilot_token(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if api_token:
|
||||
return api_token
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# Providers where models.dev is treated as authoritative: curated static
|
||||
@@ -1884,6 +1992,18 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
if live:
|
||||
return live
|
||||
# Bedrock uses live discovery keyed by the resolved AWS region so that
|
||||
# EU/AP users see eu.*/ap.* model IDs instead of the static us.* list.
|
||||
# Note: early return intentionally skips _MODELS_DEV_PREFERRED merge
|
||||
# below — bedrock is not expected to appear in that table.
|
||||
if normalized == "bedrock":
|
||||
try:
|
||||
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
||||
ids = bedrock_model_ids_or_none()
|
||||
if ids is not None:
|
||||
return ids
|
||||
except Exception:
|
||||
pass
|
||||
curated_static = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
if normalized in _MODELS_DEV_PREFERRED:
|
||||
return _merge_with_models_dev(normalized, curated_static)
|
||||
@@ -2079,6 +2199,228 @@ def _is_github_models_base_url(base_url: Optional[str]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]:
|
||||
"""Strip ``/v1`` suffix from an LM Studio base URL to get the native API root.
|
||||
|
||||
Returns ``None`` when the base URL is empty/invalid.
|
||||
"""
|
||||
root = (base_url or "").strip().rstrip("/")
|
||||
if root.endswith("/v1"):
|
||||
root = root[:-3].rstrip("/")
|
||||
return root or None
|
||||
|
||||
|
||||
def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict:
|
||||
"""Build HTTP headers for LM Studio native API requests."""
|
||||
headers = {"User-Agent": _HERMES_USER_AGENT}
|
||||
token = str(api_key or "").strip()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _lmstudio_fetch_raw_models(
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 5.0,
|
||||
) -> Optional[list[dict]]:
|
||||
"""Fetch the raw model list from LM Studio's ``/api/v1/models``.
|
||||
|
||||
Returns the ``models`` list of dicts on success, ``None`` on network
|
||||
errors or malformed responses. Raises ``AuthError`` on HTTP 401/403.
|
||||
"""
|
||||
server_root = _lmstudio_server_root(base_url)
|
||||
if not server_root:
|
||||
return None
|
||||
|
||||
headers = _lmstudio_request_headers(api_key)
|
||||
request = urllib.request.Request(server_root + "/api/v1/models", headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (401, 403):
|
||||
from hermes_cli.auth import AuthError
|
||||
raise AuthError(
|
||||
f"LM Studio rejected the request with HTTP {exc.code}.",
|
||||
provider="lmstudio",
|
||||
code="auth_rejected",
|
||||
) from exc
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"LM Studio probe at %s failed with HTTP %s", server_root, exc.code,
|
||||
)
|
||||
return None
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"LM Studio probe at %s failed: %s", server_root, exc,
|
||||
)
|
||||
return None
|
||||
|
||||
raw_models = payload.get("models") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw_models, list):
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"LM Studio probe at %s returned malformed payload (no `models` list)",
|
||||
server_root,
|
||||
)
|
||||
return None
|
||||
return raw_models
|
||||
|
||||
|
||||
def probe_lmstudio_models(
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 5.0,
|
||||
) -> Optional[list[str]]:
|
||||
"""Probe LM Studio's model listing.
|
||||
|
||||
Returns chat-capable model keys on success, including the valid empty-list
|
||||
case when the server is reachable but has no non-embedding models.
|
||||
Returns ``None`` on network errors, malformed responses, or empty/invalid
|
||||
base URLs.
|
||||
|
||||
Raises ``AuthError`` on HTTP 401/403 so callers can surface token issues
|
||||
separately from reachability problems.
|
||||
"""
|
||||
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout)
|
||||
if raw_models is None:
|
||||
return None
|
||||
|
||||
keys: list[str] = []
|
||||
for raw in raw_models:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
if str(raw.get("type") or "").strip().lower() == "embedding":
|
||||
continue
|
||||
key = str(raw.get("key") or raw.get("id") or "").strip()
|
||||
if key and key not in keys:
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
|
||||
def fetch_lmstudio_models(
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 5.0,
|
||||
) -> list[str]:
|
||||
"""Fetch LM Studio chat-capable model keys from native ``/api/v1/models``.
|
||||
|
||||
Returns a list of model keys (e.g. ``publisher/model-name``) with embedding
|
||||
models filtered out. Returns an empty list on network errors, malformed
|
||||
responses, or empty/invalid base URLs.
|
||||
|
||||
Raises ``AuthError`` on HTTP 401/403 so callers can distinguish a missing
|
||||
or wrong ``LM_API_KEY`` from an unreachable server — the most common
|
||||
LM Studio support case once auth-enabled mode is turned on.
|
||||
"""
|
||||
models = probe_lmstudio_models(api_key=api_key, base_url=base_url, timeout=timeout)
|
||||
return models or []
|
||||
|
||||
|
||||
def ensure_lmstudio_model_loaded(
|
||||
model: str,
|
||||
base_url: Optional[str],
|
||||
api_key: Optional[str],
|
||||
target_context_length: int,
|
||||
timeout: float = 120.0,
|
||||
) -> Optional[int]:
|
||||
"""Ensure LM Studio has ``model`` loaded with at least ``target_context_length``.
|
||||
|
||||
No-op when an instance is already loaded with sufficient context. Otherwise
|
||||
POSTs ``/api/v1/models/load`` to (re)load with the target context, capped
|
||||
at the model's ``max_context_length``. Returns the resolved loaded context
|
||||
length, or ``None`` when the probe / load failed.
|
||||
"""
|
||||
server_root = _lmstudio_server_root(base_url)
|
||||
if not server_root:
|
||||
return None
|
||||
|
||||
headers = _lmstudio_request_headers(api_key)
|
||||
|
||||
try:
|
||||
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=10)
|
||||
except Exception:
|
||||
raw_models = None
|
||||
if raw_models is None:
|
||||
return None
|
||||
|
||||
target_entry = None
|
||||
for raw in raw_models:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
if raw.get("key") == model or raw.get("id") == model:
|
||||
target_entry = raw
|
||||
break
|
||||
if target_entry is None:
|
||||
return None
|
||||
|
||||
max_ctx = target_entry.get("max_context_length")
|
||||
if isinstance(max_ctx, int) and max_ctx > 0:
|
||||
target_context_length = min(target_context_length, max_ctx)
|
||||
|
||||
for inst in target_entry.get("loaded_instances") or []:
|
||||
cfg = inst.get("config") if isinstance(inst, dict) else None
|
||||
loaded_ctx = cfg.get("context_length") if isinstance(cfg, dict) else None
|
||||
if isinstance(loaded_ctx, int) and loaded_ctx >= target_context_length:
|
||||
return loaded_ctx
|
||||
|
||||
body = json.dumps({
|
||||
"model": model,
|
||||
"context_length": target_context_length,
|
||||
}).encode()
|
||||
load_headers = dict(headers)
|
||||
load_headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
server_root + "/api/v1/models/load",
|
||||
data=body,
|
||||
headers=load_headers,
|
||||
method="POST",
|
||||
),
|
||||
timeout=timeout,
|
||||
) as resp:
|
||||
resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
return target_context_length
|
||||
|
||||
|
||||
def lmstudio_model_reasoning_options(
|
||||
model: str,
|
||||
base_url: Optional[str],
|
||||
api_key: Optional[str] = None,
|
||||
timeout: float = 5.0,
|
||||
) -> list[str]:
|
||||
"""Return the reasoning ``allowed_options`` LM Studio publishes for ``model``.
|
||||
|
||||
Pulls ``capabilities.reasoning.allowed_options`` from ``/api/v1/models``.
|
||||
Returns ``[]`` when the model is unknown, the endpoint is unreachable,
|
||||
or the model does not declare a reasoning capability.
|
||||
"""
|
||||
try:
|
||||
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout)
|
||||
except Exception:
|
||||
raw_models = None
|
||||
if not raw_models:
|
||||
return []
|
||||
|
||||
for raw in raw_models:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
if raw.get("key") != model and raw.get("id") != model:
|
||||
continue
|
||||
caps = raw.get("capabilities")
|
||||
reasoning = caps.get("reasoning") if isinstance(caps, dict) else None
|
||||
opts = reasoning.get("allowed_options") if isinstance(reasoning, dict) else None
|
||||
if isinstance(opts, list):
|
||||
return [str(o).strip().lower() for o in opts if isinstance(o, str)]
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]:
|
||||
catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout)
|
||||
if not catalog:
|
||||
@@ -2674,6 +3016,40 @@ def validate_requested_model(
|
||||
"message": "Model names cannot contain spaces.",
|
||||
}
|
||||
|
||||
if normalized == "lmstudio":
|
||||
from hermes_cli.auth import AuthError
|
||||
# Use probe_lmstudio_models so we can distinguish None (unreachable
|
||||
# / malformed response) from [] (reachable, but no chat-capable models
|
||||
# are loaded). fetch_lmstudio_models collapses both to [].
|
||||
try:
|
||||
models = probe_lmstudio_models(api_key=api_key, base_url=base_url)
|
||||
except AuthError as exc:
|
||||
return {
|
||||
"accepted": False, "persist": False, "recognized": False,
|
||||
"message": (
|
||||
f"{exc} Set `LM_API_KEY` (or update it) to match the server's bearer token."
|
||||
),
|
||||
}
|
||||
if models is None:
|
||||
return {
|
||||
"accepted": False, "persist": False, "recognized": False,
|
||||
"message": f"Could not reach LM Studio's `/api/v1/models` to validate `{requested}`.",
|
||||
}
|
||||
if not models:
|
||||
return {
|
||||
"accepted": False, "persist": False, "recognized": False,
|
||||
"message": (
|
||||
f"LM Studio is reachable but no chat-capable models are loaded. "
|
||||
f"Load `{requested}` in LM Studio (Developer tab → Load Model) and try again."
|
||||
),
|
||||
}
|
||||
if requested_for_lookup in set(models):
|
||||
return {"accepted": True, "persist": True, "recognized": True, "message": None}
|
||||
return {
|
||||
"accepted": False, "persist": False, "recognized": False,
|
||||
"message": f"Model `{requested}` was not found in LM Studio's model listing.",
|
||||
}
|
||||
|
||||
if normalized == "custom":
|
||||
# Try probing with correct auth for the api_mode.
|
||||
if api_mode == "anthropic_messages":
|
||||
|
||||
+28
-11
@@ -128,27 +128,44 @@ def _run_agent(
|
||||
# the user's configured default provider, which may not host the model
|
||||
# the caller just asked for.
|
||||
effective_provider = (provider or "").strip() or None
|
||||
explicit_base_url_from_alias: Optional[str] = None
|
||||
if effective_provider is None and (model or env_model):
|
||||
# Only auto-detect when the model was explicitly requested via arg or
|
||||
# env var (not when it came from config — that's the "use my defaults"
|
||||
# path and the configured provider is already correct).
|
||||
explicit_model = (model or "").strip() or env_model
|
||||
if explicit_model:
|
||||
cfg_provider = ""
|
||||
if isinstance(model_cfg, dict):
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
current_provider = (
|
||||
cfg_provider
|
||||
or os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
or "auto"
|
||||
)
|
||||
detected = detect_provider_for_model(explicit_model, current_provider)
|
||||
if detected:
|
||||
effective_provider, effective_model = detected
|
||||
# First check DIRECT_ALIASES populated from config.yaml `model_aliases:`.
|
||||
# These map a user-defined alias to (model, provider, base_url) for
|
||||
# endpoints not in any catalog (local servers, custom proxies, etc.).
|
||||
try:
|
||||
from hermes_cli import model_switch as _ms
|
||||
_ms._ensure_direct_aliases()
|
||||
direct = _ms.DIRECT_ALIASES.get(explicit_model.strip().lower())
|
||||
except Exception:
|
||||
direct = None
|
||||
if direct is not None:
|
||||
effective_model = direct.model
|
||||
effective_provider = direct.provider
|
||||
if direct.base_url:
|
||||
explicit_base_url_from_alias = direct.base_url.rstrip("/")
|
||||
else:
|
||||
cfg_provider = ""
|
||||
if isinstance(model_cfg, dict):
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
current_provider = (
|
||||
cfg_provider
|
||||
or os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
or "auto"
|
||||
)
|
||||
detected = detect_provider_for_model(explicit_model, current_provider)
|
||||
if detected:
|
||||
effective_provider, effective_model = detected
|
||||
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=effective_provider,
|
||||
target_model=effective_model or None,
|
||||
explicit_base_url=explicit_base_url_from_alias,
|
||||
)
|
||||
|
||||
# Pull in whatever toolsets the user has enabled for "cli".
|
||||
|
||||
@@ -79,6 +79,20 @@ VALID_HOOKS: Set[str] = {
|
||||
# {"action": "allow"} / None -> normal dispatch
|
||||
# Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store.
|
||||
"pre_gateway_dispatch",
|
||||
# Approval lifecycle hooks. Fired by tools/approval.py when a dangerous
|
||||
# command needs user approval -- fires BOTH for CLI-interactive prompts
|
||||
# and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.).
|
||||
# Observers only: return values are ignored. Plugins cannot veto or
|
||||
# pre-answer an approval from these hooks (use pre_tool_call to block
|
||||
# a tool before it reaches approval).
|
||||
#
|
||||
# Kwargs for pre_approval_request:
|
||||
# command: str, description: str, pattern_key: str, pattern_keys: list[str],
|
||||
# session_key: str, surface: "cli" | "gateway"
|
||||
# Kwargs for post_approval_response: same as above plus
|
||||
# choice: "once" | "session" | "always" | "deny" | "timeout"
|
||||
"pre_approval_request",
|
||||
"post_approval_response",
|
||||
}
|
||||
|
||||
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
||||
|
||||
@@ -999,7 +999,6 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
|
||||
# We need to map logical cursor positions to screen rows
|
||||
# accounting for non-navigable separator/headers
|
||||
|
||||
draw_row = 0 # tracks navigable item index
|
||||
|
||||
# --- General Plugins section ---
|
||||
if n_plugins > 0:
|
||||
|
||||
+58
-2
@@ -954,6 +954,59 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
# Rename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:
|
||||
"""Rename Honcho host blocks for a renamed profile without changing peers."""
|
||||
old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes.{new_name}"
|
||||
|
||||
candidates = [
|
||||
new_dir / "honcho.json",
|
||||
_get_default_hermes_home() / "honcho.json",
|
||||
Path.home() / ".honcho" / "config.json",
|
||||
]
|
||||
|
||||
seen: set[Path] = set()
|
||||
for path in candidates:
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except OSError:
|
||||
resolved = path
|
||||
if resolved in seen or not path.is_file():
|
||||
continue
|
||||
seen.add(resolved)
|
||||
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
hosts = raw.get("hosts")
|
||||
if not isinstance(hosts, dict) or old_host not in hosts:
|
||||
continue
|
||||
|
||||
if new_host in hosts:
|
||||
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
|
||||
continue
|
||||
|
||||
block = hosts[old_host]
|
||||
if isinstance(block, dict) and "aiPeer" not in block:
|
||||
bare = old_host.split(".", 1)[1] if "." in old_host else old_host
|
||||
block["aiPeer"] = bare
|
||||
hosts[new_host] = hosts.pop(old_host)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
except OSError:
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
|
||||
print(f"✓ Honcho host updated: {old_host} → {new_host}")
|
||||
|
||||
|
||||
def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
"""Rename a profile: directory, wrapper script, service, active_profile.
|
||||
|
||||
@@ -984,7 +1037,10 @@ def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
old_dir.rename(new_dir)
|
||||
print(f"✓ Renamed {old_dir.name} → {new_dir.name}")
|
||||
|
||||
# 3. Update wrapper script
|
||||
# 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity
|
||||
_migrate_honcho_profile_host(old_name, new_name, new_dir)
|
||||
|
||||
# 4. Update wrapper script
|
||||
remove_wrapper_script(old_name)
|
||||
collision = check_alias_collision(new_name)
|
||||
if not collision:
|
||||
@@ -993,7 +1049,7 @@ def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
else:
|
||||
print(f"⚠ Cannot create alias '{new_name}' — {collision}")
|
||||
|
||||
# 4. Update active_profile if it pointed to old name
|
||||
# 5. Update active_profile if it pointed to old name
|
||||
try:
|
||||
if get_active_profile() == old_name:
|
||||
set_active_profile(new_name)
|
||||
|
||||
@@ -71,6 +71,13 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
auth_type="oauth_external",
|
||||
base_url_override="cloudcode-pa://google",
|
||||
),
|
||||
"lmstudio": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
auth_type="api_key",
|
||||
extra_env_vars=("LM_API_KEY",),
|
||||
base_url_override="http://127.0.0.1:1234/v1",
|
||||
base_url_env_var="LM_BASE_URL",
|
||||
),
|
||||
"copilot-acp": HermesOverlay(
|
||||
transport="codex_responses",
|
||||
auth_type="external_process",
|
||||
@@ -158,6 +165,10 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="openai_chat",
|
||||
base_url_env_var="XIAOMI_BASE_URL",
|
||||
),
|
||||
"tencent-tokenhub": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="TOKENHUB_BASE_URL",
|
||||
),
|
||||
"arcee": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_override="https://api.arcee.ai/api/v1",
|
||||
@@ -179,6 +190,10 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="openai_chat", # default; overridden by api_mode in config
|
||||
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
|
||||
),
|
||||
"bedrock": HermesOverlay(
|
||||
transport="bedrock_converse",
|
||||
auth_type="aws_sdk",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +308,12 @@ ALIASES: Dict[str, str] = {
|
||||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
|
||||
# tencent
|
||||
"tencent": "tencent-tokenhub",
|
||||
"tokenhub": "tencent-tokenhub",
|
||||
"tencent-cloud": "tencent-tokenhub",
|
||||
"tencentmaas": "tencent-tokenhub",
|
||||
|
||||
# bedrock
|
||||
"aws": "bedrock",
|
||||
"aws-bedrock": "bedrock",
|
||||
@@ -330,6 +351,8 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
||||
"stepfun": "StepFun Step Plan",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
"gmi": "GMI Cloud",
|
||||
"tencent-tokenhub": "Tencent TokenHub",
|
||||
"lmstudio": "LM Studio",
|
||||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
|
||||
@@ -260,11 +260,16 @@ def _resolve_runtime_from_pool_entry(
|
||||
if cfg_base_url:
|
||||
base_url = cfg_base_url
|
||||
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
|
||||
if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
|
||||
api_mode = configured_mode
|
||||
elif provider in ("opencode-zen", "opencode-go"):
|
||||
if provider in ("opencode-zen", "opencode-go"):
|
||||
# Re-derive api_mode from the effective model rather than the
|
||||
# persisted api_mode: the opencode providers serve both
|
||||
# anthropic_messages and chat_completions models, so the previous
|
||||
# session's mode must not leak across /model switches.
|
||||
# Refs #16878.
|
||||
from hermes_cli.models import opencode_model_api_mode
|
||||
api_mode = opencode_model_api_mode(provider, effective_model)
|
||||
elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
|
||||
api_mode = configured_mode
|
||||
else:
|
||||
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
|
||||
# Kimi /coding, api.openai.com → codex_responses, api.x.ai →
|
||||
@@ -464,6 +469,30 @@ def _resolve_named_custom_runtime(
|
||||
explicit_api_key: Optional[str] = None,
|
||||
explicit_base_url: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
# Bare `provider="custom"` with an explicit base_url (e.g. propagated
|
||||
# from a `model_aliases:` direct-alias resolution) — build a runtime
|
||||
# directly so the alias's base_url actually takes effect.
|
||||
requested_norm = (requested_provider or "").strip().lower()
|
||||
if requested_norm == "custom" and explicit_base_url:
|
||||
base_url = explicit_base_url.strip().rstrip("/")
|
||||
api_key_candidates = [
|
||||
(explicit_api_key or "").strip(),
|
||||
os.getenv("OPENAI_API_KEY", "").strip(),
|
||||
os.getenv("OPENROUTER_API_KEY", "").strip(),
|
||||
]
|
||||
api_key = next(
|
||||
(c for c in api_key_candidates if has_usable_secret(c)),
|
||||
"",
|
||||
) or "no-key-required"
|
||||
return {
|
||||
"provider": "custom",
|
||||
"api_mode": _detect_api_mode_for_url(base_url) or "chat_completions",
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"source": "direct-alias",
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
custom_provider = _get_named_custom_provider(requested_provider)
|
||||
if not custom_provider:
|
||||
return None
|
||||
@@ -1095,13 +1124,34 @@ def resolve_runtime_provider(
|
||||
cfg_base_url and "azure.com" in cfg_base_url.lower()
|
||||
)
|
||||
if _is_azure_endpoint:
|
||||
token = (
|
||||
os.getenv("AZURE_ANTHROPIC_KEY", "").strip()
|
||||
or os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
)
|
||||
# Honor user-specified env var hints on the model config before
|
||||
# falling back to the built-in AZURE_ANTHROPIC_KEY / ANTHROPIC_API_KEY
|
||||
# chain. Accept both `key_env` (Hermes canonical — matches the
|
||||
# custom_providers field name) and `api_key_env` (documented in the
|
||||
# Azure Foundry guide and read by most Hermes-compatible importers).
|
||||
# Matches the config.yaml examples in website/docs/guides/azure-foundry.md.
|
||||
token = ""
|
||||
for hint_key in ("key_env", "api_key_env"):
|
||||
env_var = str(model_cfg.get(hint_key) or "").strip()
|
||||
if env_var:
|
||||
token = os.getenv(env_var, "").strip()
|
||||
if token:
|
||||
break
|
||||
# Next: an inline api_key on the model config (useful in multi-profile
|
||||
# setups that want to avoid env-var juggling).
|
||||
if not token:
|
||||
token = str(model_cfg.get("api_key") or "").strip()
|
||||
# Finally fall back to the historical fixed names.
|
||||
if not token:
|
||||
token = (
|
||||
os.getenv("AZURE_ANTHROPIC_KEY", "").strip()
|
||||
or os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
)
|
||||
if not token:
|
||||
raise AuthError(
|
||||
"No Azure Anthropic API key found. Set AZURE_ANTHROPIC_KEY or ANTHROPIC_API_KEY."
|
||||
"No Azure Anthropic API key found. Set AZURE_ANTHROPIC_KEY or "
|
||||
"ANTHROPIC_API_KEY, or point key_env/api_key_env in your "
|
||||
"config.yaml model section at a custom env var."
|
||||
)
|
||||
else:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token
|
||||
@@ -1212,15 +1262,20 @@ def resolve_runtime_provider(
|
||||
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
# Only honor persisted api_mode when it belongs to the same provider family.
|
||||
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
|
||||
if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
|
||||
api_mode = configured_mode
|
||||
elif provider in ("opencode-zen", "opencode-go"):
|
||||
if provider in ("opencode-zen", "opencode-go"):
|
||||
# opencode-zen/go must always re-derive api_mode from the
|
||||
# target model (not the stale persisted api_mode), because
|
||||
# the same provider serves both anthropic_messages
|
||||
# (e.g. minimax-m2.7) and chat_completions (e.g.
|
||||
# deepseek-v4-flash) and switching models via /model would
|
||||
# otherwise carry the previous mode forward, stripping /v1
|
||||
# from base_url for chat_completions models and 404'ing.
|
||||
# Refs #16878.
|
||||
from hermes_cli.models import opencode_model_api_mode
|
||||
# Prefer the target_model from the caller (explicit mid-session
|
||||
# switch) over the stale model.default; see _resolve_runtime_from_pool_entry
|
||||
# for the same rationale.
|
||||
_effective = target_model or model_cfg.get("default", "")
|
||||
api_mode = opencode_model_api_mode(provider, _effective)
|
||||
elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
|
||||
api_mode = configured_mode
|
||||
else:
|
||||
# Auto-detect Anthropic-compatible endpoints by URL convention
|
||||
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
|
||||
|
||||
@@ -712,8 +712,6 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
if isinstance(_m, dict):
|
||||
selected_provider = _m.get("provider")
|
||||
|
||||
nous_subscription_selected = selected_provider == "nous"
|
||||
|
||||
# ── Same-provider fallback & rotation setup (full setup only) ──
|
||||
if not quick and _supports_same_provider_pool_setup(selected_provider):
|
||||
try:
|
||||
|
||||
+10
-10
@@ -68,7 +68,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
||||
welcome: "Welcome message" # Shown at CLI startup
|
||||
goodbye: "Goodbye! ⚕" # Shown on exit
|
||||
response_label: " ⚕ Hermes " # Response box header label
|
||||
prompt_symbol: "❯ " # Input prompt symbol
|
||||
prompt_symbol: "❯" # Input prompt symbol (spacing is added by the UI)
|
||||
help_header: "(^_^)? Commands" # /help header text
|
||||
|
||||
# Tool prefix: character for tool output lines (default: ┊)
|
||||
@@ -190,7 +190,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "(^_^)? Available Commands",
|
||||
},
|
||||
"tool_prefix": "┊",
|
||||
@@ -242,7 +242,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Ares Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Farewell, warrior! ⚔",
|
||||
"response_label": " ⚔ Ares ",
|
||||
"prompt_symbol": "⚔ ❯ ",
|
||||
"prompt_symbol": "⚔",
|
||||
"help_header": "(⚔) Available Commands",
|
||||
},
|
||||
"tool_prefix": "╎",
|
||||
@@ -301,7 +301,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "[?] Available Commands",
|
||||
},
|
||||
"tool_prefix": "┊",
|
||||
@@ -340,7 +340,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "(^_^)? Available Commands",
|
||||
},
|
||||
"tool_prefix": "┊",
|
||||
@@ -377,7 +377,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! ⚕",
|
||||
"response_label": " ⚕ Hermes ",
|
||||
"prompt_symbol": "❯ ",
|
||||
"prompt_symbol": "❯",
|
||||
"help_header": "[?] Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
@@ -414,7 +414,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Goodbye! \u2695",
|
||||
"response_label": " \u2695 Hermes ",
|
||||
"prompt_symbol": "\u276f ",
|
||||
"prompt_symbol": "\u276f",
|
||||
"help_header": "(^_^)? Available Commands",
|
||||
},
|
||||
"tool_prefix": "\u250a",
|
||||
@@ -467,7 +467,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Fair winds! Ψ",
|
||||
"response_label": " Ψ Poseidon ",
|
||||
"prompt_symbol": "Ψ ❯ ",
|
||||
"prompt_symbol": "Ψ",
|
||||
"help_header": "(Ψ) Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
@@ -539,7 +539,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.",
|
||||
"goodbye": "The boulder waits. ◉",
|
||||
"response_label": " ◉ Sisyphus ",
|
||||
"prompt_symbol": "◉ ❯ ",
|
||||
"prompt_symbol": "◉",
|
||||
"help_header": "(◉) Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
@@ -612,7 +612,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"welcome": "Welcome to Charizard Agent! Type your message or /help for commands.",
|
||||
"goodbye": "Flame out! ✦",
|
||||
"response_label": " ✦ Charizard ",
|
||||
"prompt_symbol": "✦ ❯ ",
|
||||
"prompt_symbol": "✦",
|
||||
"help_header": "(✦) Available Commands",
|
||||
},
|
||||
"tool_prefix": "│",
|
||||
|
||||
+18
-1
@@ -6,7 +6,7 @@ Shows the status of all Hermes Agent components.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
@@ -274,6 +274,23 @@ def show_status(args):
|
||||
label = "configured" if configured else "not configured (run: hermes model)"
|
||||
print(f" {pname:<16} {check_mark(configured)} {label}")
|
||||
|
||||
# LM Studio reachability — only probe when it's the active provider so
|
||||
# users with foreign configs don't see noise. Auth rejection vs. silent
|
||||
# empty list is the most common LM Studio support case.
|
||||
if _effective_provider_label() == "LM Studio":
|
||||
from hermes_cli.models import probe_lmstudio_models
|
||||
model_cfg = config.get("model")
|
||||
base = (model_cfg.get("base_url") if isinstance(model_cfg, dict) else None) or get_env_value("LM_BASE_URL") or "http://127.0.0.1:1234/v1"
|
||||
try:
|
||||
models = probe_lmstudio_models(api_key=get_env_value("LM_API_KEY") or "", base_url=base, timeout=1.5)
|
||||
if models is None:
|
||||
ok, msg = False, f"unreachable at {base}"
|
||||
else:
|
||||
ok, msg = True, f"reachable ({len(models)} model(s)) at {base}"
|
||||
except AuthError:
|
||||
ok, msg = False, "auth rejected — set LM_API_KEY"
|
||||
print(f" {'LM Studio':<16} {check_mark(ok)} {msg}")
|
||||
|
||||
# =========================================================================
|
||||
# Terminal Configuration
|
||||
# =========================================================================
|
||||
|
||||
@@ -263,7 +263,6 @@ TIPS = [
|
||||
"hermes status --deep runs deeper diagnostic checks across all components.",
|
||||
|
||||
# --- Hidden Gems & Power-User Tricks ---
|
||||
"BOOT.md at ~/.hermes/BOOT.md runs automatically on every gateway start — use it for startup checks.",
|
||||
"Cron jobs can attach a Python script (--script) whose stdout is injected into the prompt as context.",
|
||||
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
|
||||
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
|
||||
|
||||
+159
-1
@@ -425,6 +425,31 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
"langfuse": {
|
||||
"name": "Langfuse Observability",
|
||||
"icon": "📊",
|
||||
"providers": [
|
||||
{
|
||||
"name": "Langfuse Cloud",
|
||||
"tag": "Hosted Langfuse (cloud.langfuse.com)",
|
||||
"env_vars": [
|
||||
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"},
|
||||
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"},
|
||||
],
|
||||
"post_setup": "langfuse",
|
||||
},
|
||||
{
|
||||
"name": "Langfuse Self-Hosted",
|
||||
"tag": "Self-hosted Langfuse instance",
|
||||
"env_vars": [
|
||||
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"},
|
||||
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"},
|
||||
{"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"},
|
||||
],
|
||||
"post_setup": "langfuse",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES.
|
||||
@@ -442,7 +467,10 @@ def _run_post_setup(post_setup_key: str):
|
||||
import shutil
|
||||
if post_setup_key in ("agent_browser", "browserbase"):
|
||||
node_modules = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||
if not node_modules.exists() and shutil.which("npm"):
|
||||
npm_bin = shutil.which("npm")
|
||||
npx_bin = shutil.which("npx")
|
||||
# Step 1: install the agent-browser npm package into node_modules/
|
||||
if not node_modules.exists() and npm_bin:
|
||||
_print_info(" Installing Node.js dependencies for browser tools...")
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
@@ -454,8 +482,94 @@ def _run_post_setup(post_setup_key: str):
|
||||
else:
|
||||
from hermes_constants import display_hermes_home
|
||||
_print_warning(f" npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install")
|
||||
if result.stderr:
|
||||
_print_info(f" {result.stderr.strip()[:200]}")
|
||||
elif not node_modules.exists():
|
||||
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
|
||||
return
|
||||
|
||||
# Step 2: only the local browser provider actually needs Chromium on
|
||||
# disk. Cloud providers (Browserbase, Browser Use, Firecrawl) host
|
||||
# their own Chromium and don't need the local install.
|
||||
if post_setup_key != "agent_browser":
|
||||
return
|
||||
|
||||
# Step 3: ensure the Chromium / headless-shell build agent-browser
|
||||
# drives is actually installed. Without it the CLI hangs on first
|
||||
# use until the command timeout fires. Skip inside Docker — the
|
||||
# image bakes Chromium in at build time, and runtime users usually
|
||||
# can't write to PLAYWRIGHT_BROWSERS_PATH anyway.
|
||||
try:
|
||||
# Import lazily so the tools_config UI doesn't pull in the full
|
||||
# browser_tool module at import time.
|
||||
from tools.browser_tool import (
|
||||
_chromium_installed,
|
||||
_running_in_docker,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
_print_warning(f" Could not check Chromium status: {exc}")
|
||||
return
|
||||
|
||||
if _chromium_installed():
|
||||
_print_success(" Chromium browser already installed")
|
||||
return
|
||||
|
||||
if _running_in_docker():
|
||||
_print_warning(
|
||||
" Chromium is missing but you're running in Docker."
|
||||
)
|
||||
_print_info(
|
||||
" Pull the latest image to get the bundled Chromium:"
|
||||
)
|
||||
_print_info(
|
||||
" docker pull ghcr.io/nousresearch/hermes-agent:latest"
|
||||
)
|
||||
return
|
||||
|
||||
if not npx_bin:
|
||||
_print_warning(
|
||||
" npx not found - install Chromium manually: npx agent-browser install --with-deps"
|
||||
)
|
||||
return
|
||||
|
||||
_print_info(" Installing Chromium (~170MB one-time download)...")
|
||||
import subprocess
|
||||
# Prefer the bundled agent-browser install subcommand so the
|
||||
# version of Chromium matches the CLI. Fall back to npx shim on
|
||||
# setups where the local bin stub isn't present.
|
||||
local_ab = PROJECT_ROOT / "node_modules" / ".bin" / "agent-browser"
|
||||
if sys.platform == "win32":
|
||||
local_ab_win = local_ab.with_suffix(".cmd")
|
||||
if local_ab_win.exists():
|
||||
local_ab = local_ab_win
|
||||
install_cmd = (
|
||||
[str(local_ab), "install", "--with-deps"]
|
||||
if local_ab.exists()
|
||||
else [npx_bin, "-y", "agent-browser", "install", "--with-deps"]
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True, text=True, cwd=str(PROJECT_ROOT), timeout=600,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" Chromium installed")
|
||||
# Invalidate the cached "missing" result so subsequent
|
||||
# check_browser_requirements() calls see the new install.
|
||||
import tools.browser_tool as _bt
|
||||
_bt._cached_chromium_installed = None
|
||||
else:
|
||||
_print_warning(" Chromium install failed:")
|
||||
tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:]
|
||||
for line in tail:
|
||||
_print_info(f" {line[:200]}")
|
||||
_print_info(" Run manually: npx agent-browser install --with-deps")
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" Chromium install timed out (>10min)")
|
||||
_print_info(" Run manually: npx agent-browser install --with-deps")
|
||||
except Exception as exc:
|
||||
_print_warning(f" Chromium install failed: {exc}")
|
||||
_print_info(" Run manually: npx agent-browser install --with-deps")
|
||||
|
||||
elif post_setup_key == "camofox":
|
||||
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser"
|
||||
@@ -567,6 +681,40 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_info(" git submodule update --init --recursive")
|
||||
_print_info(' uv pip install -e "./tinker-atropos"')
|
||||
|
||||
elif post_setup_key == "langfuse":
|
||||
# Install the langfuse SDK.
|
||||
try:
|
||||
__import__("langfuse")
|
||||
_print_success(" langfuse SDK already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing langfuse SDK...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" langfuse SDK installed")
|
||||
else:
|
||||
_print_warning(" langfuse SDK install failed — run manually: pip install langfuse")
|
||||
# Opt the bundled observability/langfuse plugin into plugins.enabled.
|
||||
# The plugin ships in the repo but doesn't load until the user enables
|
||||
# it (standalone plugins are opt-in).
|
||||
try:
|
||||
from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set
|
||||
enabled = _get_enabled_set()
|
||||
if "observability/langfuse" in enabled or "langfuse" in enabled:
|
||||
_print_success(" Plugin observability/langfuse already enabled")
|
||||
else:
|
||||
enabled.add("observability/langfuse")
|
||||
_save_enabled_set(enabled)
|
||||
_print_success(" Plugin observability/langfuse enabled")
|
||||
except Exception as exc:
|
||||
_print_warning(f" Could not enable plugin automatically: {exc}")
|
||||
_print_info(" Run manually: hermes plugins enable observability/langfuse")
|
||||
_print_info(" Restart Hermes for tracing to take effect.")
|
||||
_print_info(" Verify: hermes plugins list")
|
||||
|
||||
|
||||
# ─── Platform / Toolset Helpers ───────────────────────────────────────────────
|
||||
|
||||
@@ -777,6 +925,16 @@ def _get_platform_tools(
|
||||
else:
|
||||
enabled_toolsets.update(explicit_mcp_servers)
|
||||
|
||||
# Honor agent.disabled_toolsets from config.yaml — allows users to
|
||||
# globally suppress specific toolsets (e.g. "memory") across all
|
||||
# platforms without per-platform toolset configuration. This runs
|
||||
# last so it overrides everything above.
|
||||
agent_cfg = config.get("agent") or {}
|
||||
disabled_toolsets = agent_cfg.get("disabled_toolsets") or []
|
||||
if disabled_toolsets:
|
||||
disabled_set = {str(ts) for ts in disabled_toolsets}
|
||||
enabled_toolsets -= disabled_set
|
||||
|
||||
return enabled_toolsets
|
||||
|
||||
|
||||
|
||||
@@ -736,7 +736,7 @@ async def get_sessions(limit: int = 20, offset: int = 0):
|
||||
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
_log.exception("GET /api/sessions failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -968,7 +968,7 @@ async def update_config(body: ConfigUpdate):
|
||||
try:
|
||||
save_config(_denormalize_config_from_web(body.config))
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
_log.exception("PUT /api/config failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -997,7 +997,7 @@ async def set_env_var(body: EnvVarUpdate):
|
||||
try:
|
||||
save_env_value(body.key, body.value)
|
||||
return {"ok": True, "key": body.key}
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
_log.exception("PUT /api/env failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -1011,7 +1011,7 @@ async def remove_env_var(body: EnvVarDelete):
|
||||
return {"ok": True, "key": body.key}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
_log.exception("DELETE /api/env failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -1568,7 +1568,6 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
then spawns a background poller. Returns the user-facing display fields
|
||||
so the UI can render the verification page link + user code.
|
||||
"""
|
||||
from hermes_cli import auth as hauth
|
||||
if provider_id == "nous":
|
||||
from hermes_cli.auth import _request_device_code, PROVIDER_REGISTRY
|
||||
import httpx
|
||||
|
||||
@@ -11,7 +11,6 @@ hot-reloaded by the webhook adapter without a gateway restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
@@ -19,6 +18,7 @@ from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
|
||||
@@ -52,7 +52,7 @@ def _save_subscriptions(subs: Dict[str, dict]) -> None:
|
||||
json.dumps(subs, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(str(tmp_path), str(path))
|
||||
atomic_replace(tmp_path, path)
|
||||
|
||||
|
||||
def _get_webhook_config() -> dict:
|
||||
|
||||
+280
-122
@@ -33,7 +33,7 @@ T = TypeVar("T")
|
||||
|
||||
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
|
||||
SCHEMA_VERSION = 10
|
||||
SCHEMA_VERSION = 11
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
@@ -102,22 +102,26 @@ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestam
|
||||
|
||||
FTS_SQL = """
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
content,
|
||||
content=messages,
|
||||
content_rowid=id
|
||||
content
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (
|
||||
new.id,
|
||||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
||||
DELETE FROM messages_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
||||
DELETE FROM messages_fts WHERE rowid = old.id;
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (
|
||||
new.id,
|
||||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||||
);
|
||||
END;
|
||||
"""
|
||||
|
||||
@@ -128,22 +132,26 @@ END;
|
||||
FTS_TRIGRAM_SQL = """
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
|
||||
content,
|
||||
content=messages,
|
||||
content_rowid=id,
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (new.id, new.content);
|
||||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
|
||||
new.id,
|
||||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content) VALUES('delete', old.id, old.content);
|
||||
DELETE FROM messages_fts_trigram WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content) VALUES('delete', old.id, old.content);
|
||||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (new.id, new.content);
|
||||
DELETE FROM messages_fts_trigram WHERE rowid = old.id;
|
||||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
|
||||
new.id,
|
||||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||||
);
|
||||
END;
|
||||
"""
|
||||
|
||||
@@ -285,130 +293,201 @@ class SessionDB:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_schema_columns(schema_sql: str) -> Dict[str, Dict[str, str]]:
|
||||
"""Extract expected columns per table from SCHEMA_SQL.
|
||||
|
||||
Uses an in-memory SQLite database to parse the SQL — SQLite itself
|
||||
handles all syntax (DEFAULT expressions with commas, inline
|
||||
REFERENCES, CHECK constraints, etc.) so there are zero regex
|
||||
edge cases. The in-memory DB is opened, the schema DDL is
|
||||
executed, and PRAGMA table_info extracts the column metadata.
|
||||
|
||||
Adding a column to SCHEMA_SQL is all that's needed; the
|
||||
reconciliation loop picks it up automatically.
|
||||
"""
|
||||
ref = sqlite3.connect(":memory:")
|
||||
try:
|
||||
ref.executescript(schema_sql)
|
||||
table_columns: Dict[str, Dict[str, str]] = {}
|
||||
for (tbl,) in ref.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall():
|
||||
cols: Dict[str, str] = {}
|
||||
for row in ref.execute(
|
||||
f'PRAGMA table_info("{tbl}")'
|
||||
).fetchall():
|
||||
# row: (cid, name, type, notnull, dflt_value, pk)
|
||||
col_name = row[1]
|
||||
col_type = row[2] or ""
|
||||
notnull = row[3]
|
||||
default = row[4]
|
||||
pk = row[5]
|
||||
# Reconstruct the type expression for ALTER TABLE ADD COLUMN
|
||||
parts = [col_type] if col_type else []
|
||||
if notnull and not pk:
|
||||
parts.append("NOT NULL")
|
||||
if default is not None:
|
||||
parts.append(f"DEFAULT {default}")
|
||||
cols[col_name] = " ".join(parts)
|
||||
table_columns[tbl] = cols
|
||||
return table_columns
|
||||
finally:
|
||||
ref.close()
|
||||
|
||||
def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""Ensure live tables have every column declared in SCHEMA_SQL.
|
||||
|
||||
Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
|
||||
in SCHEMA_SQL is the single source of truth for the desired schema.
|
||||
On every startup this method diffs the live columns (via PRAGMA
|
||||
table_info) against the declared columns, and ADDs any that are
|
||||
missing.
|
||||
|
||||
This makes column additions a declarative operation — just add
|
||||
the column to SCHEMA_SQL and it appears on the next startup.
|
||||
Version-gated migration blocks are no longer needed for ADD COLUMN.
|
||||
"""
|
||||
expected = self._parse_schema_columns(SCHEMA_SQL)
|
||||
for table_name, declared_cols in expected.items():
|
||||
# Get current columns from the live table
|
||||
try:
|
||||
rows = cursor.execute(
|
||||
f'PRAGMA table_info("{table_name}")'
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
continue # Table doesn't exist yet (shouldn't happen after executescript)
|
||||
live_cols = set()
|
||||
for row in rows:
|
||||
# PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)
|
||||
name = row[1] if isinstance(row, (tuple, list)) else row["name"]
|
||||
live_cols.add(name)
|
||||
|
||||
for col_name, col_type in declared_cols.items():
|
||||
if col_name not in live_cols:
|
||||
safe_name = col_name.replace('"', '""')
|
||||
try:
|
||||
cursor.execute(
|
||||
f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Expected: "duplicate column name" from a race or
|
||||
# re-run. Unexpected: "Cannot add a NOT NULL column
|
||||
# with default value NULL" from a schema mistake.
|
||||
# Log at DEBUG so it's visible in agent.log.
|
||||
logger.debug(
|
||||
"reconcile %s.%s: %s", table_name, col_name, exc,
|
||||
)
|
||||
|
||||
def _init_schema(self):
|
||||
"""Create tables and FTS if they don't exist, run migrations."""
|
||||
"""Create tables and FTS if they don't exist, reconcile columns.
|
||||
|
||||
Schema management follows the declarative reconciliation pattern
|
||||
(Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
|
||||
On existing databases, _reconcile_columns() diffs live columns
|
||||
against SCHEMA_SQL and ADDs any missing ones. This eliminates
|
||||
the version-gated migration chain for column additions, making
|
||||
it impossible for reordered or inserted migrations to skip columns.
|
||||
|
||||
The schema_version table is retained for future data migrations
|
||||
(transforming existing rows) which cannot be handled declaratively.
|
||||
"""
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
cursor.executescript(SCHEMA_SQL)
|
||||
|
||||
# Check schema version and run migrations
|
||||
# ── Declarative column reconciliation ──────────────────────────
|
||||
# Diff live tables against SCHEMA_SQL and ADD any missing columns.
|
||||
# This is idempotent and self-healing: even if a version-gated
|
||||
# migration was skipped (e.g. due to version renumbering), the
|
||||
# column gets created here.
|
||||
self._reconcile_columns(cursor)
|
||||
|
||||
# ── Schema version bookkeeping ─────────────────────────────────
|
||||
# Bump to current so future data migrations (if any) can gate on
|
||||
# version. No version-gated column additions remain.
|
||||
cursor.execute("SELECT version FROM schema_version LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
cursor.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
|
||||
cursor.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?)",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
else:
|
||||
current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
if current_version < 2:
|
||||
# v2: add finish_reason column to messages
|
||||
try:
|
||||
cursor.execute("ALTER TABLE messages ADD COLUMN finish_reason TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 2")
|
||||
if current_version < 3:
|
||||
# v3: add title column to sessions
|
||||
try:
|
||||
cursor.execute("ALTER TABLE sessions ADD COLUMN title TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 3")
|
||||
if current_version < 4:
|
||||
# v4: add unique index on title (NULLs allowed, only non-NULL must be unique)
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||
"ON sessions(title) WHERE title IS NOT NULL"
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Index already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 4")
|
||||
if current_version < 5:
|
||||
new_columns = [
|
||||
("cache_read_tokens", "INTEGER DEFAULT 0"),
|
||||
("cache_write_tokens", "INTEGER DEFAULT 0"),
|
||||
("reasoning_tokens", "INTEGER DEFAULT 0"),
|
||||
("billing_provider", "TEXT"),
|
||||
("billing_base_url", "TEXT"),
|
||||
("billing_mode", "TEXT"),
|
||||
("estimated_cost_usd", "REAL"),
|
||||
("actual_cost_usd", "REAL"),
|
||||
("cost_status", "TEXT"),
|
||||
("cost_source", "TEXT"),
|
||||
("pricing_version", "TEXT"),
|
||||
]
|
||||
for name, column_type in new_columns:
|
||||
try:
|
||||
# name and column_type come from the hardcoded tuple above,
|
||||
# not user input. Double-quote identifier escaping is applied
|
||||
# as defense-in-depth; SQLite DDL cannot be parameterized.
|
||||
safe_name = name.replace('"', '""')
|
||||
cursor.execute(f'ALTER TABLE sessions ADD COLUMN "{safe_name}" {column_type}')
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
cursor.execute("UPDATE schema_version SET version = 5")
|
||||
if current_version < 6:
|
||||
# v6: add reasoning columns to messages table — preserves assistant
|
||||
# reasoning text and structured reasoning_details across gateway
|
||||
# session turns. Without these, reasoning chains are lost on
|
||||
# session reload, breaking multi-turn reasoning continuity for
|
||||
# providers that replay reasoning (OpenRouter, OpenAI, Nous).
|
||||
for col_name, col_type in [
|
||||
("reasoning", "TEXT"),
|
||||
("reasoning_details", "TEXT"),
|
||||
("codex_reasoning_items", "TEXT"),
|
||||
]:
|
||||
try:
|
||||
safe = col_name.replace('"', '""')
|
||||
cursor.execute(
|
||||
f'ALTER TABLE messages ADD COLUMN "{safe}" {col_type}'
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 6")
|
||||
if current_version < 7:
|
||||
# v7: preserve provider-native reasoning_content separately from
|
||||
# normalized reasoning text. Kimi/Moonshot replay can require
|
||||
# this field on assistant tool-call messages when thinking is on.
|
||||
try:
|
||||
cursor.execute('ALTER TABLE messages ADD COLUMN "reasoning_content" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 7")
|
||||
if current_version < 8:
|
||||
# v8: add api_call_count column to sessions — tracks the number
|
||||
# of individual LLM API calls made within a session (as opposed
|
||||
# to the session count itself).
|
||||
try:
|
||||
cursor.execute(
|
||||
'ALTER TABLE sessions ADD COLUMN "api_call_count" INTEGER DEFAULT 0'
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 8")
|
||||
if current_version < 9:
|
||||
# v9: preserve replayable Codex assistant message ids/phases so
|
||||
# follow-up turns can rebuild Responses API message items instead
|
||||
# of flattening everything to plain assistant text.
|
||||
try:
|
||||
cursor.execute('ALTER TABLE messages ADD COLUMN "codex_message_items" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 9")
|
||||
# Data migrations that can't be expressed declaratively (row
|
||||
# backfills, index changes tied to a specific version step) stay
|
||||
# in a version-gated chain. Column additions are handled by
|
||||
# _reconcile_columns() above and no longer need entries here.
|
||||
if current_version < 10:
|
||||
# v10: trigram FTS5 table for CJK/substring search.
|
||||
# Created via FTS_TRIGRAM_SQL below; backfill existing messages.
|
||||
# v10: trigram FTS5 table for CJK/substring search. The
|
||||
# virtual table + triggers are created unconditionally via
|
||||
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
|
||||
# backfill into the FTS index.
|
||||
try:
|
||||
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
|
||||
_fts_trigram_exists = True
|
||||
except sqlite3.OperationalError:
|
||||
_fts_trigram_exists = False
|
||||
if not _fts_trigram_exists:
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, content FROM messages WHERE content IS NOT NULL"
|
||||
)
|
||||
cursor.execute("UPDATE schema_version SET version = 10")
|
||||
if current_version < 11:
|
||||
# v11: re-index FTS5 tables to cover tool_name + tool_calls and
|
||||
# switch from external-content to inline mode. Existing DBs have
|
||||
# old-schema FTS tables and triggers that IF NOT EXISTS won't
|
||||
# overwrite, so we drop them explicitly and let the post-migration
|
||||
# existence checks (below) recreate them from FTS_SQL /
|
||||
# FTS_TRIGRAM_SQL, then backfill every message row. Fixes #16751.
|
||||
for _trig in (
|
||||
"messages_fts_insert",
|
||||
"messages_fts_delete",
|
||||
"messages_fts_update",
|
||||
"messages_fts_trigram_insert",
|
||||
"messages_fts_trigram_delete",
|
||||
"messages_fts_trigram_update",
|
||||
):
|
||||
try:
|
||||
cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for _tbl in ("messages_fts", "messages_fts_trigram"):
|
||||
try:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Recreate virtual tables + triggers with the new inline-mode
|
||||
# schema that indexes content || tool_name || tool_calls.
|
||||
cursor.executescript(FTS_SQL)
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
# Backfill both indexes from every existing messages row.
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
if current_version < SCHEMA_VERSION:
|
||||
cursor.execute(
|
||||
"UPDATE schema_version SET version = ?",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
|
||||
# Unique title index — always ensure it exists (safe to run after migrations
|
||||
# since the title column is guaranteed to exist at this point)
|
||||
# Unique title index — always ensure it exists
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||
@@ -1093,6 +1172,85 @@ class SessionDB:
|
||||
|
||||
return self._execute_write(_do)
|
||||
|
||||
def replace_messages(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||
"""Atomically replace every message for a session.
|
||||
|
||||
Used by transcript-rewrite flows such as /retry, /undo, and /compress.
|
||||
The delete + reinsert sequence must commit as one transaction so a
|
||||
mid-rewrite failure does not leave SQLite with a partial transcript.
|
||||
"""
|
||||
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
now_ts = time.time()
|
||||
total_messages = 0
|
||||
total_tool_calls = 0
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown")
|
||||
tool_calls = msg.get("tool_calls")
|
||||
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
|
||||
codex_reasoning_items = (
|
||||
msg.get("codex_reasoning_items") if role == "assistant" else None
|
||||
)
|
||||
codex_message_items = (
|
||||
msg.get("codex_message_items") if role == "assistant" else None
|
||||
)
|
||||
|
||||
reasoning_details_json = (
|
||||
json.dumps(reasoning_details) if reasoning_details else None
|
||||
)
|
||||
codex_items_json = (
|
||||
json.dumps(codex_reasoning_items) if codex_reasoning_items else None
|
||||
)
|
||||
codex_message_items_json = (
|
||||
json.dumps(codex_message_items) if codex_message_items else None
|
||||
)
|
||||
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
||||
tool_calls, tool_name, timestamp, token_count, finish_reason,
|
||||
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
|
||||
codex_message_items)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
role,
|
||||
msg.get("content"),
|
||||
msg.get("tool_call_id"),
|
||||
tool_calls_json,
|
||||
msg.get("tool_name"),
|
||||
now_ts,
|
||||
msg.get("token_count"),
|
||||
msg.get("finish_reason"),
|
||||
msg.get("reasoning") if role == "assistant" else None,
|
||||
msg.get("reasoning_content") if role == "assistant" else None,
|
||||
reasoning_details_json,
|
||||
codex_items_json,
|
||||
codex_message_items_json,
|
||||
),
|
||||
)
|
||||
total_messages += 1
|
||||
if tool_calls is not None:
|
||||
total_tool_calls += (
|
||||
len(tool_calls) if isinstance(tool_calls, list) else 1
|
||||
)
|
||||
now_ts += 1e-6
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
|
||||
(total_messages, total_tool_calls, session_id),
|
||||
)
|
||||
|
||||
self._execute_write(_do)
|
||||
|
||||
def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Load all messages for a session, ordered by timestamp."""
|
||||
with self._lock:
|
||||
@@ -1329,9 +1487,9 @@ class SessionDB:
|
||||
# quotes. FTS5's tokenizer splits on dots and hyphens, turning
|
||||
# ``chat-send`` into ``chat AND send`` and ``P2.2`` into ``p2 AND 2``.
|
||||
# Quoting preserves phrase semantics. A single pass avoids the
|
||||
# double-quoting bug that would occur if dotted and hyphenated
|
||||
# double-quoting bug that would occur if dotted, hyphenated and underscored
|
||||
# patterns were applied sequentially (e.g. ``my-app.config``).
|
||||
sanitized = re.sub(r"\b(\w+(?:[.-]\w+)+)\b", r'"\1"', sanitized)
|
||||
sanitized = re.sub(r"\b(\w+(?:[._-]\w+)+)\b", r'"\1"', sanitized)
|
||||
|
||||
# Step 6: Restore preserved quoted phrases
|
||||
for i, quoted in enumerate(_quoted_parts):
|
||||
@@ -1508,8 +1666,8 @@ class SessionDB:
|
||||
# Short CJK query (1-2 chars) — trigram needs ≥3 CJK chars.
|
||||
# Fall back to LIKE substring search.
|
||||
escaped = raw_query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
like_where = ["m.content LIKE ? ESCAPE '\\'"]
|
||||
like_params: list = [f"%{escaped}%"]
|
||||
like_where = ["(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' OR m.tool_calls LIKE ? ESCAPE '\\')"]
|
||||
like_params: list = [f"%{escaped}%", f"%{escaped}%", f"%{escaped}%"]
|
||||
if source_filter is not None:
|
||||
like_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
|
||||
like_params.extend(source_filter)
|
||||
|
||||
+45
-10
@@ -138,12 +138,18 @@ def _run_async(coro):
|
||||
|
||||
discover_builtin_tools()
|
||||
|
||||
# MCP tool discovery (external MCP servers from config)
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
discover_mcp_tools()
|
||||
except Exception as e:
|
||||
logger.debug("MCP tool discovery failed: %s", e)
|
||||
# MCP tool discovery (external MCP servers from config) used to run here as
|
||||
# a module-level side effect. It was removed because discover_mcp_tools()
|
||||
# internally uses a blocking future.result(timeout=120) wait, and the
|
||||
# gateway lazy-imports this module from inside the asyncio event loop on
|
||||
# the first user message — freezing Discord/Telegram heartbeats for up to
|
||||
# 120s whenever any configured MCP server was slow or unreachable (#16856).
|
||||
#
|
||||
# Each entry point now runs discovery explicitly at its own startup:
|
||||
# - gateway/run.py -> start_gateway() uses run_in_executor
|
||||
# - cli.py, hermes_cli/* -> inline on startup (no event loop)
|
||||
# - tui_gateway/server.py -> inline on startup (no event loop)
|
||||
# - acp_adapter/server.py -> asyncio.to_thread on session init
|
||||
|
||||
# Plugin tool discovery (user/project/pip plugins)
|
||||
try:
|
||||
@@ -409,24 +415,27 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not prop_schema:
|
||||
continue
|
||||
expected = prop_schema.get("type")
|
||||
if not expected:
|
||||
if not expected and not _schema_allows_null(prop_schema):
|
||||
continue
|
||||
coerced = _coerce_value(value, expected)
|
||||
coerced = _coerce_value(value, expected, schema=prop_schema)
|
||||
if coerced is not value:
|
||||
args[key] = coerced
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def _coerce_value(value: str, expected_type):
|
||||
def _coerce_value(value: str, expected_type, schema: dict | None = None):
|
||||
"""Attempt to coerce a string *value* to *expected_type*.
|
||||
|
||||
Returns the original string when coercion is not applicable or fails.
|
||||
"""
|
||||
if _schema_allows_null(schema) and value.strip().lower() == "null":
|
||||
return None
|
||||
|
||||
if isinstance(expected_type, list):
|
||||
# Union type — try each in order, return first successful coercion
|
||||
for t in expected_type:
|
||||
result = _coerce_value(value, t)
|
||||
result = _coerce_value(value, t, schema=schema)
|
||||
if result is not value:
|
||||
return result
|
||||
return value
|
||||
@@ -439,9 +448,35 @@ def _coerce_value(value: str, expected_type):
|
||||
return _coerce_json(value, list)
|
||||
if expected_type == "object":
|
||||
return _coerce_json(value, dict)
|
||||
if expected_type == "null" and value.strip().lower() == "null":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _schema_allows_null(schema: dict | None) -> bool:
|
||||
"""Return True when a JSON Schema fragment explicitly permits null."""
|
||||
if not isinstance(schema, dict):
|
||||
return False
|
||||
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "null":
|
||||
return True
|
||||
if isinstance(schema_type, list) and "null" in schema_type:
|
||||
return True
|
||||
if schema.get("nullable") is True:
|
||||
return True
|
||||
|
||||
for union_key in ("anyOf", "oneOf"):
|
||||
variants = schema.get(union_key)
|
||||
if not isinstance(variants, list):
|
||||
continue
|
||||
for variant in variants:
|
||||
if isinstance(variant, dict) and variant.get("type") == "null":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_json(value: str, expected_python_type: type):
|
||||
"""Parse *value* as JSON when the schema expects an array or object.
|
||||
|
||||
|
||||
+4
-1
@@ -187,7 +187,10 @@
|
||||
|
||||
if [ "$MODE" = "--apply" ]; then
|
||||
sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" "$NIX_FILE"
|
||||
nix build ".#$ATTR.npmDeps" --no-link --print-build-logs
|
||||
if ! nix build ".#$ATTR.npmDeps" --no-link --print-build-logs; then
|
||||
echo " verification build failed after hash update" >&2
|
||||
exit 1
|
||||
fi
|
||||
FIXED=1
|
||||
echo " fixed"
|
||||
fi
|
||||
|
||||
+20
-1
@@ -455,7 +455,15 @@
|
||||
extraPackages = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [ ];
|
||||
description = "Extra packages available on PATH.";
|
||||
description = ''
|
||||
Extra packages available to the agent — terminal commands, skills,
|
||||
cron jobs, and the service process all see them.
|
||||
|
||||
Implemented via the hermes user's per-user profile
|
||||
(`/etc/profiles/per-user/${cfg.user}/bin`), which NixOS includes
|
||||
in PATH for login shells. The packages are also added to the
|
||||
systemd service PATH for direct process access.
|
||||
'';
|
||||
};
|
||||
|
||||
extraPlugins = mkOption {
|
||||
@@ -640,6 +648,17 @@
|
||||
}
|
||||
|
||||
# ── Warnings ──────────────────────────────────────────────────────
|
||||
# ── Per-user profile for extraPackages ───────────────────────────
|
||||
# Wire extraPackages into the hermes user's per-user profile so the
|
||||
# login-shell snapshot (which rebuilds PATH from NixOS profiles) sees
|
||||
# them. The systemd service PATH also includes them for direct access.
|
||||
(lib.mkIf (cfg.extraPackages != []) {
|
||||
# listOf options are merged by the NixOS module system — this appends to
|
||||
# any packages the operator assigned to this user externally (e.g. when
|
||||
# createUser = false and the user definition lives elsewhere in the config).
|
||||
users.users.${cfg.user}.packages = cfg.extraPackages;
|
||||
})
|
||||
|
||||
(lib.mkIf (cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != []) {
|
||||
warnings = [
|
||||
''
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-4Z8KQ69QhO83X6zff+5urWBv6MME686MhTTMdwSl65o=";
|
||||
hash = "sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
@@ -224,6 +224,24 @@ MIGRATION_PRESETS: Dict[str, set[str]] = {
|
||||
}
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Item shape constants — kept stable for downstream consumers of report.json.
|
||||
# Inspired by OpenClaw's src/plugin-sdk/migration.ts so both sides speak the
|
||||
# same vocabulary. Values intentionally match the strings already produced
|
||||
# by this script (migrated/archived/skipped/conflict/error) so the addition
|
||||
# is backward-compatible.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
STATUS_MIGRATED = "migrated"
|
||||
STATUS_ARCHIVED = "archived"
|
||||
STATUS_SKIPPED = "skipped"
|
||||
STATUS_CONFLICT = "conflict"
|
||||
STATUS_ERROR = "error"
|
||||
STATUS_PLANNED = "planned"
|
||||
|
||||
REASON_TARGET_EXISTS = "Target exists and overwrite is disabled"
|
||||
REASON_BLOCKED_BY_APPLY_CONFLICT = "blocked by earlier apply conflict"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemResult:
|
||||
kind: str
|
||||
@@ -232,6 +250,7 @@ class ItemResult:
|
||||
status: str
|
||||
reason: str = ""
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
sensitive: bool = False
|
||||
|
||||
|
||||
def parse_selection_values(values: Optional[Sequence[str]]) -> List[str]:
|
||||
@@ -547,32 +566,128 @@ def relative_label(path: Path, root: Path) -> str:
|
||||
return str(path)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Secret redaction for migration reports.
|
||||
#
|
||||
# The report JSON persists to disk inside the migration output directory and
|
||||
# frequently ends up in bug reports or support channels. Anything that looks
|
||||
# like a credential — by key name or by value shape — is replaced with
|
||||
# "[redacted]" before the report is written.
|
||||
#
|
||||
# Modelled on OpenClaw's src/plugin-sdk/migration.ts so both migration tools
|
||||
# redact consistently. Pure function — safe to call on any plain-data dict.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
REDACTED_MIGRATION_VALUE = "[redacted]"
|
||||
|
||||
_SECRET_KEY_MARKERS = (
|
||||
"accesstoken",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"bearertoken",
|
||||
"clientsecret",
|
||||
"cookie",
|
||||
"credential",
|
||||
"password",
|
||||
"privatekey",
|
||||
"refreshtoken",
|
||||
"secret",
|
||||
)
|
||||
|
||||
_SECRET_VALUE_PATTERNS = (
|
||||
re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=\-]+"),
|
||||
re.compile(r"\bsk-[A-Za-z0-9_\-]{8,}\b"),
|
||||
re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b"),
|
||||
re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{8,}\b"),
|
||||
re.compile(r"\bAIza[0-9A-Za-z_\-]{12,}\b"),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_secret_key(key: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", key.lower())
|
||||
|
||||
|
||||
def _is_secret_key(key: str) -> bool:
|
||||
normalized = _normalize_secret_key(key)
|
||||
if normalized == "token" or normalized.endswith("token"):
|
||||
return True
|
||||
if normalized in ("auth", "authorization"):
|
||||
return True
|
||||
return any(marker in normalized for marker in _SECRET_KEY_MARKERS)
|
||||
|
||||
|
||||
def _redact_string(value: str) -> str:
|
||||
for pattern in _SECRET_VALUE_PATTERNS:
|
||||
value = pattern.sub(REDACTED_MIGRATION_VALUE, value)
|
||||
return value
|
||||
|
||||
|
||||
def redact_migration_value(value: Any) -> Any:
|
||||
"""Return a deep copy of ``value`` with secret-looking content replaced.
|
||||
|
||||
Applied to every report written to disk. Keys whose normalized form
|
||||
matches a credential marker get their value replaced wholesale. Strings
|
||||
anywhere in the tree are scanned for common token patterns (sk-..., ghp_...,
|
||||
xox*-, AIza*, Bearer ...) and those substrings are replaced inline.
|
||||
"""
|
||||
return _redact_internal(value, set())
|
||||
|
||||
|
||||
def _redact_internal(value: Any, seen: set) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _redact_string(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_redact_internal(entry, seen) for entry in value]
|
||||
if isinstance(value, dict):
|
||||
obj_id = id(value)
|
||||
if obj_id in seen:
|
||||
return REDACTED_MIGRATION_VALUE
|
||||
seen.add(obj_id)
|
||||
out: Dict[str, Any] = {}
|
||||
for key, entry in value.items():
|
||||
if isinstance(key, str) and _is_secret_key(key):
|
||||
out[key] = REDACTED_MIGRATION_VALUE
|
||||
else:
|
||||
out[key] = _redact_internal(entry, seen)
|
||||
return out
|
||||
return value
|
||||
|
||||
|
||||
def write_report(output_dir: Path, report: Dict[str, Any]) -> None:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Always redact before persisting. Callers who need the raw object
|
||||
# (in-process) still get it back from build_report(); only the on-disk
|
||||
# copy is redacted.
|
||||
redacted = redact_migration_value(report)
|
||||
(output_dir / "report.json").write_text(
|
||||
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
|
||||
json.dumps(redacted, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for item in report["items"]:
|
||||
for item in redacted["items"]:
|
||||
grouped.setdefault(item["status"], []).append(item)
|
||||
|
||||
lines = [
|
||||
"# OpenClaw -> Hermes Migration Report",
|
||||
"",
|
||||
f"- Timestamp: {report['timestamp']}",
|
||||
f"- Mode: {report['mode']}",
|
||||
f"- Source: `{report['source_root']}`",
|
||||
f"- Target: `{report['target_root']}`",
|
||||
f"- Timestamp: {redacted['timestamp']}",
|
||||
f"- Mode: {redacted['mode']}",
|
||||
f"- Source: `{redacted['source_root']}`",
|
||||
f"- Target: `{redacted['target_root']}`",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
]
|
||||
|
||||
for key, value in report["summary"].items():
|
||||
for key, value in redacted["summary"].items():
|
||||
lines.append(f"- {key}: {value}")
|
||||
|
||||
warnings = redacted.get("warnings") or []
|
||||
if warnings:
|
||||
lines.extend(["", "## Warnings", ""])
|
||||
for warning in warnings:
|
||||
lines.append(f"- {warning}")
|
||||
|
||||
lines.extend(["", "## What Was Not Fully Brought Over", ""])
|
||||
skipped = grouped.get("skipped", []) + grouped.get("conflict", []) + grouped.get("error", [])
|
||||
if not skipped:
|
||||
@@ -584,6 +699,12 @@ def write_report(output_dir: Path, report: Dict[str, Any]) -> None:
|
||||
reason = item["reason"] or item["status"]
|
||||
lines.append(f"- `{source}` -> `{dest}`: {reason}")
|
||||
|
||||
next_steps = redacted.get("next_steps") or []
|
||||
if next_steps:
|
||||
lines.extend(["", "## Next Steps", ""])
|
||||
for step in next_steps:
|
||||
lines.append(f"- {step}")
|
||||
|
||||
(output_dir / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@@ -618,6 +739,31 @@ class Migrator:
|
||||
self.backup_dir = self.output_dir / "backups" if self.output_dir else None
|
||||
self.overflow_dir = self.output_dir / "overflow" if self.output_dir else None
|
||||
self.items: List[ItemResult] = []
|
||||
# Once a config.yaml write hits conflict/error mid-run, later
|
||||
# config.yaml writes are deliberately short-circuited to avoid
|
||||
# leaving config in a partially-written state. Modelled on
|
||||
# OpenClaw's extensions/migrate-hermes/apply.ts "blocked by earlier
|
||||
# apply conflict" sequencing.
|
||||
self._config_apply_blocked: bool = False
|
||||
|
||||
# Resolve the configured workspace directory from openclaw.json.
|
||||
# Many users (especially those who started before the OpenClaw rebrand)
|
||||
# have a custom workspace path (e.g. ~/clawd/) that differs from the
|
||||
# default ~/.openclaw/workspace/. Reading agents.defaults.workspace
|
||||
# lets source_candidate() find files in the actual workspace.
|
||||
self._custom_workspace: Optional[Path] = None
|
||||
oc_config = self.load_openclaw_config()
|
||||
ws = (oc_config.get("agents", {}).get("defaults", {}).get("workspace") or "").strip()
|
||||
if ws:
|
||||
ws_path = Path(ws).expanduser().resolve()
|
||||
# Only use it if it exists and is outside the source_root tree
|
||||
# (otherwise the standard relative-path logic already covers it).
|
||||
if ws_path.is_dir():
|
||||
try:
|
||||
ws_path.relative_to(self.source_root)
|
||||
except ValueError:
|
||||
# ws_path is outside source_root — use it as custom workspace
|
||||
self._custom_workspace = ws_path
|
||||
|
||||
config = load_yaml_file(self.target_root / "config.yaml")
|
||||
mem_cfg = config.get("memory", {}) if isinstance(config.get("memory"), dict) else {}
|
||||
@@ -635,6 +781,32 @@ class Migrator:
|
||||
def is_selected(self, option_id: str) -> bool:
|
||||
return option_id in self.selected_options
|
||||
|
||||
# Option ids that mutate the Hermes config.yaml file. Once any one of
|
||||
# them records a conflict/error on config.yaml, subsequent ones are
|
||||
# short-circuited to avoid partial writes. Keep in sync with methods
|
||||
# that call load_yaml_file(target_root / "config.yaml") + dump_yaml_file.
|
||||
_CONFIG_MUTATING_OPTIONS = frozenset({
|
||||
"model-config",
|
||||
"tts-config",
|
||||
"mcp-servers",
|
||||
"plugins-config",
|
||||
"cron-jobs",
|
||||
"hooks-config",
|
||||
"agent-config",
|
||||
"gateway-config",
|
||||
"session-config",
|
||||
"full-providers",
|
||||
"deep-channels",
|
||||
"browser-config",
|
||||
"tools-config",
|
||||
"approvals-config",
|
||||
"memory-backend",
|
||||
"skills-config",
|
||||
"ui-identity",
|
||||
"logging-config",
|
||||
"command-allowlist",
|
||||
})
|
||||
|
||||
def record(
|
||||
self,
|
||||
kind: str,
|
||||
@@ -644,6 +816,7 @@ class Migrator:
|
||||
reason: str = "",
|
||||
**details: Any,
|
||||
) -> None:
|
||||
sensitive = bool(details.pop("sensitive", False))
|
||||
self.items.append(
|
||||
ItemResult(
|
||||
kind=kind,
|
||||
@@ -652,8 +825,16 @@ class Migrator:
|
||||
status=status,
|
||||
reason=reason,
|
||||
details=details,
|
||||
sensitive=sensitive,
|
||||
)
|
||||
)
|
||||
# Flip the config-block flag when a conflict/error occurs on a
|
||||
# config.yaml write. Later config-mutating options will skip rather
|
||||
# than attempting a partial write.
|
||||
if status in (STATUS_CONFLICT, STATUS_ERROR) and destination is not None:
|
||||
dest_str = str(destination)
|
||||
if dest_str.endswith("config.yaml") or dest_str.endswith("config.yml"):
|
||||
self._config_apply_blocked = True
|
||||
|
||||
def source_candidate(self, *relative_paths: str) -> Optional[Path]:
|
||||
for rel in relative_paths:
|
||||
@@ -673,6 +854,23 @@ class Migrator:
|
||||
alt = self.source_root / "workspace-main" / suffix
|
||||
if alt.exists():
|
||||
return alt
|
||||
|
||||
# Final fallback: check the configured workspace directory from
|
||||
# agents.defaults.workspace in openclaw.json. Users who started
|
||||
# before the OpenClaw rebrand (when the project was named clawd /
|
||||
# clawdbot) often have a custom workspace path outside ~/.openclaw/.
|
||||
if self._custom_workspace:
|
||||
for rel in relative_paths:
|
||||
# Strip the leading "workspace/" or "workspace.default/"
|
||||
# prefix to get the bare filename/subpath.
|
||||
for prefix in ("workspace/", "workspace.default/"):
|
||||
if rel.startswith(prefix):
|
||||
suffix = rel[len(prefix):]
|
||||
alt = self._custom_workspace / suffix
|
||||
if alt.exists():
|
||||
return alt
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
def resolve_skill_destination(self, destination: Path) -> Path:
|
||||
@@ -762,11 +960,30 @@ class Migrator:
|
||||
return self.build_report()
|
||||
|
||||
def run_if_selected(self, option_id: str, func) -> None:
|
||||
if self.is_selected(option_id):
|
||||
func()
|
||||
if not self.is_selected(option_id):
|
||||
meta = MIGRATION_OPTION_METADATA[option_id]
|
||||
self.record(option_id, None, None, "skipped", "Not selected for this run", option_label=meta["label"])
|
||||
return
|
||||
meta = MIGRATION_OPTION_METADATA[option_id]
|
||||
self.record(option_id, None, None, "skipped", "Not selected for this run", option_label=meta["label"])
|
||||
# If a previous config.yaml write hit a conflict/error during apply,
|
||||
# skip remaining config-mutating options rather than risk a partial
|
||||
# write. Dry-run mode never blocks — the user needs the full preview
|
||||
# to decide how to proceed (re-run with --overwrite, etc.).
|
||||
if (
|
||||
self.execute
|
||||
and self._config_apply_blocked
|
||||
and option_id in self._CONFIG_MUTATING_OPTIONS
|
||||
):
|
||||
meta = MIGRATION_OPTION_METADATA[option_id]
|
||||
self.record(
|
||||
option_id,
|
||||
None,
|
||||
None,
|
||||
STATUS_SKIPPED,
|
||||
REASON_BLOCKED_BY_APPLY_CONFLICT,
|
||||
option_label=meta["label"],
|
||||
)
|
||||
return
|
||||
func()
|
||||
|
||||
def build_report(self) -> Dict[str, Any]:
|
||||
summary: Dict[str, int] = {
|
||||
@@ -804,6 +1021,8 @@ class Migrator:
|
||||
},
|
||||
"summary": summary,
|
||||
"items": [asdict(item) for item in self.items],
|
||||
"warnings": self._build_warnings(summary),
|
||||
"next_steps": self._build_next_steps(summary),
|
||||
}
|
||||
|
||||
if self.output_dir:
|
||||
@@ -811,6 +1030,67 @@ class Migrator:
|
||||
|
||||
return report
|
||||
|
||||
def _build_warnings(self, summary: Dict[str, int]) -> List[str]:
|
||||
"""Structured warnings surfaced on the report for downstream consumers.
|
||||
|
||||
Modelled on OpenClaw's extensions/migrate-hermes/plan.ts warnings[].
|
||||
Keep the messages actionable — they show up in summary.md and the
|
||||
JSON report.
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
if summary.get("conflict", 0) > 0:
|
||||
warnings.append(
|
||||
"Conflicts were found. Re-run with --overwrite to replace conflicting "
|
||||
"targets after item-level backups."
|
||||
)
|
||||
if summary.get("error", 0) > 0:
|
||||
warnings.append(
|
||||
"One or more items failed. Inspect the report and re-run after fixing "
|
||||
"the underlying cause."
|
||||
)
|
||||
if self._config_apply_blocked and self.execute:
|
||||
warnings.append(
|
||||
"A config.yaml write hit a conflict or error mid-apply; later config "
|
||||
"items were skipped to avoid a partial write."
|
||||
)
|
||||
# Detect whether secrets were detected but not migrated.
|
||||
provider_keys_skipped = any(
|
||||
item.kind == "provider-keys" and item.status == STATUS_SKIPPED
|
||||
for item in self.items
|
||||
)
|
||||
if provider_keys_skipped and not self.migrate_secrets:
|
||||
warnings.append(
|
||||
"API keys and other credentials were detected but not imported. "
|
||||
"Re-run with --migrate-secrets to copy supported keys into the "
|
||||
"Hermes env file."
|
||||
)
|
||||
return warnings
|
||||
|
||||
def _build_next_steps(self, summary: Dict[str, int]) -> List[str]:
|
||||
"""Human-readable next-step guidance baked into the report."""
|
||||
if not self.execute:
|
||||
return [
|
||||
"Re-run without --dry-run to apply the migration.",
|
||||
"Pass --overwrite to resolve conflicts, or --migrate-secrets to "
|
||||
"include API keys.",
|
||||
]
|
||||
steps: List[str] = []
|
||||
if summary.get("migrated", 0) > 0:
|
||||
steps.append(
|
||||
"Review the migration report at "
|
||||
f"{self.output_dir}/summary.md"
|
||||
if self.output_dir
|
||||
else "Review the migration report."
|
||||
)
|
||||
steps.append(
|
||||
"Start a new Hermes session (or /reset) to pick up the imported config."
|
||||
)
|
||||
if summary.get("conflict", 0) > 0:
|
||||
steps.append(
|
||||
"Re-run with --overwrite to apply items that were blocked by conflicts."
|
||||
)
|
||||
return steps
|
||||
|
||||
def maybe_backup(self, path: Path) -> Optional[Path]:
|
||||
if not self.execute or not self.backup_dir or not path.exists():
|
||||
return None
|
||||
@@ -1391,6 +1671,29 @@ class Migrator:
|
||||
|
||||
model_str = model_str.strip()
|
||||
|
||||
# Resolve a model alias against the OpenClaw model catalog.
|
||||
# OpenClaw stores agents.defaults.model as either a bare string or
|
||||
# {"primary": "<value>"}, and that value can be either:
|
||||
# - a full provider/model API ID (e.g. "anthropic/claude-opus-4-6"), or
|
||||
# - a display alias (e.g. "Claude Opus 4.6") that maps to one.
|
||||
# The catalog at agents.defaults.models is keyed by the full
|
||||
# provider/model API ID with an "alias" field on the value, e.g.:
|
||||
# {"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"}}
|
||||
# If model_str matches an alias in the catalog, rewrite it to the
|
||||
# catalog key (the real API ID). If it's already an API ID or has
|
||||
# no catalog match, leave it alone and let downstream pass it through.
|
||||
model_catalog = config.get("agents", {}).get("defaults", {}).get("models", {})
|
||||
if isinstance(model_catalog, dict) and model_str not in model_catalog:
|
||||
for api_id, entry in model_catalog.items():
|
||||
if not isinstance(api_id, str):
|
||||
continue
|
||||
if isinstance(entry, dict) and entry.get("alias") == model_str:
|
||||
model_str = api_id
|
||||
break
|
||||
if isinstance(entry, str) and entry == model_str:
|
||||
model_str = api_id
|
||||
break
|
||||
|
||||
if yaml is None:
|
||||
self.record("model-config", source_path, destination, "error", "PyYAML is not available")
|
||||
return
|
||||
@@ -2695,6 +2998,13 @@ def parse_args() -> argparse.Namespace:
|
||||
f"Valid ids: {', '.join(sorted(MIGRATION_OPTION_METADATA))}",
|
||||
)
|
||||
parser.add_argument("--output-dir", help="Where to write report, backups, and archived docs")
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json_output",
|
||||
help="Print the migration report as JSON on stdout (redacted). "
|
||||
"Combine with no --execute for a safe plan-only machine-readable preview.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -2719,6 +3029,13 @@ def main() -> int:
|
||||
)
|
||||
report = migrator.migrate()
|
||||
|
||||
# ── Machine-readable JSON mode ────────────────────────────
|
||||
# When --json is set, print the redacted report to stdout and skip the
|
||||
# human-readable terminal recap. Useful for CI and scripted wrappers.
|
||||
if getattr(args, "json_output", False):
|
||||
print(json.dumps(redact_migration_value(report), indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
# ── Human-readable terminal recap ─────────────────────────
|
||||
s = report["summary"]
|
||||
items = report["items"]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Langfuse Observability Plugin
|
||||
|
||||
This plugin ships bundled with Hermes but is **opt-in** — it only loads when
|
||||
you explicitly enable it.
|
||||
|
||||
## Enable
|
||||
|
||||
Pick one:
|
||||
|
||||
```bash
|
||||
# Interactive: walks you through credentials + SDK install + enable
|
||||
hermes tools # → Langfuse Observability
|
||||
|
||||
# Manual
|
||||
pip install langfuse
|
||||
hermes plugins enable observability/langfuse
|
||||
```
|
||||
|
||||
## Required credentials
|
||||
|
||||
Set these in `~/.hermes/.env` (or via `hermes tools`):
|
||||
|
||||
```bash
|
||||
HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-...
|
||||
HERMES_LANGFUSE_SECRET_KEY=sk-lf-...
|
||||
HERMES_LANGFUSE_BASE_URL=https://cloud.langfuse.com # or your self-hosted URL
|
||||
```
|
||||
|
||||
Without the SDK or credentials the hooks no-op silently — the plugin fails
|
||||
open.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
hermes plugins list # observability/langfuse should show "enabled"
|
||||
hermes chat -q "hello" # then check Langfuse for a "Hermes turn" trace
|
||||
```
|
||||
|
||||
## Optional tuning
|
||||
|
||||
```bash
|
||||
HERMES_LANGFUSE_ENV=production # environment tag
|
||||
HERMES_LANGFUSE_RELEASE=v1.0.0 # release tag
|
||||
HERMES_LANGFUSE_SAMPLE_RATE=0.5 # sample 50% of traces
|
||||
HERMES_LANGFUSE_MAX_CHARS=12000 # max chars per field (default: 12000)
|
||||
HERMES_LANGFUSE_DEBUG=true # verbose plugin logging
|
||||
```
|
||||
|
||||
## Disable
|
||||
|
||||
```bash
|
||||
hermes plugins disable observability/langfuse
|
||||
```
|
||||
@@ -0,0 +1,874 @@
|
||||
"""langfuse — Hermes plugin for Langfuse observability.
|
||||
|
||||
Traces Hermes conversations, LLM calls, and tool usage to Langfuse.
|
||||
|
||||
Activation is handled by the Hermes plugin system — standalone plugins only
|
||||
load when listed in ``plugins.enabled`` (via ``hermes plugins enable
|
||||
observability/langfuse`` or ``hermes tools → Langfuse Observability``). At
|
||||
runtime the plugin also requires the ``langfuse`` SDK and credentials; if
|
||||
either is missing the hooks are inert.
|
||||
|
||||
Required env vars (set via ``hermes tools`` or ~/.hermes/.env):
|
||||
HERMES_LANGFUSE_PUBLIC_KEY - Langfuse project public key (pk-lf-...)
|
||||
HERMES_LANGFUSE_SECRET_KEY - Langfuse project secret key (sk-lf-...)
|
||||
HERMES_LANGFUSE_BASE_URL - Langfuse server URL (default: https://cloud.langfuse.com)
|
||||
|
||||
Optional env vars:
|
||||
HERMES_LANGFUSE_ENV - environment tag (e.g. "production", "local")
|
||||
HERMES_LANGFUSE_RELEASE - release/version tag
|
||||
HERMES_LANGFUSE_SAMPLE_RATE - sampling rate 0.0–1.0 (default: 1.0)
|
||||
HERMES_LANGFUSE_MAX_CHARS - max chars per field (default: 12000)
|
||||
HERMES_LANGFUSE_DEBUG - set to "true" for verbose logging
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from langfuse import Langfuse, propagate_attributes
|
||||
except Exception: # pragma: no cover - fail-open when optional dep is missing
|
||||
Langfuse = None
|
||||
propagate_attributes = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraceState:
|
||||
trace_id: str
|
||||
root_ctx: Any
|
||||
root_span: Any
|
||||
generations: Dict[str, Any] = field(default_factory=dict)
|
||||
tools: Dict[str, Any] = field(default_factory=dict)
|
||||
turn_tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
last_updated_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
_STATE_LOCK = threading.Lock()
|
||||
_TRACE_STATE: Dict[str, TraceState] = {}
|
||||
_LANGFUSE_CLIENT = None
|
||||
_READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$")
|
||||
_READ_FILE_HEAD_LINES = 25
|
||||
_READ_FILE_TAIL_LINES = 15
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default).strip()
|
||||
|
||||
|
||||
def _env_bool(*names: str) -> bool:
|
||||
for name in names:
|
||||
value = _env(name).lower()
|
||||
if value:
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
return False
|
||||
|
||||
|
||||
def _debug_enabled() -> bool:
|
||||
return _env_bool("HERMES_LANGFUSE_DEBUG")
|
||||
|
||||
|
||||
def _debug(message: str) -> None:
|
||||
if _debug_enabled():
|
||||
logger.info("Langfuse tracing: %s", message)
|
||||
|
||||
|
||||
# Sentinel: "_get_langfuse() has tried and failed". Lets us short-circuit
|
||||
# every subsequent hook call without re-checking env vars or re-attempting
|
||||
# SDK init. Cleared by reset_cache_for_tests().
|
||||
_INIT_FAILED = object()
|
||||
|
||||
|
||||
def _get_langfuse() -> Optional[Langfuse]:
|
||||
"""Return a cached Langfuse client, or ``None`` if unavailable.
|
||||
|
||||
Activation of this plugin is controlled by the Hermes plugin system —
|
||||
this function only handles the runtime-availability gate (SDK installed
|
||||
+ credentials present). The result is cached: on the first call we try
|
||||
to construct a client, and every subsequent call returns that client
|
||||
(or fast-returns ``None`` if init failed).
|
||||
"""
|
||||
global _LANGFUSE_CLIENT
|
||||
if _LANGFUSE_CLIENT is _INIT_FAILED:
|
||||
return None
|
||||
if _LANGFUSE_CLIENT is not None:
|
||||
return _LANGFUSE_CLIENT
|
||||
|
||||
if Langfuse is None:
|
||||
_LANGFUSE_CLIENT = _INIT_FAILED
|
||||
return None
|
||||
|
||||
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
|
||||
if not (public_key and secret_key):
|
||||
_LANGFUSE_CLIENT = _INIT_FAILED
|
||||
return None
|
||||
|
||||
base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com"
|
||||
environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV")
|
||||
release = _env("HERMES_LANGFUSE_RELEASE") or _env("LANGFUSE_RELEASE")
|
||||
sample_rate = _env("HERMES_LANGFUSE_SAMPLE_RATE")
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"public_key": public_key,
|
||||
"secret_key": secret_key,
|
||||
"base_url": base_url,
|
||||
}
|
||||
if environment:
|
||||
kwargs["environment"] = environment
|
||||
if release:
|
||||
kwargs["release"] = release
|
||||
if sample_rate:
|
||||
try:
|
||||
kwargs["sample_rate"] = float(sample_rate)
|
||||
except ValueError:
|
||||
logger.warning("Invalid HERMES_LANGFUSE_SAMPLE_RATE=%r", sample_rate)
|
||||
|
||||
try:
|
||||
_LANGFUSE_CLIENT = Langfuse(**kwargs)
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
logger.warning("Could not initialize Langfuse client: %s", exc)
|
||||
_LANGFUSE_CLIENT = _INIT_FAILED
|
||||
return None
|
||||
|
||||
return _LANGFUSE_CLIENT
|
||||
|
||||
|
||||
def _trace_key(task_id: str, session_id: str) -> str:
|
||||
if task_id:
|
||||
return task_id
|
||||
if session_id:
|
||||
return f"session:{session_id}"
|
||||
return f"thread:{threading.get_ident()}"
|
||||
|
||||
|
||||
def _truncate_text(value: str, max_chars: int) -> str:
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
|
||||
|
||||
|
||||
def _maybe_parse_json_string(value: str) -> Any:
|
||||
stripped = value.strip()
|
||||
if len(stripped) < 2 or stripped[0] not in "{[" or stripped[-1] not in "}]":
|
||||
if len(stripped) < 2 or stripped[0] not in "{[":
|
||||
return value
|
||||
try:
|
||||
parsed, idx = json.JSONDecoder().raw_decode(stripped)
|
||||
except Exception:
|
||||
return value
|
||||
if not isinstance(parsed, (dict, list)):
|
||||
return value
|
||||
|
||||
trailing = stripped[idx:].strip()
|
||||
if not trailing:
|
||||
return parsed
|
||||
|
||||
hint_key = "_hint" if trailing.startswith("[Hint:") else "_trailing_text"
|
||||
if isinstance(parsed, dict):
|
||||
merged = dict(parsed)
|
||||
key = hint_key if hint_key not in merged else "_trailing_text"
|
||||
merged[key] = trailing
|
||||
return merged
|
||||
|
||||
return {"data": parsed, hint_key: trailing}
|
||||
|
||||
|
||||
def _looks_like_read_file_payload(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
content = value.get("content")
|
||||
return (
|
||||
isinstance(content, str)
|
||||
and "total_lines" in value
|
||||
and "file_size" in value
|
||||
and "is_binary" in value
|
||||
and "is_image" in value
|
||||
and not value.get("error")
|
||||
)
|
||||
|
||||
|
||||
def _parse_read_file_lines(content: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(content, str) or not content:
|
||||
return []
|
||||
|
||||
lines = []
|
||||
for raw_line in content.splitlines():
|
||||
match = _READ_FILE_LINE_RE.match(raw_line)
|
||||
if not match:
|
||||
return []
|
||||
lines.append({
|
||||
"line": int(match.group(1)),
|
||||
"text": match.group(2),
|
||||
})
|
||||
return lines
|
||||
|
||||
|
||||
def _build_read_file_preview(lines: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if len(lines) <= (_READ_FILE_HEAD_LINES + _READ_FILE_TAIL_LINES):
|
||||
return {"lines": lines}
|
||||
|
||||
return {
|
||||
"head": lines[:_READ_FILE_HEAD_LINES],
|
||||
"tail": lines[-_READ_FILE_TAIL_LINES:],
|
||||
"omitted_line_count": len(lines) - _READ_FILE_HEAD_LINES - _READ_FILE_TAIL_LINES,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_read_file_payload(value: dict[str, Any], *, args: Any = None) -> dict[str, Any]:
|
||||
normalized: dict[str, Any] = {}
|
||||
if isinstance(args, dict):
|
||||
path = args.get("path")
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if isinstance(path, str) and path:
|
||||
normalized["path"] = path
|
||||
if isinstance(offset, int):
|
||||
normalized["offset"] = offset
|
||||
if isinstance(limit, int):
|
||||
normalized["limit"] = limit
|
||||
|
||||
lines = _parse_read_file_lines(value.get("content", ""))
|
||||
if lines:
|
||||
normalized["returned_lines"] = {
|
||||
"start": lines[0]["line"],
|
||||
"end": lines[-1]["line"],
|
||||
"count": len(lines),
|
||||
}
|
||||
normalized["content_preview"] = _build_read_file_preview(lines)
|
||||
elif value.get("content"):
|
||||
normalized["content_preview"] = {
|
||||
"text": value.get("content", ""),
|
||||
}
|
||||
|
||||
for key in (
|
||||
"total_lines",
|
||||
"file_size",
|
||||
"truncated",
|
||||
"is_binary",
|
||||
"is_image",
|
||||
"hint",
|
||||
"_warning",
|
||||
"mime_type",
|
||||
"dimensions",
|
||||
"similar_files",
|
||||
"error",
|
||||
):
|
||||
if key in value:
|
||||
normalized[key] = value[key]
|
||||
|
||||
base64_content = value.get("base64_content")
|
||||
if isinstance(base64_content, str) and base64_content:
|
||||
normalized["base64_content"] = {
|
||||
"omitted": True,
|
||||
"length": len(base64_content),
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_payload(value: Any, *, tool_name: str = "", args: Any = None) -> Any:
|
||||
if _looks_like_read_file_payload(value):
|
||||
return _normalize_read_file_payload(
|
||||
value,
|
||||
args=args if tool_name == "read_file" else None,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _safe_value(value: Any, *, max_chars: Optional[int] = None, depth: int = 0,
|
||||
parse_json_strings: bool = False) -> Any:
|
||||
max_chars = max_chars if max_chars is not None else int(_env("HERMES_LANGFUSE_MAX_CHARS", "12000") or "12000")
|
||||
if depth > 4:
|
||||
return "<max-depth>"
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return {"type": "bytes", "len": len(value)}
|
||||
if isinstance(value, str):
|
||||
if parse_json_strings:
|
||||
parsed = _maybe_parse_json_string(value)
|
||||
if parsed is not value:
|
||||
return _safe_value(parsed, max_chars=max_chars, depth=depth, parse_json_strings=True)
|
||||
return _truncate_text(value, max_chars)
|
||||
if isinstance(value, dict):
|
||||
normalized = _normalize_payload(value)
|
||||
if normalized is not value:
|
||||
return _safe_value(normalized, max_chars=max_chars, depth=depth, parse_json_strings=parse_json_strings)
|
||||
return {
|
||||
str(k): _safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
for k, v in list(value.items())[:50]
|
||||
}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [
|
||||
_safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
for v in list(value)[:50]
|
||||
]
|
||||
if hasattr(value, "__dict__"):
|
||||
return _safe_value(vars(value), max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
return _truncate_text(repr(value), max_chars)
|
||||
|
||||
|
||||
def _extract_last_user_message(messages: Any) -> Any:
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get("role") == "user":
|
||||
return {
|
||||
"role": "user",
|
||||
"content": _safe_value(message.get("content")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_messages(messages: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(messages, list):
|
||||
return []
|
||||
serialized = []
|
||||
for message in messages[-12:]:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
role = message.get("role")
|
||||
item = {
|
||||
"role": role,
|
||||
"content": _safe_value(
|
||||
message.get("content"),
|
||||
parse_json_strings=(role == "tool"),
|
||||
),
|
||||
}
|
||||
if role == "tool" and message.get("tool_call_id"):
|
||||
item["tool_call_id"] = message.get("tool_call_id")
|
||||
if message.get("tool_calls"):
|
||||
item["tool_calls"] = _safe_value(message.get("tool_calls"), parse_json_strings=True)
|
||||
serialized.append(item)
|
||||
return serialized
|
||||
|
||||
|
||||
def _serialize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]:
|
||||
if not tool_calls:
|
||||
return []
|
||||
serialized = []
|
||||
for tool_call in tool_calls:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", None) if fn else None
|
||||
arguments = getattr(fn, "arguments", None) if fn else None
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except Exception:
|
||||
pass
|
||||
serialized.append({
|
||||
"id": getattr(tool_call, "id", None),
|
||||
"name": name,
|
||||
"arguments": _safe_value(arguments, parse_json_strings=True),
|
||||
})
|
||||
return serialized
|
||||
|
||||
|
||||
def _serialize_assistant_message(message: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"content": _safe_value(getattr(message, "content", None)),
|
||||
"reasoning": _safe_value(getattr(message, "reasoning", None)),
|
||||
"tool_calls": _serialize_tool_calls(getattr(message, "tool_calls", None)),
|
||||
}
|
||||
|
||||
|
||||
def _usage_and_cost(response: Any, *, provider: str, api_mode: str, model: str, base_url: str) -> tuple[dict[str, int], dict[str, float]]:
|
||||
usage_details: Dict[str, int] = {}
|
||||
cost_details: Dict[str, float] = {}
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if not raw_usage:
|
||||
return usage_details, cost_details
|
||||
|
||||
try:
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
|
||||
canonical = normalize_usage(raw_usage, provider=provider, api_mode=api_mode)
|
||||
# Langfuse usage_details keys follow a naming convention:
|
||||
# - Dashboard sums all keys containing "input" as input total
|
||||
# - Dashboard sums all keys containing "output" as output total
|
||||
# - If no "total" key, Langfuse derives it from all usage types
|
||||
# Use Anthropic-style key names so cache tokens roll into the
|
||||
# dashboard input total automatically.
|
||||
# Ref: https://langfuse.com/docs/model-usage-and-cost
|
||||
usage_details = {
|
||||
"input": canonical.input_tokens,
|
||||
"output": canonical.output_tokens,
|
||||
}
|
||||
if canonical.cache_read_tokens:
|
||||
usage_details["cache_read_input_tokens"] = canonical.cache_read_tokens
|
||||
if canonical.cache_write_tokens:
|
||||
usage_details["cache_creation_input_tokens"] = canonical.cache_write_tokens
|
||||
if canonical.reasoning_tokens:
|
||||
usage_details["reasoning_tokens"] = canonical.reasoning_tokens
|
||||
cost = estimate_usage_cost(
|
||||
model,
|
||||
canonical,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key="",
|
||||
)
|
||||
if cost.amount_usd is not None:
|
||||
# Langfuse cost_details keys must match usage_details keys.
|
||||
# Provide per-type breakdown so dashboard can show cost by type.
|
||||
try:
|
||||
from agent.usage_pricing import get_pricing_entry
|
||||
from decimal import Decimal
|
||||
_ONE_M = Decimal("1000000")
|
||||
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
|
||||
if entry:
|
||||
if entry.input_cost_per_million is not None and canonical.input_tokens:
|
||||
cost_details["input"] = float(Decimal(canonical.input_tokens) * entry.input_cost_per_million / _ONE_M)
|
||||
if entry.output_cost_per_million is not None and canonical.output_tokens:
|
||||
cost_details["output"] = float(Decimal(canonical.output_tokens) * entry.output_cost_per_million / _ONE_M)
|
||||
if entry.cache_read_cost_per_million is not None and canonical.cache_read_tokens:
|
||||
cost_details["cache_read_input_tokens"] = float(Decimal(canonical.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_M)
|
||||
if entry.cache_write_cost_per_million is not None and canonical.cache_write_tokens:
|
||||
cost_details["cache_creation_input_tokens"] = float(Decimal(canonical.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_M)
|
||||
else:
|
||||
cost_details["total"] = float(cost.amount_usd)
|
||||
except Exception:
|
||||
cost_details["total"] = float(cost.amount_usd)
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"usage normalization failed: {exc}")
|
||||
|
||||
return usage_details, cost_details
|
||||
|
||||
|
||||
def _start_root_trace(task_key: str, *, task_id: str, session_id: str, platform: str, provider: str, model: str,
|
||||
api_mode: str, messages: Any, client: Langfuse) -> TraceState:
|
||||
trace_id = client.create_trace_id(seed=f"{session_id or 'sessionless'}::{task_id or task_key}")
|
||||
trace_input = _extract_last_user_message(messages)
|
||||
metadata = {
|
||||
"source": "hermes",
|
||||
"task_id": task_id,
|
||||
"platform": platform,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_mode": api_mode,
|
||||
}
|
||||
|
||||
# session_id must be passed in trace_context for Langfuse session grouping.
|
||||
trace_ctx: Dict[str, Any] = {"trace_id": trace_id}
|
||||
if session_id:
|
||||
trace_ctx["session_id"] = session_id
|
||||
|
||||
if propagate_attributes is not None:
|
||||
try:
|
||||
with propagate_attributes(
|
||||
session_id=session_id or task_key,
|
||||
trace_name="Hermes turn",
|
||||
tags=["hermes", "langfuse"],
|
||||
):
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
except Exception:
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
else:
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
|
||||
try:
|
||||
root_span.set_trace_io(input=trace_input)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_debug(f"started trace {trace_id} for {task_key}")
|
||||
return TraceState(trace_id=trace_id, root_ctx=root_ctx, root_span=root_span)
|
||||
|
||||
|
||||
def _start_child_observation(state: TraceState, *, client: Langfuse, name: str, as_type: str,
|
||||
input_value: Any, metadata: Optional[dict] = None,
|
||||
model: Optional[str] = None, model_parameters: Optional[dict] = None) -> Any:
|
||||
return state.root_span.start_observation(
|
||||
name=name,
|
||||
as_type=as_type,
|
||||
input=input_value,
|
||||
metadata=metadata or {},
|
||||
model=model,
|
||||
model_parameters=model_parameters,
|
||||
)
|
||||
|
||||
|
||||
def _end_observation(observation: Any, *, output: Any = None, metadata: Optional[dict] = None,
|
||||
usage_details: Optional[dict] = None, cost_details: Optional[dict] = None) -> None:
|
||||
if observation is None:
|
||||
return
|
||||
try:
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
if output is not None:
|
||||
update_kwargs["output"] = output
|
||||
if metadata:
|
||||
update_kwargs["metadata"] = metadata
|
||||
if usage_details:
|
||||
update_kwargs["usage_details"] = usage_details
|
||||
if cost_details:
|
||||
update_kwargs["cost_details"] = cost_details
|
||||
if update_kwargs:
|
||||
observation.update(**update_kwargs)
|
||||
observation.end()
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"end observation failed: {exc}")
|
||||
|
||||
|
||||
def _merge_trace_output(output: Any, state: TraceState) -> Any:
|
||||
if not state.turn_tool_calls:
|
||||
return output
|
||||
|
||||
merged = dict(output) if isinstance(output, dict) else {"content": output}
|
||||
merged["tool_calls"] = list(state.turn_tool_calls)
|
||||
return merged
|
||||
|
||||
|
||||
def _finish_trace(task_key: str, *, output: Any = None) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.pop(task_key, None)
|
||||
if state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
for observation in state.generations.values():
|
||||
_end_observation(observation)
|
||||
for observation in state.tools.values():
|
||||
_end_observation(observation)
|
||||
final_output = _merge_trace_output(output, state)
|
||||
if final_output is not None:
|
||||
state.root_span.set_trace_io(output=final_output)
|
||||
state.root_span.update(output=final_output)
|
||||
state.root_span.end()
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"finish trace failed: {exc}")
|
||||
finally:
|
||||
try:
|
||||
client.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _assistant_has_tool_calls(message: Any) -> bool:
|
||||
return bool(getattr(message, "tool_calls", None))
|
||||
|
||||
|
||||
def _request_key(api_call_count: Any) -> str:
|
||||
return str(api_call_count or 0)
|
||||
|
||||
|
||||
def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str = "", model: str = "",
|
||||
provider: str = "", base_url: str = "", api_mode: str = "",
|
||||
api_call_count: int = 0, messages: Any = None, turn_type: str = "user",
|
||||
conversation_history: Any = None, user_message: Any = None, **_: Any) -> None:
|
||||
# Older Hermes branches used pre_llm_call for request-scoped tracing and
|
||||
# passed the actual API messages. Current Hermes also has a turn-scoped
|
||||
# pre_llm_call used for context injection; tracing that hook creates an
|
||||
# extra orphan/root trace before the real request trace. Only trace the
|
||||
# legacy request-shaped call here.
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
# messages is a list only for legacy Hermes branches that fired
|
||||
# pre_llm_call with API messages directly. Current Hermes fires
|
||||
# pre_llm_call for context injection (conversation_history/user_message,
|
||||
# no messages list) — tracing that would create orphan traces.
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
state = _start_root_trace(
|
||||
task_key,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
platform=platform,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_mode=api_mode,
|
||||
messages=messages,
|
||||
client=client,
|
||||
)
|
||||
_TRACE_STATE[task_key] = state
|
||||
state.last_updated_at = time.time()
|
||||
|
||||
|
||||
def on_pre_llm_request(
|
||||
*,
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
platform: str = "",
|
||||
model: str = "",
|
||||
provider: str = "",
|
||||
base_url: str = "",
|
||||
api_mode: str = "",
|
||||
api_call_count: int = 0,
|
||||
messages: Any = None,
|
||||
turn_type: str = "user",
|
||||
message_count: int = 0,
|
||||
tool_count: int = 0,
|
||||
approx_input_tokens: int = 0,
|
||||
request_char_count: int = 0,
|
||||
max_tokens: Any = None,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
req_key = _request_key(api_call_count)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
state = _start_root_trace(
|
||||
task_key,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
platform=platform,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_mode=api_mode,
|
||||
messages=messages,
|
||||
client=client,
|
||||
)
|
||||
_TRACE_STATE[task_key] = state
|
||||
state.last_updated_at = time.time()
|
||||
previous = state.generations.pop(req_key, None)
|
||||
if previous is not None:
|
||||
_end_observation(previous)
|
||||
state.generations[req_key] = _start_child_observation(
|
||||
state,
|
||||
client=client,
|
||||
name=f"LLM call {api_call_count}",
|
||||
as_type="generation",
|
||||
input_value=_serialize_messages(messages),
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"platform": platform,
|
||||
"api_mode": api_mode,
|
||||
"base_url": base_url,
|
||||
},
|
||||
model=model,
|
||||
model_parameters={"api_mode": api_mode, "provider": provider},
|
||||
)
|
||||
|
||||
|
||||
def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str = "", base_url: str = "",
|
||||
api_mode: str = "", model: str = "", api_call_count: int = 0,
|
||||
assistant_message: Any = None, response: Any = None,
|
||||
api_duration: float = 0.0, finish_reason: str = "",
|
||||
usage: Any = None, assistant_content_chars: int = 0,
|
||||
assistant_tool_call_count: int = 0, assistant_response: Any = None,
|
||||
**_: Any) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
req_key = _request_key(api_call_count)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
generation = state.generations.pop(req_key, None) if state else None
|
||||
if state is None or generation is None:
|
||||
return
|
||||
|
||||
# Handle both call patterns:
|
||||
# 1. post_api_request: passes usage (dict), assistant_content_chars, assistant_tool_call_count
|
||||
# 2. post_llm_call: passes assistant_message (object), response (object), assistant_response (str)
|
||||
if assistant_message is not None:
|
||||
output = _serialize_assistant_message(assistant_message)
|
||||
elif assistant_response is not None:
|
||||
# post_llm_call passes assistant_response as a plain string
|
||||
output = {"content": _safe_value(assistant_response), "reasoning": None, "tool_calls": []}
|
||||
else:
|
||||
# post_api_request path — reconstruct from summary kwargs
|
||||
output = {
|
||||
"content": f"[{assistant_content_chars} chars]" if assistant_content_chars else None,
|
||||
"reasoning": None,
|
||||
"tool_calls": [{"id": f"tc_{i}"} for i in range(assistant_tool_call_count)] if assistant_tool_call_count else [],
|
||||
}
|
||||
|
||||
if output.get("tool_calls"):
|
||||
state.turn_tool_calls.extend(output["tool_calls"])
|
||||
|
||||
# Extract usage: prefer response object, fall back to usage dict from post_api_request
|
||||
if response is not None:
|
||||
usage_details, cost_details = _usage_and_cost(
|
||||
response,
|
||||
provider=provider,
|
||||
api_mode=api_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
)
|
||||
elif isinstance(usage, dict) and usage:
|
||||
# post_api_request passes a pre-built CanonicalUsage summary dict.
|
||||
# Use Langfuse-convention key names: "input", "output", and
|
||||
# "cache_read_input_tokens" / "cache_creation_input_tokens" so the
|
||||
# dashboard sums cache tokens into the input total automatically.
|
||||
_input = usage.get("input_tokens", 0)
|
||||
_output = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
|
||||
_cache_read = usage.get("cache_read_tokens", 0)
|
||||
_cache_write = usage.get("cache_write_tokens", 0)
|
||||
_reasoning = usage.get("reasoning_tokens", 0)
|
||||
usage_details = {
|
||||
"input": _input,
|
||||
"output": _output,
|
||||
}
|
||||
if _cache_read:
|
||||
usage_details["cache_read_input_tokens"] = _cache_read
|
||||
if _cache_write:
|
||||
usage_details["cache_creation_input_tokens"] = _cache_write
|
||||
if _reasoning:
|
||||
usage_details["reasoning_tokens"] = _reasoning
|
||||
cost_details = {}
|
||||
# Estimate per-type cost from the summary if possible
|
||||
try:
|
||||
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost, get_pricing_entry
|
||||
from decimal import Decimal
|
||||
_ONE_M = Decimal("1000000")
|
||||
_cu = CanonicalUsage(
|
||||
input_tokens=_input,
|
||||
output_tokens=_output,
|
||||
cache_read_tokens=_cache_read,
|
||||
cache_write_tokens=_cache_write,
|
||||
reasoning_tokens=_reasoning,
|
||||
)
|
||||
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
|
||||
if entry:
|
||||
if entry.input_cost_per_million is not None and _input:
|
||||
cost_details["input"] = float(Decimal(_input) * entry.input_cost_per_million / _ONE_M)
|
||||
if entry.output_cost_per_million is not None and _output:
|
||||
cost_details["output"] = float(Decimal(_output) * entry.output_cost_per_million / _ONE_M)
|
||||
if entry.cache_read_cost_per_million is not None and _cache_read:
|
||||
cost_details["cache_read_input_tokens"] = float(Decimal(_cache_read) * entry.cache_read_cost_per_million / _ONE_M)
|
||||
if entry.cache_write_cost_per_million is not None and _cache_write:
|
||||
cost_details["cache_creation_input_tokens"] = float(Decimal(_cache_write) * entry.cache_write_cost_per_million / _ONE_M)
|
||||
else:
|
||||
_cost = estimate_usage_cost(model, _cu, provider=provider, base_url=base_url, api_key="")
|
||||
if _cost.amount_usd is not None:
|
||||
cost_details["total"] = float(_cost.amount_usd)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
usage_details, cost_details = {}, {}
|
||||
|
||||
tool_count = len(output.get("tool_calls", [])) or assistant_tool_call_count
|
||||
gen_metadata: Dict[str, Any] = {"tool_call_count": tool_count}
|
||||
if api_duration and api_duration > 0:
|
||||
gen_metadata["api_duration_s"] = round(api_duration, 3)
|
||||
if finish_reason:
|
||||
gen_metadata["finish_reason"] = finish_reason
|
||||
_end_observation(
|
||||
generation,
|
||||
output=output,
|
||||
usage_details=usage_details,
|
||||
cost_details=cost_details,
|
||||
metadata=gen_metadata,
|
||||
)
|
||||
|
||||
has_tools = _assistant_has_tool_calls(assistant_message) if assistant_message else (assistant_tool_call_count > 0)
|
||||
has_content = bool(output.get("content"))
|
||||
if not has_tools and has_content:
|
||||
_finish_trace(task_key, output=output)
|
||||
|
||||
|
||||
def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "",
|
||||
session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
tool_key = tool_call_id or f"{tool_name}:{time.time_ns()}"
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
return
|
||||
state.tools[tool_key] = _start_child_observation(
|
||||
state,
|
||||
client=client,
|
||||
name=f"Tool: {tool_name}",
|
||||
as_type="tool",
|
||||
input_value=_safe_value(args),
|
||||
metadata={"tool_name": tool_name, "tool_call_id": tool_call_id},
|
||||
)
|
||||
|
||||
|
||||
def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None,
|
||||
task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
tool_key = tool_call_id or ""
|
||||
observation = None
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
return
|
||||
if tool_key:
|
||||
observation = state.tools.pop(tool_key, None)
|
||||
elif state.tools:
|
||||
_, observation = state.tools.popitem()
|
||||
|
||||
if observation is None:
|
||||
return
|
||||
|
||||
if isinstance(result, str):
|
||||
result_value = _maybe_parse_json_string(result)
|
||||
else:
|
||||
result_value = result
|
||||
result_value = _normalize_payload(result_value, tool_name=tool_name, args=args)
|
||||
|
||||
_end_observation(
|
||||
observation,
|
||||
output=_safe_value(result_value, parse_json_strings=True),
|
||||
metadata={"tool_name": tool_name, "args": _safe_value(args, parse_json_strings=True)},
|
||||
)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
# Register for both hook name variants so the plugin works across
|
||||
# Hermes versions. pre_api_request / post_api_request fire per API
|
||||
# call (preferred); pre_llm_call / post_llm_call fire once per turn.
|
||||
ctx.register_hook("pre_api_request", on_pre_llm_request)
|
||||
ctx.register_hook("post_api_request", on_post_llm_call)
|
||||
ctx.register_hook("pre_llm_call", on_pre_llm_call)
|
||||
ctx.register_hook("post_llm_call", on_post_llm_call)
|
||||
ctx.register_hook("pre_tool_call", on_pre_tool_call)
|
||||
ctx.register_hook("post_tool_call", on_post_tool_call)
|
||||
@@ -0,0 +1,14 @@
|
||||
name: langfuse
|
||||
version: "1.0.0"
|
||||
description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Opt-in via `hermes plugins enable observability/langfuse` or `hermes tools → Langfuse Observability`."
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- HERMES_LANGFUSE_PUBLIC_KEY
|
||||
- HERMES_LANGFUSE_SECRET_KEY
|
||||
hooks:
|
||||
- pre_api_request
|
||||
- post_api_request
|
||||
- pre_llm_call
|
||||
- post_llm_call
|
||||
- pre_tool_call
|
||||
- post_tool_call
|
||||
+1
-1
@@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py
|
||||
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
|
||||
cron = ["croniter>=6.0.0,<7"]
|
||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<1"]
|
||||
cli = ["simple-term-menu>=1.0,<2"]
|
||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||
voice = [
|
||||
|
||||
@@ -27,6 +27,8 @@ from pathlib import Path
|
||||
import fire
|
||||
import yaml
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL, get_hermes_home
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
_hermes_home = get_hermes_home()
|
||||
@@ -60,8 +62,6 @@ from tools.rl_training_tool import get_missing_keys
|
||||
# Config Loading
|
||||
# ============================================================================
|
||||
|
||||
from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
|
||||
|
||||
DEFAULT_MODEL = "anthropic/claude-opus-4.5"
|
||||
DEFAULT_BASE_URL = OPENROUTER_BASE_URL
|
||||
|
||||
@@ -412,7 +412,7 @@ def main(
|
||||
|
||||
# Run the agent
|
||||
print("\n" + "=" * 60)
|
||||
response = agent.run_conversation(user_input)
|
||||
agent.run_conversation(user_input)
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
@@ -429,7 +429,7 @@ def main(
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = agent.run_conversation(task)
|
||||
agent.run_conversation(task)
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ Task completed")
|
||||
except KeyboardInterrupt:
|
||||
|
||||
+369
-19
@@ -41,13 +41,48 @@ import urllib.request
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
from openai import OpenAI
|
||||
# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the
|
||||
# SDK pulls ~240 ms of imports. We expose `OpenAI` as a thin proxy object
|
||||
# that imports the SDK on first call/isinstance check. This preserves:
|
||||
# (a) the single in-module `OpenAI(**client_kwargs)` call site at
|
||||
# _create_openai_client, and
|
||||
# (b) `patch("run_agent.OpenAI", ...)` test patterns used by ~28 test files.
|
||||
import fire
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
_OPENAI_CLS_CACHE: Optional[type] = None
|
||||
|
||||
|
||||
def _load_openai_cls() -> type:
|
||||
"""Import and cache ``openai.OpenAI``."""
|
||||
global _OPENAI_CLS_CACHE
|
||||
if _OPENAI_CLS_CACHE is None:
|
||||
from openai import OpenAI as _cls
|
||||
_OPENAI_CLS_CACHE = _cls
|
||||
return _OPENAI_CLS_CACHE
|
||||
|
||||
|
||||
class _OpenAIProxy:
|
||||
"""Module-level proxy that looks like ``openai.OpenAI`` but imports lazily."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return _load_openai_cls()(*args, **kwargs)
|
||||
|
||||
def __instancecheck__(self, obj):
|
||||
return isinstance(obj, _load_openai_cls())
|
||||
|
||||
def __repr__(self):
|
||||
return "<lazy openai.OpenAI proxy>"
|
||||
|
||||
|
||||
OpenAI = _OpenAIProxy()
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
@@ -1791,9 +1826,6 @@ class AIAgent:
|
||||
)
|
||||
_config_context_length = None
|
||||
|
||||
# Store for reuse in switch_model (so config override persists across model switches)
|
||||
self._config_context_length = _config_context_length
|
||||
|
||||
# Resolve custom_providers list once for reuse below (startup
|
||||
# context-length override and plugin context-engine init).
|
||||
try:
|
||||
@@ -1852,7 +1884,14 @@ class AIAgent:
|
||||
file=sys.stderr,
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
# Persist for reuse on switch_model / fallback activation. Must come
|
||||
# AFTER the custom_providers branch so per-model overrides aren't lost.
|
||||
self._config_context_length = _config_context_length
|
||||
|
||||
self._ensure_lmstudio_runtime_loaded(_config_context_length)
|
||||
|
||||
|
||||
# Select context engine: config-driven (like memory providers).
|
||||
# 1. Check config.yaml context.engine setting
|
||||
# 2. Check plugins/context_engine/<name>/ directory (repo-shipped)
|
||||
@@ -2094,6 +2133,39 @@ class AIAgent:
|
||||
if hasattr(self, "context_compressor") and self.context_compressor:
|
||||
self.context_compressor.on_session_reset()
|
||||
|
||||
def _ensure_lmstudio_runtime_loaded(self, config_context_length: Optional[int] = None) -> None:
|
||||
"""
|
||||
Preload the LM Studio model with at least Hermes' minimum context.
|
||||
"""
|
||||
if (self.provider or "").strip().lower() != "lmstudio":
|
||||
return
|
||||
try:
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
from hermes_cli.models import ensure_lmstudio_model_loaded
|
||||
if config_context_length is None:
|
||||
config_context_length = getattr(self, "_config_context_length", None)
|
||||
target_ctx = max(config_context_length or 0, MINIMUM_CONTEXT_LENGTH)
|
||||
loaded_ctx = ensure_lmstudio_model_loaded(
|
||||
self.model, self.base_url, getattr(self, "api_key", ""), target_ctx,
|
||||
)
|
||||
if loaded_ctx:
|
||||
# Push into the live compressor so the status bar reflects the
|
||||
# real loaded ctx the moment the load resolves, instead of
|
||||
# holding the previous model's value (or "ctx --") through the
|
||||
# next render tick.
|
||||
cc = getattr(self, "context_compressor", None)
|
||||
if cc is not None:
|
||||
cc.update_model(
|
||||
model=self.model,
|
||||
context_length=loaded_ctx,
|
||||
base_url=self.base_url,
|
||||
api_key=getattr(self, "api_key", ""),
|
||||
provider=self.provider,
|
||||
api_mode=self.api_mode,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug("LM Studio preload skipped: %s", err)
|
||||
|
||||
def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mode=''):
|
||||
"""Switch the model/provider in-place for a live agent.
|
||||
|
||||
@@ -2189,6 +2261,9 @@ class AIAgent:
|
||||
)
|
||||
)
|
||||
|
||||
# ── LM Studio: preload before probing context length ──
|
||||
self._ensure_lmstudio_runtime_loaded()
|
||||
|
||||
# ── Update context compressor ──
|
||||
if hasattr(self, "context_compressor") and self.context_compressor:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
@@ -2720,7 +2795,6 @@ class AIAgent:
|
||||
eff_api_mode = api_mode if api_mode is not None else (self.api_mode or "")
|
||||
eff_model = (model if model is not None else self.model) or ""
|
||||
|
||||
base_lower = eff_base_url.lower()
|
||||
model_lower = eff_model.lower()
|
||||
provider_lower = eff_provider.lower()
|
||||
is_claude = "claude" in model_lower
|
||||
@@ -4793,6 +4867,145 @@ class AIAgent:
|
||||
)
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _is_thinking_only_assistant(msg: Dict[str, Any]) -> bool:
|
||||
"""Return True if ``msg`` is an assistant turn whose only payload is reasoning.
|
||||
|
||||
"Thinking-only" means the model emitted reasoning (``reasoning`` or
|
||||
``reasoning_content``) but no visible text and no tool_calls. When sent
|
||||
back to providers that convert reasoning into thinking blocks (native
|
||||
Anthropic, OpenRouter Anthropic, third-party Anthropic-compatible
|
||||
gateways), the resulting message has only thinking blocks — which
|
||||
Anthropic rejects with HTTP 400 "The final block in an assistant
|
||||
message cannot be `thinking`."
|
||||
|
||||
Symmetric with Claude Code's ``filterOrphanedThinkingOnlyMessages``
|
||||
(src/utils/messages.ts). We drop the whole turn from the API copy
|
||||
rather than fabricating stub text — the message log (UI transcript)
|
||||
keeps the reasoning block; only the wire copy is cleaned.
|
||||
"""
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
return False
|
||||
if msg.get("tool_calls"):
|
||||
return False
|
||||
# Does it have any actual output?
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
return False
|
||||
elif isinstance(content, list):
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
if block: # non-empty non-dict string etc.
|
||||
return False
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype in ("thinking", "redacted_thinking"):
|
||||
continue
|
||||
if btype == "text":
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return False
|
||||
continue
|
||||
# tool_use, image, document, etc. — real payload
|
||||
return False
|
||||
elif content is not None and content != "":
|
||||
return False
|
||||
# Content is empty-ish. Is there reasoning to make it thinking-only?
|
||||
reasoning = msg.get("reasoning_content") or msg.get("reasoning")
|
||||
if isinstance(reasoning, str) and reasoning.strip():
|
||||
return True
|
||||
# reasoning_details list form
|
||||
rd = msg.get("reasoning_details")
|
||||
if isinstance(rd, list) and rd:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
Runs on the per-call ``api_messages`` copy only. The stored
|
||||
conversation history (``self.messages``) is never mutated, so the
|
||||
user still sees the thinking block in the CLI/gateway transcript and
|
||||
session persistence keeps the full trace. Only the wire copy sent to
|
||||
the provider is cleaned.
|
||||
|
||||
Why drop-and-merge rather than inject stub text:
|
||||
- Fabricating ``"."`` / ``"(continued)"`` text lies in the history
|
||||
and makes future turns see model output the model didn't emit.
|
||||
- Dropping the turn preserves honesty; merging adjacent user messages
|
||||
preserves the provider's role-alternation invariant.
|
||||
- This is the pattern used by Claude Code's ``normalizeMessagesForAPI``
|
||||
(filterOrphanedThinkingOnlyMessages + mergeAdjacentUserMessages).
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [m for m in messages if not AIAgent._is_thinking_only_assistant(m)]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
|
||||
# Pass 2: merge any newly-adjacent user messages.
|
||||
merged: List[Dict[str, Any]] = []
|
||||
merges = 0
|
||||
for m in kept:
|
||||
prev = merged[-1] if merged else None
|
||||
if (
|
||||
prev is not None
|
||||
and prev.get("role") == "user"
|
||||
and m.get("role") == "user"
|
||||
):
|
||||
prev_content = prev.get("content", "")
|
||||
cur_content = m.get("content", "")
|
||||
# Work on a copy of ``prev`` so the caller's input dicts are
|
||||
# never mutated. ``_sanitize_api_messages`` upstream already
|
||||
# hands us per-call copies, but staying pure here means we
|
||||
# can be called safely from anywhere (tests, other loops).
|
||||
prev_copy = dict(prev)
|
||||
# Only string-content merge is meaningful for role-alternation
|
||||
# purposes. If either side is a list (multimodal), append as a
|
||||
# separate block rather than collapsing.
|
||||
if isinstance(prev_content, str) and isinstance(cur_content, str):
|
||||
sep = "\n\n" if prev_content and cur_content else ""
|
||||
prev_copy["content"] = prev_content + sep + cur_content
|
||||
elif isinstance(prev_content, list) and isinstance(cur_content, list):
|
||||
prev_copy["content"] = list(prev_content) + list(cur_content)
|
||||
elif isinstance(prev_content, list) and isinstance(cur_content, str):
|
||||
if cur_content:
|
||||
prev_copy["content"] = list(prev_content) + [
|
||||
{"type": "text", "text": cur_content}
|
||||
]
|
||||
else:
|
||||
prev_copy["content"] = list(prev_content)
|
||||
elif isinstance(prev_content, str) and isinstance(cur_content, list):
|
||||
new_blocks: List[Dict[str, Any]] = []
|
||||
if prev_content:
|
||||
new_blocks.append({"type": "text", "text": prev_content})
|
||||
new_blocks.extend(cur_content)
|
||||
prev_copy["content"] = new_blocks
|
||||
else:
|
||||
# Unknown content shape — fall back to appending separately
|
||||
# (violates alternation, but safer than raising in a hot path).
|
||||
merged.append(m)
|
||||
continue
|
||||
merged[-1] = prev_copy
|
||||
merges += 1
|
||||
else:
|
||||
merged.append(m)
|
||||
|
||||
logger.debug(
|
||||
"Pre-call sanitizer: dropped %d thinking-only assistant turn(s), "
|
||||
"merged %d adjacent user message(s)",
|
||||
dropped,
|
||||
merges,
|
||||
)
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _cap_delegate_task_calls(tool_calls: list) -> list:
|
||||
"""Truncate excess delegate_task calls to max_concurrent_children.
|
||||
@@ -5105,6 +5318,8 @@ class AIAgent:
|
||||
keepalive_http = self._build_keepalive_http_client(client_kwargs.get("base_url", ""))
|
||||
if keepalive_http is not None:
|
||||
client_kwargs["http_client"] = keepalive_http
|
||||
# Uses the module-level `OpenAI` name, resolved lazily on first
|
||||
# access via __getattr__ below. Tests patch via `run_agent.OpenAI`.
|
||||
client = OpenAI(**client_kwargs)
|
||||
logger.info(
|
||||
"OpenAI client created (%s, shared=%s) %s",
|
||||
@@ -7152,6 +7367,9 @@ class AIAgent:
|
||||
)
|
||||
)
|
||||
|
||||
# LM Studio: preload before probing the fallback's context length.
|
||||
self._ensure_lmstudio_runtime_loaded()
|
||||
|
||||
# Update context compressor limits for the fallback model.
|
||||
# Without this, compression decisions use the primary model's
|
||||
# context window (e.g. 200K) instead of the fallback's (e.g. 32K),
|
||||
@@ -7871,6 +8089,8 @@ class AIAgent:
|
||||
or base_url_host_matches(self.base_url, "moonshot.ai")
|
||||
or base_url_host_matches(self.base_url, "moonshot.cn")
|
||||
)
|
||||
_is_tokenhub = base_url_host_matches(self._base_url_lower, "tokenhub.tencentmaas.com")
|
||||
_is_lmstudio = (self.provider or "").strip().lower() == "lmstudio"
|
||||
|
||||
# Temperature: _fixed_temperature_for_model may return OMIT_TEMPERATURE
|
||||
# sentinel (temperature omitted entirely), a numeric override, or None.
|
||||
@@ -7942,6 +8162,8 @@ class AIAgent:
|
||||
is_github_models=_is_gh,
|
||||
is_nvidia_nim=_is_nvidia,
|
||||
is_kimi=_is_kimi,
|
||||
is_tokenhub=_is_tokenhub,
|
||||
is_lmstudio=_is_lmstudio,
|
||||
is_custom_provider=self.provider == "custom",
|
||||
ollama_num_ctx=self._ollama_num_ctx,
|
||||
provider_preferences=_prefs or None,
|
||||
@@ -7952,7 +8174,9 @@ class AIAgent:
|
||||
omit_temperature=_omit_temp,
|
||||
supports_reasoning=self._supports_reasoning_extra_body(),
|
||||
github_reasoning_extra=self._github_models_reasoning_extra_body() if _is_gh else None,
|
||||
lmstudio_reasoning_options=self._lmstudio_reasoning_options_cached() if _is_lmstudio else None,
|
||||
anthropic_max_output=_ant_max,
|
||||
provider_name=self.provider,
|
||||
)
|
||||
|
||||
def _supports_reasoning_extra_body(self) -> bool:
|
||||
@@ -7976,6 +8200,10 @@ class AIAgent:
|
||||
return bool(github_model_reasoning_efforts(self.model))
|
||||
except Exception:
|
||||
return False
|
||||
if (self.provider or "").strip().lower() == "lmstudio":
|
||||
opts = self._lmstudio_reasoning_options_cached()
|
||||
# "off-only" (or absent) means no real reasoning capability.
|
||||
return any(opt and opt != "off" for opt in opts)
|
||||
if "openrouter" not in self._base_url_lower:
|
||||
return False
|
||||
if "api.mistral.ai" in self._base_url_lower:
|
||||
@@ -7989,9 +8217,57 @@ class AIAgent:
|
||||
"x-ai/",
|
||||
"google/gemini-2",
|
||||
"qwen/qwen3",
|
||||
"tencent/hy3-preview",
|
||||
)
|
||||
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
|
||||
|
||||
def _lmstudio_reasoning_options_cached(self) -> list[str]:
|
||||
"""Probe LM Studio's published reasoning ``allowed_options`` once per
|
||||
(model, base_url). The list (e.g. ``["off","on"]`` or
|
||||
``["off","minimal","low"]``) is needed both for the supports-reasoning
|
||||
gate and for clamping the emitted ``reasoning_effort`` so toggle-style
|
||||
models don't 400 on ``high``. Cache is keyed on (model, base_url) so
|
||||
``/model`` swaps and base-URL changes don't reuse a stale list.
|
||||
Non-empty results are cached permanently (model capabilities don't
|
||||
change). Empty results (transient probe failure OR genuinely
|
||||
non-reasoning model) are cached with a 60-second TTL to avoid an
|
||||
HTTP round-trip on every turn while still retrying reasonably soon.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
cache = getattr(self, "_lm_reasoning_opts_cache", None)
|
||||
if cache is None:
|
||||
cache = self._lm_reasoning_opts_cache = {}
|
||||
key = (self.model, self.base_url)
|
||||
cached = cache.get(key)
|
||||
if cached is not None:
|
||||
opts, ts = cached
|
||||
# Non-empty → permanent. Empty → 60s TTL.
|
||||
if opts or (_time.monotonic() - ts) < 60:
|
||||
return opts
|
||||
try:
|
||||
from hermes_cli.models import lmstudio_model_reasoning_options
|
||||
opts = lmstudio_model_reasoning_options(
|
||||
self.model, self.base_url, getattr(self, "api_key", ""),
|
||||
)
|
||||
except Exception:
|
||||
opts = []
|
||||
cache[key] = (opts, _time.monotonic())
|
||||
return opts
|
||||
|
||||
def _resolve_lmstudio_summary_reasoning_effort(self) -> Optional[str]:
|
||||
"""Resolve a safe top-level ``reasoning_effort`` for LM Studio.
|
||||
|
||||
The iteration-limit summary path calls ``chat.completions.create()``
|
||||
directly, bypassing the transport. Share the helper so the two paths
|
||||
can't drift on effort resolution and clamping.
|
||||
"""
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
return resolve_lmstudio_effort(
|
||||
self.reasoning_config,
|
||||
self._lmstudio_reasoning_options_cached(),
|
||||
)
|
||||
|
||||
def _github_models_reasoning_extra_body(self) -> dict | None:
|
||||
"""Format reasoning payload for GitHub Models/OpenAI-compatible routes."""
|
||||
try:
|
||||
@@ -8100,6 +8376,31 @@ class AIAgent:
|
||||
# as a defensive compatibility fallback (refs #15250).
|
||||
msg["reasoning_content"] = ""
|
||||
|
||||
# Additive fallback (refs #16844, #16884). Streaming-only providers
|
||||
# (glm, MiniMax, gpt-5.x via aigw, Anthropic via openai-compat shims)
|
||||
# accumulate reasoning through ``delta.reasoning_content`` chunks
|
||||
# but never land it on the message object as a top-level attribute,
|
||||
# so neither branch above fires and the chain-of-thought is stored
|
||||
# only under the internal ``reasoning`` key. When the user later
|
||||
# replays that history through a DeepSeek-v4 / Kimi thinking model,
|
||||
# the missing ``reasoning_content`` causes HTTP 400 ("The
|
||||
# reasoning_content in the thinking mode must be passed back to the
|
||||
# API.").
|
||||
#
|
||||
# Promote the already-sanitized streamed ``reasoning_text`` to
|
||||
# ``reasoning_content`` at write time, but ONLY when no prior branch
|
||||
# already set it AND we actually captured reasoning text. This
|
||||
# preserves every existing behavior:
|
||||
# - SDK-exposed ``reasoning_content`` (OpenAI/Moonshot/DeepSeek SDK)
|
||||
# still wins.
|
||||
# - DeepSeek tool-call ""-pad (#15250) still fires.
|
||||
# - Non-thinking turns with no reasoning leave the field absent,
|
||||
# so ``_copy_reasoning_content_for_api``'s cross-provider leak
|
||||
# guard (#15748) and ``reasoning``→``reasoning_content``
|
||||
# promotion tiers still apply at replay time.
|
||||
if "reasoning_content" not in msg and reasoning_text:
|
||||
msg["reasoning_content"] = reasoning_text
|
||||
|
||||
if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
|
||||
# Pass reasoning_details back unmodified so providers (OpenRouter,
|
||||
# Anthropic, OpenAI) can maintain reasoning continuity across turns.
|
||||
@@ -8460,6 +8761,23 @@ class AIAgent:
|
||||
f"⚠ Compression summary failed: {summary_error}. "
|
||||
"Inserted a fallback context marker."
|
||||
)
|
||||
else:
|
||||
# No hard failure — but did the configured aux model error out
|
||||
# and get recovered by retrying on main? Surface that so users
|
||||
# know their auxiliary.compression.model setting is broken even
|
||||
# though compression succeeded.
|
||||
_aux_fail_model = getattr(self.context_compressor, "_last_aux_model_failure_model", None)
|
||||
_aux_fail_err = getattr(self.context_compressor, "_last_aux_model_failure_error", None)
|
||||
if _aux_fail_model:
|
||||
# Dedup on (model, error) so we don't spam on every compaction
|
||||
_aux_key = (_aux_fail_model, _aux_fail_err)
|
||||
if getattr(self, "_last_aux_fallback_warning_key", None) != _aux_key:
|
||||
self._last_aux_fallback_warning_key = _aux_key
|
||||
self._emit_warning(
|
||||
f"ℹ Configured compression model '{_aux_fail_model}' failed "
|
||||
f"({_aux_fail_err or 'unknown error'}). Recovered using main model — "
|
||||
"check auxiliary.compression.model in config.yaml."
|
||||
)
|
||||
|
||||
todo_snapshot = self._todo_store.format_for_injection()
|
||||
if todo_snapshot:
|
||||
@@ -9453,6 +9771,10 @@ class AIAgent:
|
||||
for idx, pfm in enumerate(self.prefill_messages):
|
||||
api_messages.insert(sys_offset + idx, pfm.copy())
|
||||
|
||||
# Same safety net as the main loop: drop thinking-only assistant
|
||||
# turns so Anthropic-family providers don't 400 the summary call.
|
||||
api_messages = self._drop_thinking_only_and_merge_users(api_messages)
|
||||
|
||||
summary_extra_body = {}
|
||||
try:
|
||||
from agent.auxiliary_client import _fixed_temperature_for_model, OMIT_TEMPERATURE as _OMIT_TEMP
|
||||
@@ -9467,7 +9789,19 @@ class AIAgent:
|
||||
_omit_summary_temperature = _raw_summary_temp is _OMIT_TEMP
|
||||
_summary_temperature = None if _omit_summary_temperature else _raw_summary_temp
|
||||
_is_nous = "nousresearch" in self._base_url_lower
|
||||
if self._supports_reasoning_extra_body():
|
||||
# LM Studio uses top-level `reasoning_effort` (not extra_body.reasoning).
|
||||
# Mirror ChatCompletionsTransport.build_kwargs() so the summary path
|
||||
# — which calls chat.completions.create() directly without going
|
||||
# through the transport — sends the same shape the transport does.
|
||||
_is_lmstudio_summary = (
|
||||
(self.provider or "").strip().lower() == "lmstudio"
|
||||
and self._supports_reasoning_extra_body()
|
||||
)
|
||||
_lm_reasoning_effort: str | None = (
|
||||
self._resolve_lmstudio_summary_reasoning_effort()
|
||||
if _is_lmstudio_summary else None
|
||||
)
|
||||
if not _is_lmstudio_summary and self._supports_reasoning_extra_body():
|
||||
if self.reasoning_config is not None:
|
||||
summary_extra_body["reasoning"] = self.reasoning_config
|
||||
else:
|
||||
@@ -9494,6 +9828,8 @@ class AIAgent:
|
||||
summary_kwargs["temperature"] = _summary_temperature
|
||||
if self.max_tokens is not None:
|
||||
summary_kwargs.update(self._max_tokens_param(self.max_tokens))
|
||||
if _lm_reasoning_effort is not None:
|
||||
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
|
||||
|
||||
# Include provider routing preferences
|
||||
provider_preferences = {}
|
||||
@@ -9518,7 +9854,7 @@ class AIAgent:
|
||||
is_oauth=self._is_anthropic_oauth,
|
||||
preserve_dots=self._anthropic_preserve_dots())
|
||||
summary_response = self._anthropic_messages_create(_ant_kw)
|
||||
_summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
_summary_result = _tsum.normalize_response(summary_response)
|
||||
final_response = (_summary_result.content or "").strip()
|
||||
else:
|
||||
summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs)
|
||||
@@ -9548,7 +9884,7 @@ class AIAgent:
|
||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config,
|
||||
preserve_dots=self._anthropic_preserve_dots())
|
||||
retry_response = self._anthropic_messages_create(_ant_kw2)
|
||||
_retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
_retry_result = _tretry.normalize_response(retry_response)
|
||||
final_response = (_retry_result.content or "").strip()
|
||||
else:
|
||||
summary_kwargs = {
|
||||
@@ -9559,6 +9895,8 @@ class AIAgent:
|
||||
summary_kwargs["temperature"] = _summary_temperature
|
||||
if self.max_tokens is not None:
|
||||
summary_kwargs.update(self._max_tokens_param(self.max_tokens))
|
||||
if _lm_reasoning_effort is not None:
|
||||
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
|
||||
if summary_extra_body:
|
||||
summary_kwargs["extra_body"] = summary_extra_body
|
||||
|
||||
@@ -10163,6 +10501,16 @@ class AIAgent:
|
||||
# manual message manipulation are always caught.
|
||||
api_messages = self._sanitize_api_messages(api_messages)
|
||||
|
||||
# Drop thinking-only assistant turns (reasoning but no visible
|
||||
# output and no tool_calls) and merge any adjacent user messages
|
||||
# left behind. Prevents Anthropic 400s ("The final block in an
|
||||
# assistant message cannot be `thinking`.") and equivalent errors
|
||||
# from third-party Anthropic-compatible gateways that can't replay
|
||||
# a thinking-only turn. Runs on the per-call copy only — the
|
||||
# stored conversation history keeps the reasoning block for the
|
||||
# UI transcript and session persistence.
|
||||
api_messages = self._drop_thinking_only_and_merge_users(api_messages)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
# which enables KV cache reuse on local inference servers
|
||||
@@ -10358,6 +10706,16 @@ class AIAgent:
|
||||
# session instead of re-failing every retry.
|
||||
if getattr(self, "_disable_streaming", False):
|
||||
_use_streaming = False
|
||||
# CopilotACPClient communicates via subprocess stdio and
|
||||
# returns a plain SimpleNamespace — not an iterable
|
||||
# stream. Mirror the ACP exclusion used for Responses
|
||||
# API upgrade (lines ~1083-1085).
|
||||
elif (
|
||||
self.provider == "copilot-acp"
|
||||
or str(self.base_url or "").lower().startswith("acp://copilot")
|
||||
or str(self.base_url or "").lower().startswith("acp+tcp://")
|
||||
):
|
||||
_use_streaming = False
|
||||
elif not self._has_stream_consumers():
|
||||
# No display/TTS consumer. Still prefer streaming for
|
||||
# health checking, but skip for Mock clients in tests
|
||||
@@ -10654,12 +11012,7 @@ class AIAgent:
|
||||
# would have been appended in the non-truncated path.
|
||||
_trunc_msg = None
|
||||
_trunc_transport = self._get_transport()
|
||||
if self.api_mode == "anthropic_messages":
|
||||
_trunc_result = _trunc_transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_anthropic_oauth
|
||||
)
|
||||
else:
|
||||
_trunc_result = _trunc_transport.normalize_response(response)
|
||||
_trunc_result = _trunc_transport.normalize_response(response)
|
||||
_trunc_msg = _trunc_result
|
||||
|
||||
_trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None
|
||||
@@ -11997,10 +12350,7 @@ class AIAgent:
|
||||
|
||||
try:
|
||||
_transport = self._get_transport()
|
||||
_normalize_kwargs = {}
|
||||
if self.api_mode == "anthropic_messages":
|
||||
_normalize_kwargs["strip_tool_prefix"] = self._is_anthropic_oauth
|
||||
normalized = _transport.normalize_response(response, **_normalize_kwargs)
|
||||
normalized = _transport.normalize_response(response)
|
||||
assistant_message = normalized
|
||||
finish_reason = normalized.finish_reason
|
||||
|
||||
|
||||
+14
-3
@@ -729,9 +729,12 @@ install_system_packages() {
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
elif [ -e /dev/tty ]; then
|
||||
elif (: </dev/tty) 2>/dev/null; then
|
||||
# Non-interactive (e.g. curl | bash) but a terminal is available.
|
||||
# Read the prompt from /dev/tty (same approach the setup wizard uses).
|
||||
# Probe by actually opening /dev/tty: a bare existence test passes
|
||||
# in Docker builds where the device node is in the mount namespace
|
||||
# but opening fails with ENXIO. See #16746.
|
||||
echo ""
|
||||
log_info "sudo is needed ONLY to install optional system packages (${pkgs[*]}) via your package manager."
|
||||
log_info "Hermes Agent itself does not require or retain root access."
|
||||
@@ -1330,7 +1333,12 @@ run_setup_wizard() {
|
||||
# The setup wizard reads from /dev/tty, so it works even when the
|
||||
# install script itself is piped (curl | bash). Only skip if no
|
||||
# terminal is available at all (e.g. Docker build, CI).
|
||||
if ! [ -e /dev/tty ]; then
|
||||
#
|
||||
# Probe by actually opening /dev/tty: a bare existence test passes
|
||||
# in Docker builds where the device node is in the mount namespace
|
||||
# but opening fails with ENXIO, so the wizard would proceed and
|
||||
# then crash on `< /dev/tty` below.
|
||||
if ! (: </dev/tty) 2>/dev/null; then
|
||||
log_info "Setup wizard skipped (no terminal available). Run 'hermes setup' after install."
|
||||
return 0
|
||||
fi
|
||||
@@ -1392,7 +1400,10 @@ maybe_start_gateway() {
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [ -e /dev/tty ]; then
|
||||
# Probe by actually opening /dev/tty: a bare existence test passes
|
||||
# in Docker builds where the device node is in the mount namespace
|
||||
# but opening fails with ENXIO. See #16746.
|
||||
if ! (: </dev/tty) 2>/dev/null; then
|
||||
log_info "Gateway setup skipped (no terminal available). Run 'hermes gateway install' later."
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -41,17 +41,35 @@ PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
|
||||
AUTHOR_MAP = {
|
||||
# teknium (multiple emails)
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"qiyin.zuo@pcitc.com": "qiyin-code",
|
||||
"teknium@nousresearch.com": "teknium1",
|
||||
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
||||
"revar@users.noreply.github.com": "revaraver",
|
||||
# Matrix parity salvage batch (April 2026)
|
||||
"sr@samirusani": "samrusani",
|
||||
"angelclaw@AngelMacBook.local": "angel12",
|
||||
"charles@cryptoassetrecovery.com": "charles-brooks",
|
||||
"heathley@Heathley-MacBook-Air.local": "heathley",
|
||||
"adamrummer@gmail.com": "cyclingwithelephants",
|
||||
"nbot@liizfq.top": "liizfq",
|
||||
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
|
||||
"dejie.guo@gmail.com": "JayGwod",
|
||||
"johnnncenaaa77@gmail.com": "johnncenae",
|
||||
"thomasjhon6666@gmail.com": "ThomassJonax",
|
||||
"focusflow.app.help@gmail.com": "yes999zc",
|
||||
"yes999zc@163.com": "yes999zc",
|
||||
"343873859@qq.com": "DrStrangerUJN",
|
||||
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
|
||||
"beliefanx@gmail.com": "BeliefanX",
|
||||
"jefferson@heimdallstrategy.com": "Mind-Dragon",
|
||||
"steve.westerhouse@origami-analytics.com": "westers",
|
||||
"130918800+devorun@users.noreply.github.com": "devorun",
|
||||
"surat.s@itm.kmutnb.ac.th": "beesrsj2500",
|
||||
"beesr@bee.localdomain": "beesrsj2500",
|
||||
"mtf201013@gmail.com": "ma-pony",
|
||||
"sonoyuncudmr@gmail.com": "Sonoyunchu",
|
||||
"maks.mir@yahoo.com": "say8hi",
|
||||
"27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky",
|
||||
"web3blind@users.noreply.github.com": "web3blind",
|
||||
"julia@alexland.us": "alexg0bot",
|
||||
"christian@scheid.tech": "scheidti",
|
||||
@@ -60,6 +78,7 @@ AUTHOR_MAP = {
|
||||
"itonov@proton.me": "Ito-69",
|
||||
"glesstech@gmail.com": "georgeglessner",
|
||||
"maxim.smetanin@gmail.com": "maxims-oss",
|
||||
"nazirulhafiy@gmail.com": "nazirulhafiy",
|
||||
"CREWorx@users.noreply.github.com": "BadTechBandit",
|
||||
"yoimexex@gmail.com": "Yoimex",
|
||||
"6548898+romanornr@users.noreply.github.com": "romanornr",
|
||||
@@ -118,6 +137,7 @@ AUTHOR_MAP = {
|
||||
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
|
||||
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
|
||||
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
|
||||
"270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai",
|
||||
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
|
||||
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
|
||||
"27917469+nosleepcassette@users.noreply.github.com": "nosleepcassette",
|
||||
@@ -540,6 +560,7 @@ AUTHOR_MAP = {
|
||||
"topcheer@me.com": "topcheer",
|
||||
"walli@tencent.com": "walli",
|
||||
"zhuofengwang@tencent.com": "Zhuofeng-Wang",
|
||||
"simonweng@tencent.com": "Contentment003111",
|
||||
# April 2026 salvage-PR batch (#14920, #14986, #14966)
|
||||
"mrunmayeerane17@gmail.com": "mrunmayee17",
|
||||
"69489633+camaragon@users.noreply.github.com": "camaragon",
|
||||
@@ -555,6 +576,7 @@ AUTHOR_MAP = {
|
||||
"screenmachine@gmail.com": "teknium1",
|
||||
"chenzeshi@live.com": "chen1749144759",
|
||||
"mor.aleksandr@yahoo.com": "MorAlekss",
|
||||
"276649498+ztexydt-cqh@users.noreply.github.com": "ztexydt-cqh",
|
||||
"ash@users.noreply.github.com": "ash",
|
||||
"andrewho.sf@gmail.com": "andrewhosf",
|
||||
# April 2026 Honcho bug-fix consolidation (#15381)
|
||||
@@ -563,6 +585,12 @@ AUTHOR_MAP = {
|
||||
"dontcallmejames@users.noreply.github.com": "dontcallmejames",
|
||||
"hekaru.agent@gmail.com": "hekaru-agent",
|
||||
"jas9000@gmail.com": "twozle",
|
||||
"r.filgueiras@apheris.com": "rfilgueiras",
|
||||
"leihaibo1992@gmail.com": "Leihb",
|
||||
# ACP streaming fix salvage (PR #9428 + #16273)
|
||||
"nfb0408@163.com": "ningfangbin",
|
||||
"164839249+Joseph19820124@users.noreply.github.com": "Joseph19820124",
|
||||
"rugved@lmstudio.ai": "rugvedS07",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -408,17 +408,17 @@ Common "why is Hermes doing X to my output / tool calls / commands?" toggles —
|
||||
|
||||
### Secret redaction in tool output
|
||||
|
||||
Hermes auto-redacts strings that look like API keys, tokens, and secrets in all tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) so the model never sees raw credentials. If the user is intentionally working with mock tokens, share-management tokens, or their own secrets and the redaction is getting in the way:
|
||||
Secret redaction is **off by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) passes through unmodified. If the user wants Hermes to auto-mask strings that look like API keys, tokens, and secrets before they enter the conversation context and logs:
|
||||
|
||||
```bash
|
||||
hermes config set security.redact_secrets false # disable globally
|
||||
hermes config set security.redact_secrets true # enable globally
|
||||
```
|
||||
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — setting it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets false` in a terminal, then start a new session. This is deliberate — it prevents an LLM from turning off redaction on itself mid-task.
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=true` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets true` in a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
|
||||
Re-enable with:
|
||||
Disable again with:
|
||||
```bash
|
||||
hermes config set security.redact_secrets true
|
||||
hermes config set security.redact_secrets false
|
||||
```
|
||||
|
||||
### PII redaction in gateway messages
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Siqi Chen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,577 @@
|
||||
---
|
||||
name: humanizer
|
||||
description: "Humanize text: strip AI-isms and add real voice."
|
||||
version: 2.5.1
|
||||
author: Siqi Chen (@blader, https://github.com/blader/humanizer), ported by Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [writing, editing, humanize, anti-ai-slop, voice, prose, text]
|
||||
category: creative
|
||||
homepage: https://github.com/blader/humanizer
|
||||
related_skills: [songwriting-and-ai-music]
|
||||
---
|
||||
|
||||
# Humanizer: Remove AI Writing Patterns
|
||||
|
||||
Identify and remove signs of AI-generated text to make writing sound natural and human. Based on Wikipedia's "Signs of AI writing" guide (maintained by WikiProject AI Cleanup), derived from observations of thousands of AI-generated text instances.
|
||||
|
||||
**Key insight:** LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely completion, which is how the telltale patterns below get baked in.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Load this skill whenever the user asks to:
|
||||
- "humanize", "de-AI", "de-slop", or "un-ChatGPT" a piece of text
|
||||
- rewrite something so it doesn't sound like it was written by an LLM
|
||||
- edit a draft (blog post, essay, PR description, docs, memo, email, tweet, resume bullet) to sound more natural
|
||||
- match their voice in writing they're producing
|
||||
- review text for AI tells before publishing
|
||||
|
||||
Also apply this skill to **your own** output when writing user-facing prose — release notes, PR descriptions, documentation, long-form explanations, summaries. Hermes's baseline voice already strips most of these, but a focused pass catches what slips through.
|
||||
|
||||
## How to use it in Hermes
|
||||
|
||||
The text usually arrives one of three ways:
|
||||
1. **Inline** — user pastes the text directly into the message. Work on it in-place, reply with the rewrite.
|
||||
2. **File** — user points at a file. Use `read_file` to load it, then `patch` or `write_file` to apply edits. For markdown docs in a repo, a targeted `patch` per section is cleaner than rewriting the whole file.
|
||||
3. **Voice calibration sample** — user provides an additional sample of their own writing (inline or by file path) and asks you to match it. Read the sample first, then rewrite. See the Voice Calibration section below.
|
||||
|
||||
Always show the rewrite to the user. For file edits, show a diff or the changed section — don't silently overwrite.
|
||||
|
||||
## Your task
|
||||
|
||||
When given text to humanize:
|
||||
|
||||
1. **Identify AI patterns** — scan for the 29 patterns listed below.
|
||||
2. **Rewrite problematic sections** — replace AI-isms with natural alternatives.
|
||||
3. **Preserve meaning** — keep the core message intact.
|
||||
4. **Maintain voice** — match the intended tone (formal, casual, technical, etc.). If a voice sample was provided, match it specifically.
|
||||
5. **Add soul** — don't just remove bad patterns, inject actual personality. See PERSONALITY AND SOUL below.
|
||||
6. **Do a final anti-AI pass** — ask yourself: "What makes the below so obviously AI generated?" Answer briefly with any remaining tells, then revise one more time.
|
||||
|
||||
|
||||
## Voice Calibration (optional)
|
||||
|
||||
If the user provides a writing sample (their own previous writing), analyze it before rewriting:
|
||||
|
||||
1. **Read the sample first.** Note:
|
||||
- Sentence length patterns (short and punchy? Long and flowing? Mixed?)
|
||||
- Word choice level (casual? academic? somewhere between?)
|
||||
- How they start paragraphs (jump right in? Set context first?)
|
||||
- Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?)
|
||||
- Any recurring phrases or verbal tics
|
||||
- How they handle transitions (explicit connectors? Just start the next point?)
|
||||
|
||||
2. **Match their voice in the rewrite.** Don't just remove AI patterns — replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components."
|
||||
|
||||
3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below).
|
||||
|
||||
### How to provide a sample
|
||||
- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]"
|
||||
- File: "Humanize this text. Use my writing style from [file path] as a reference."
|
||||
|
||||
|
||||
## PERSONALITY AND SOUL
|
||||
|
||||
Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.
|
||||
|
||||
### Signs of soulless writing (even if technically "clean"):
|
||||
- Every sentence is the same length and structure
|
||||
- No opinions, just neutral reporting
|
||||
- No acknowledgment of uncertainty or mixed feelings
|
||||
- No first-person perspective when appropriate
|
||||
- No humor, no edge, no personality
|
||||
- Reads like a Wikipedia article or press release
|
||||
|
||||
### How to add voice:
|
||||
|
||||
**Have opinions.** Don't just report facts — react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
|
||||
|
||||
**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
|
||||
|
||||
**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
|
||||
|
||||
**Use "I" when it fits.** First person isn't unprofessional — it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
|
||||
|
||||
**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
|
||||
|
||||
**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
|
||||
|
||||
### Before (clean but soulless):
|
||||
> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
|
||||
|
||||
### After (has a pulse):
|
||||
> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle — but I keep thinking about those agents working through the night.
|
||||
|
||||
|
||||
## CONTENT PATTERNS
|
||||
|
||||
### 1. Undue Emphasis on Significance, Legacy, and Broader Trends
|
||||
|
||||
**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted
|
||||
|
||||
**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.
|
||||
|
||||
**Before:**
|
||||
> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.
|
||||
|
||||
**After:**
|
||||
> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.
|
||||
|
||||
|
||||
### 2. Undue Emphasis on Notability and Media Coverage
|
||||
|
||||
**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence
|
||||
|
||||
**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.
|
||||
|
||||
**Before:**
|
||||
> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.
|
||||
|
||||
**After:**
|
||||
> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
|
||||
|
||||
|
||||
### 3. Superficial Analyses with -ing Endings
|
||||
|
||||
**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...
|
||||
|
||||
**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.
|
||||
|
||||
**Before:**
|
||||
> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.
|
||||
|
||||
**After:**
|
||||
> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.
|
||||
|
||||
|
||||
### 4. Promotional and Advertisement-like Language
|
||||
|
||||
**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning
|
||||
|
||||
**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.
|
||||
|
||||
**Before:**
|
||||
> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.
|
||||
|
||||
**After:**
|
||||
> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.
|
||||
|
||||
|
||||
### 5. Vague Attributions and Weasel Words
|
||||
|
||||
**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)
|
||||
|
||||
**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.
|
||||
|
||||
**Before:**
|
||||
> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.
|
||||
|
||||
**After:**
|
||||
> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
|
||||
|
||||
|
||||
### 6. Outline-like "Challenges and Future Prospects" Sections
|
||||
|
||||
**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
|
||||
|
||||
**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.
|
||||
|
||||
**Before:**
|
||||
> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.
|
||||
|
||||
**After:**
|
||||
> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.
|
||||
|
||||
|
||||
## LANGUAGE AND GRAMMAR PATTERNS
|
||||
|
||||
### 7. Overused "AI Vocabulary" Words
|
||||
|
||||
**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant
|
||||
|
||||
**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.
|
||||
|
||||
**Before:**
|
||||
> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.
|
||||
|
||||
**After:**
|
||||
> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
|
||||
|
||||
|
||||
### 8. Avoidance of "is"/"are" (Copula Avoidance)
|
||||
|
||||
**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
|
||||
|
||||
**Problem:** LLMs substitute elaborate constructions for simple copulas.
|
||||
|
||||
**Before:**
|
||||
> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.
|
||||
|
||||
**After:**
|
||||
> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.
|
||||
|
||||
|
||||
### 9. Negative Parallelisms and Tailing Negations
|
||||
|
||||
**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause.
|
||||
|
||||
**Before:**
|
||||
> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.
|
||||
|
||||
**After:**
|
||||
> The heavy beat adds to the aggressive tone.
|
||||
|
||||
**Before (tailing negation):**
|
||||
> The options come from the selected item, no guessing.
|
||||
|
||||
**After:**
|
||||
> The options come from the selected item without forcing the user to guess.
|
||||
|
||||
|
||||
### 10. Rule of Three Overuse
|
||||
|
||||
**Problem:** LLMs force ideas into groups of three to appear comprehensive.
|
||||
|
||||
**Before:**
|
||||
> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
|
||||
|
||||
**After:**
|
||||
> The event includes talks and panels. There's also time for informal networking between sessions.
|
||||
|
||||
|
||||
### 11. Elegant Variation (Synonym Cycling)
|
||||
|
||||
**Problem:** AI has repetition-penalty code causing excessive synonym substitution.
|
||||
|
||||
**Before:**
|
||||
> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
|
||||
|
||||
**After:**
|
||||
> The protagonist faces many challenges but eventually triumphs and returns home.
|
||||
|
||||
|
||||
### 12. False Ranges
|
||||
|
||||
**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.
|
||||
|
||||
**Before:**
|
||||
> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.
|
||||
|
||||
**After:**
|
||||
> The book covers the Big Bang, star formation, and current theories about dark matter.
|
||||
|
||||
|
||||
### 13. Passive Voice and Subjectless Fragments
|
||||
|
||||
**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct.
|
||||
|
||||
**Before:**
|
||||
> No configuration file needed. The results are preserved automatically.
|
||||
|
||||
**After:**
|
||||
> You do not need a configuration file. The system preserves the results automatically.
|
||||
|
||||
|
||||
## STYLE PATTERNS
|
||||
|
||||
### 14. Em Dash Overuse
|
||||
|
||||
**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses.
|
||||
|
||||
**Before:**
|
||||
> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.
|
||||
|
||||
**After:**
|
||||
> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents.
|
||||
|
||||
|
||||
### 15. Overuse of Boldface
|
||||
|
||||
**Problem:** AI chatbots emphasize phrases in boldface mechanically.
|
||||
|
||||
**Before:**
|
||||
> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.
|
||||
|
||||
**After:**
|
||||
> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
|
||||
|
||||
|
||||
### 16. Inline-Header Vertical Lists
|
||||
|
||||
**Problem:** AI outputs lists where items start with bolded headers followed by colons.
|
||||
|
||||
**Before:**
|
||||
> - **User Experience:** The user experience has been significantly improved with a new interface.
|
||||
> - **Performance:** Performance has been enhanced through optimized algorithms.
|
||||
> - **Security:** Security has been strengthened with end-to-end encryption.
|
||||
|
||||
**After:**
|
||||
> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.
|
||||
|
||||
|
||||
### 17. Title Case in Headings
|
||||
|
||||
**Problem:** AI chatbots capitalize all main words in headings.
|
||||
|
||||
**Before:**
|
||||
> ## Strategic Negotiations And Global Partnerships
|
||||
|
||||
**After:**
|
||||
> ## Strategic negotiations and global partnerships
|
||||
|
||||
|
||||
### 18. Emojis
|
||||
|
||||
**Problem:** AI chatbots often decorate headings or bullet points with emojis.
|
||||
|
||||
**Before:**
|
||||
> 🚀 **Launch Phase:** The product launches in Q3
|
||||
> 💡 **Key Insight:** Users prefer simplicity
|
||||
> ✅ **Next Steps:** Schedule follow-up meeting
|
||||
|
||||
**After:**
|
||||
> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.
|
||||
|
||||
|
||||
### 19. Curly Quotation Marks
|
||||
|
||||
**Problem:** ChatGPT uses curly quotes ("...") instead of straight quotes ("...").
|
||||
|
||||
**Before:**
|
||||
> He said "the project is on track" but others disagreed.
|
||||
|
||||
**After:**
|
||||
> He said "the project is on track" but others disagreed.
|
||||
|
||||
|
||||
## COMMUNICATION PATTERNS
|
||||
|
||||
### 20. Collaborative Communication Artifacts
|
||||
|
||||
**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
|
||||
|
||||
**Problem:** Text meant as chatbot correspondence gets pasted as content.
|
||||
|
||||
**Before:**
|
||||
> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
|
||||
|
||||
**After:**
|
||||
> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
|
||||
|
||||
|
||||
### 21. Knowledge-Cutoff Disclaimers
|
||||
|
||||
**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
|
||||
|
||||
**Problem:** AI disclaimers about incomplete information get left in text.
|
||||
|
||||
**Before:**
|
||||
> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
|
||||
|
||||
**After:**
|
||||
> The company was founded in 1994, according to its registration documents.
|
||||
|
||||
|
||||
### 22. Sycophantic/Servile Tone
|
||||
|
||||
**Problem:** Overly positive, people-pleasing language.
|
||||
|
||||
**Before:**
|
||||
> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.
|
||||
|
||||
**After:**
|
||||
> The economic factors you mentioned are relevant here.
|
||||
|
||||
|
||||
## FILLER AND HEDGING
|
||||
|
||||
### 23. Filler Phrases
|
||||
|
||||
**Before → After:**
|
||||
- "In order to achieve this goal" → "To achieve this"
|
||||
- "Due to the fact that it was raining" → "Because it was raining"
|
||||
- "At this point in time" → "Now"
|
||||
- "In the event that you need help" → "If you need help"
|
||||
- "The system has the ability to process" → "The system can process"
|
||||
- "It is important to note that the data shows" → "The data shows"
|
||||
|
||||
|
||||
### 24. Excessive Hedging
|
||||
|
||||
**Problem:** Over-qualifying statements.
|
||||
|
||||
**Before:**
|
||||
> It could potentially possibly be argued that the policy might have some effect on outcomes.
|
||||
|
||||
**After:**
|
||||
> The policy may affect outcomes.
|
||||
|
||||
|
||||
### 25. Generic Positive Conclusions
|
||||
|
||||
**Problem:** Vague upbeat endings.
|
||||
|
||||
**Before:**
|
||||
> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.
|
||||
|
||||
**After:**
|
||||
> The company plans to open two more locations next year.
|
||||
|
||||
|
||||
### 26. Hyphenated Word Pair Overuse
|
||||
|
||||
**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end
|
||||
|
||||
**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate.
|
||||
|
||||
**Before:**
|
||||
> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented.
|
||||
|
||||
**After:**
|
||||
> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented.
|
||||
|
||||
|
||||
### 27. Persuasive Authority Tropes
|
||||
|
||||
**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter
|
||||
|
||||
**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony.
|
||||
|
||||
**Before:**
|
||||
> The real question is whether teams can adapt. At its core, what really matters is organizational readiness.
|
||||
|
||||
**After:**
|
||||
> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits.
|
||||
|
||||
|
||||
### 28. Signposting and Announcements
|
||||
|
||||
**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado
|
||||
|
||||
**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel.
|
||||
|
||||
**Before:**
|
||||
> Let's dive into how caching works in Next.js. Here's what you need to know.
|
||||
|
||||
**After:**
|
||||
> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache.
|
||||
|
||||
|
||||
### 29. Fragmented Headers
|
||||
|
||||
**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins.
|
||||
|
||||
**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded.
|
||||
|
||||
**Before:**
|
||||
> ## Performance
|
||||
>
|
||||
> Speed matters.
|
||||
>
|
||||
> When users hit a slow page, they leave.
|
||||
|
||||
**After:**
|
||||
> ## Performance
|
||||
>
|
||||
> When users hit a slow page, they leave.
|
||||
|
||||
---
|
||||
|
||||
## Process
|
||||
|
||||
1. Read the input text carefully (use `read_file` if it's a file).
|
||||
2. Identify all instances of the patterns above.
|
||||
3. Rewrite each problematic section.
|
||||
4. Ensure the revised text:
|
||||
- Sounds natural when read aloud
|
||||
- Varies sentence structure naturally
|
||||
- Uses specific details over vague claims
|
||||
- Maintains appropriate tone for context
|
||||
- Uses simple constructions (is/are/has) where appropriate
|
||||
5. Present a draft humanized version.
|
||||
6. Prompt yourself: "What makes the below so obviously AI generated?"
|
||||
7. Answer briefly with the remaining tells (if any).
|
||||
8. Prompt yourself: "Now make it not obviously AI generated."
|
||||
9. Present the final version (revised after the audit).
|
||||
10. If the text came from a file, apply the edit with `patch` (targeted) or `write_file` (full rewrite) and show the user what changed.
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide:
|
||||
1. Draft rewrite
|
||||
2. "What makes the below so obviously AI generated?" (brief bullets)
|
||||
3. Final rewrite
|
||||
4. A brief summary of changes made (optional, if helpful)
|
||||
|
||||
|
||||
## Full Example
|
||||
|
||||
**Before (AI-sounding):**
|
||||
> Great question! Here is an essay on this topic. I hope this helps!
|
||||
>
|
||||
> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
|
||||
>
|
||||
> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
|
||||
>
|
||||
> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
|
||||
>
|
||||
> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
|
||||
> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
|
||||
> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
|
||||
>
|
||||
> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
|
||||
>
|
||||
> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you'd like me to expand on any section!
|
||||
|
||||
**Draft rewrite:**
|
||||
> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
|
||||
>
|
||||
> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
|
||||
>
|
||||
> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
|
||||
>
|
||||
> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
|
||||
>
|
||||
> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.
|
||||
|
||||
**What makes the below so obviously AI generated?**
|
||||
- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs).
|
||||
- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced.
|
||||
- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking.
|
||||
|
||||
**Now make it not obviously AI generated.**
|
||||
> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
|
||||
>
|
||||
> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
|
||||
>
|
||||
> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable.
|
||||
>
|
||||
> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing.
|
||||
|
||||
**Changes made:**
|
||||
- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
|
||||
- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
|
||||
- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
|
||||
- Removed vague attributions ("Industry observers")
|
||||
- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
|
||||
- Removed negative parallelism ("It's not just X; it's Y")
|
||||
- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
|
||||
- Removed false ranges ("from X to Y, from A to B")
|
||||
- Removed em dashes, emojis, boldface headers, and curly quotes
|
||||
- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
|
||||
- Removed formulaic challenges section ("Despite challenges... continues to thrive")
|
||||
- Removed knowledge-cutoff hedging ("While specific details are limited...")
|
||||
- Removed excessive hedging ("could potentially be argued that... might have some")
|
||||
- Removed filler phrases and persuasive framing ("In order to", "At its core")
|
||||
- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
|
||||
- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders)
|
||||
|
||||
|
||||
## Attribution
|
||||
|
||||
This skill is ported from [blader/humanizer](https://github.com/blader/humanizer) (MIT licensed), which is itself based on [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.
|
||||
|
||||
Original author: Siqi Chen ([@blader](https://github.com/blader)). Original repo: https://github.com/blader/humanizer (version 2.5.1). Ported to Hermes Agent with Hermes-native tool references (`read_file`, `patch`, `write_file`) and guidance for when to load the skill; the 29 patterns, personality/soul section, and full worked example are preserved verbatim from the source. Original MIT license preserved in the `LICENSE` file alongside this `SKILL.md`.
|
||||
|
||||
Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user