Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4d4fee6fe | |||
| a1f9961f51 | |||
| 3741ee08d2 | |||
| 9cd3050a08 | |||
| 4670f66a33 | |||
| c9479c6c6f | |||
| 5a5d7ec2a2 | |||
| 87995cd9c5 | |||
| 8fd8def544 | |||
| 1d6a92103a | |||
| a692859ddb |
@@ -1,13 +0,0 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitmodules
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
+1
-14
@@ -59,25 +59,12 @@ OPENCODE_ZEN_API_KEY=
|
||||
# OpenCode Go provides access to open models (GLM-5, Kimi K2.5, MiniMax M2.5)
|
||||
# $10/month subscription. Get your key at: https://opencode.ai/auth
|
||||
OPENCODE_GO_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (Hugging Face Inference Providers)
|
||||
# =============================================================================
|
||||
# Hugging Face routes to 20+ open models via unified OpenAI-compatible endpoint.
|
||||
# Free tier included ($0.10/month), no markup on provider rates.
|
||||
# Get your token at: https://huggingface.co/settings/tokens
|
||||
# Required permission: "Make calls to Inference Providers"
|
||||
HF_TOKEN=
|
||||
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
|
||||
|
||||
# =============================================================================
|
||||
# TOOL API KEYS
|
||||
# =============================================================================
|
||||
|
||||
# Exa API Key - AI-native web search and contents
|
||||
# Get at: https://exa.ai
|
||||
EXA_API_KEY=
|
||||
|
||||
# Parallel API Key - AI-native web search and extract
|
||||
# Get at: https://parallel.ai
|
||||
PARALLEL_API_KEY=
|
||||
@@ -98,7 +85,7 @@ FAL_KEY=
|
||||
HONCHO_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# TERMINAL TOOL CONFIGURATION
|
||||
# TERMINAL TOOL CONFIGURATION (mini-swe-agent backend)
|
||||
# =============================================================================
|
||||
# Backend type: "local", "singularity", "docker", "modal", or "ssh"
|
||||
# Terminal backend is configured in ~/.hermes/config.yaml (terminal.backend).
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
tags: nousresearch/hermes-agent:test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Test image starts
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v /tmp/hermes-test:/opt/data \
|
||||
--entrypoint /opt/hermes/docker/entrypoint.sh \
|
||||
nousresearch/hermes-agent:test --help
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
nousresearch/hermes-agent:latest
|
||||
nousresearch/hermes-agent:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+1
-4
@@ -38,7 +38,7 @@ agent-browser/
|
||||
privvy*
|
||||
images/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
hermes_agent.egg-info/
|
||||
wandb/
|
||||
testlogs
|
||||
|
||||
@@ -51,9 +51,6 @@ ignored/
|
||||
.worktrees/
|
||||
environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
|
||||
# Release script temp files
|
||||
.release_notes.md
|
||||
mini-swe-agent/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.11
|
||||
@@ -210,10 +210,6 @@ registry.register(
|
||||
|
||||
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
|
||||
|
||||
**Path references in tool schemas**: If the schema description mentions file paths (e.g. default output directories), use `display_hermes_home()` to make them profile-aware. The schema is generated at import time, which is after `_apply_profile_override()` sets `HERMES_HOME`.
|
||||
|
||||
**State files**: If a tool stores persistent state (caches, logs, checkpoints), use `get_hermes_home()` for the base directory — never `Path.home() / ".hermes"`. This ensures each profile gets its own state.
|
||||
|
||||
**Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `todo_tool.py` for the pattern.
|
||||
|
||||
---
|
||||
@@ -362,69 +358,8 @@ in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
|
||||
|
||||
---
|
||||
|
||||
## Profiles: Multi-Instance Support
|
||||
|
||||
Hermes supports **profiles** — multiple fully isolated instances, each with its own
|
||||
`HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.).
|
||||
|
||||
The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
|
||||
`HERMES_HOME` before any module imports. All 119+ references to `get_hermes_home()`
|
||||
automatically scope to the active profile.
|
||||
|
||||
### Rules for profile-safe code
|
||||
|
||||
1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
|
||||
NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
|
||||
```python
|
||||
# GOOD
|
||||
from hermes_constants import get_hermes_home
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
|
||||
# BAD — breaks profiles
|
||||
config_path = Path.home() / ".hermes" / "config.yaml"
|
||||
```
|
||||
|
||||
2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
|
||||
This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
|
||||
```python
|
||||
# GOOD
|
||||
from hermes_constants import display_hermes_home
|
||||
print(f"Config saved to {display_hermes_home()}/config.yaml")
|
||||
|
||||
# BAD — shows wrong path for profiles
|
||||
print("Config saved to ~/.hermes/config.yaml")
|
||||
```
|
||||
|
||||
3. **Module-level constants are fine** — they cache `get_hermes_home()` at import time,
|
||||
which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`,
|
||||
not `Path.home() / ".hermes"`.
|
||||
|
||||
4. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
|
||||
`get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
|
||||
```python
|
||||
with patch.object(Path, "home", return_value=tmp_path), \
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
|
||||
...
|
||||
```
|
||||
|
||||
5. **Gateway platform adapters should use token locks** — if the adapter connects with
|
||||
a unique credential (bot token, API key), call `acquire_scoped_lock()` from
|
||||
`gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
|
||||
`disconnect()`/`stop()`. This prevents two profiles from using the same credential.
|
||||
See `gateway/platforms/telegram.py` for the canonical pattern.
|
||||
|
||||
6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
|
||||
returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
|
||||
This is intentional — it lets `hermes -p coder profile list` see all profiles regardless
|
||||
of which one is active.
|
||||
|
||||
## Known Pitfalls
|
||||
|
||||
### DO NOT hardcode `~/.hermes` paths
|
||||
Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
|
||||
for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
|
||||
has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
|
||||
|
||||
### DO NOT use `simple_term_menu` for interactive menus
|
||||
Rendering bugs in tmux/iTerm2 — ghosting on scroll. Use `curses` (stdlib) instead. See `hermes_cli/tools_config.py` for the pattern.
|
||||
|
||||
@@ -440,19 +375,6 @@ Tool schema descriptions must not mention tools from other toolsets by name (e.g
|
||||
### Tests must not write to `~/.hermes/`
|
||||
The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
|
||||
|
||||
**Profile tests**: When testing profile features, also mock `Path.home()` so that
|
||||
`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
|
||||
Use the pattern from `tests/hermes_cli/test_profiles.py`:
|
||||
```python
|
||||
@pytest.fixture
|
||||
def profile_env(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
FROM debian:13.4
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev
|
||||
|
||||
COPY . /opt/hermes
|
||||
WORKDIR /opt/hermes
|
||||
|
||||
RUN pip install -e ".[all]" --break-system-packages
|
||||
RUN npm install
|
||||
RUN npx playwright install --with-deps chromium
|
||||
WORKDIR /opt/hermes/scripts/whatsapp-bridge
|
||||
RUN npm install
|
||||
|
||||
WORKDIR /opt/hermes
|
||||
RUN chmod +x /opt/hermes/docker/entrypoint.sh
|
||||
|
||||
ENV HERMES_HOME=/opt/data
|
||||
VOLUME [ "/opt/data" ]
|
||||
ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ]
|
||||
@@ -1,348 +0,0 @@
|
||||
# Hermes Agent v0.5.0 (v2026.3.28)
|
||||
|
||||
**Release Date:** March 28, 2026
|
||||
|
||||
> The hardening release — Hugging Face provider, /model command overhaul, Telegram Private Chat Topics, native Modal SDK, plugin lifecycle hooks, tool-use enforcement for GPT models, Nix flake, 50+ security and reliability fixes, and a comprehensive supply chain audit.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Nous Portal now supports 400+ models** — The Nous Research inference portal has expanded dramatically, giving Hermes Agent users access to over 400 models through a single provider endpoint
|
||||
|
||||
- **Hugging Face as a first-class inference provider** — Full integration with HF Inference API including curated agentic model picker that maps to OpenRouter analogues, live `/models` endpoint probe, and setup wizard flow ([#3419](https://github.com/NousResearch/hermes-agent/pull/3419), [#3440](https://github.com/NousResearch/hermes-agent/pull/3440))
|
||||
|
||||
- **Telegram Private Chat Topics** — Project-based conversations with functional skill binding per topic, enabling isolated workflows within a single Telegram chat ([#3163](https://github.com/NousResearch/hermes-agent/pull/3163))
|
||||
|
||||
- **Native Modal SDK backend** — Replaced swe-rex dependency with native Modal SDK (`Sandbox.create.aio` + `exec.aio`), eliminating tunnels and simplifying the Modal terminal backend ([#3538](https://github.com/NousResearch/hermes-agent/pull/3538))
|
||||
|
||||
- **Plugin lifecycle hooks activated** — `pre_llm_call`, `post_llm_call`, `on_session_start`, and `on_session_end` hooks now fire in the agent loop and CLI/gateway, completing the plugin hook system ([#3542](https://github.com/NousResearch/hermes-agent/pull/3542))
|
||||
|
||||
- **Improved OpenAI Model Reliability** — Added `GPT_TOOL_USE_GUIDANCE` to prevent GPT models from describing intended actions instead of making tool calls, plus automatic stripping of stale budget warnings from conversation history that caused models to avoid tools across turns ([#3528](https://github.com/NousResearch/hermes-agent/pull/3528))
|
||||
|
||||
- **Nix flake** — Full uv2nix build, NixOS module with persistent container mode, auto-generated config keys from Python source, and suffix PATHs for agent-friendliness ([#20](https://github.com/NousResearch/hermes-agent/pull/20), [#3274](https://github.com/NousResearch/hermes-agent/pull/3274), [#3061](https://github.com/NousResearch/hermes-agent/pull/3061)) by @alt-glitch
|
||||
|
||||
- **Supply chain hardening** — Removed compromised `litellm` dependency, pinned all dependency version ranges, regenerated `uv.lock` with hashes, added CI workflow scanning PRs for supply chain attack patterns, and bumped deps to fix CVEs ([#2796](https://github.com/NousResearch/hermes-agent/pull/2796), [#2810](https://github.com/NousResearch/hermes-agent/pull/2810), [#2812](https://github.com/NousResearch/hermes-agent/pull/2812), [#2816](https://github.com/NousResearch/hermes-agent/pull/2816), [#3073](https://github.com/NousResearch/hermes-agent/pull/3073))
|
||||
|
||||
- **Anthropic output limits fix** — Replaced hardcoded 16K `max_tokens` with per-model native output limits (128K for Opus 4.6, 64K for Sonnet 4.6), fixing "Response truncated" and thinking-budget exhaustion on direct Anthropic API ([#3426](https://github.com/NousResearch/hermes-agent/pull/3426), [#3444](https://github.com/NousResearch/hermes-agent/pull/3444))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### New Provider: Hugging Face
|
||||
- First-class Hugging Face Inference API integration with auth, setup wizard, and model picker ([#3419](https://github.com/NousResearch/hermes-agent/pull/3419))
|
||||
- Curated model list mapping OpenRouter agentic defaults to HF equivalents — providers with 8+ curated models skip live `/models` probe for speed ([#3440](https://github.com/NousResearch/hermes-agent/pull/3440))
|
||||
- Added glm-5-turbo to Z.AI provider model list ([#3095](https://github.com/NousResearch/hermes-agent/pull/3095))
|
||||
|
||||
### Provider & Model Improvements
|
||||
- `/model` command overhaul — extracted shared `switch_model()` pipeline for CLI and gateway, custom endpoint support, provider-aware routing ([#2795](https://github.com/NousResearch/hermes-agent/pull/2795), [#2799](https://github.com/NousResearch/hermes-agent/pull/2799))
|
||||
- Removed `/model` slash command from CLI and gateway in favor of `hermes model` subcommand ([#3080](https://github.com/NousResearch/hermes-agent/pull/3080))
|
||||
- Preserve `custom` provider instead of silently remapping to `openrouter` ([#2792](https://github.com/NousResearch/hermes-agent/pull/2792))
|
||||
- Read root-level `provider` and `base_url` from config.yaml into model config ([#3112](https://github.com/NousResearch/hermes-agent/pull/3112))
|
||||
- Align Nous Portal model slugs with OpenRouter naming ([#3253](https://github.com/NousResearch/hermes-agent/pull/3253))
|
||||
- Fix Alibaba provider default endpoint and model list ([#3484](https://github.com/NousResearch/hermes-agent/pull/3484))
|
||||
- Allow MiniMax users to override `/v1` → `/anthropic` auto-correction ([#3553](https://github.com/NousResearch/hermes-agent/pull/3553))
|
||||
- Migrate OAuth token refresh to `platform.claude.com` with fallback ([#3246](https://github.com/NousResearch/hermes-agent/pull/3246))
|
||||
|
||||
### Agent Loop & Conversation
|
||||
- **Improved OpenAI model reliability** — `GPT_TOOL_USE_GUIDANCE` prevents GPT models from describing actions instead of calling tools + automatic budget warning stripping from history ([#3528](https://github.com/NousResearch/hermes-agent/pull/3528))
|
||||
- **Surface lifecycle events** — All retry, fallback, and compression events now surface to the user as formatted messages ([#3153](https://github.com/NousResearch/hermes-agent/pull/3153))
|
||||
- **Anthropic output limits** — Per-model native output limits instead of hardcoded 16K `max_tokens` ([#3426](https://github.com/NousResearch/hermes-agent/pull/3426))
|
||||
- **Thinking-budget exhaustion detection** — Skip useless continuation retries when model uses all output tokens on reasoning ([#3444](https://github.com/NousResearch/hermes-agent/pull/3444))
|
||||
- Always prefer streaming for API calls to prevent hung subagents ([#3120](https://github.com/NousResearch/hermes-agent/pull/3120))
|
||||
- Restore safe non-streaming fallback after stream failures ([#3020](https://github.com/NousResearch/hermes-agent/pull/3020))
|
||||
- Give subagents independent iteration budgets ([#3004](https://github.com/NousResearch/hermes-agent/pull/3004))
|
||||
- Update `api_key` in `_try_activate_fallback` for subagent auth ([#3103](https://github.com/NousResearch/hermes-agent/pull/3103))
|
||||
- Graceful return on max retries instead of crashing thread ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Count compression restarts toward retry limit ([#3070](https://github.com/NousResearch/hermes-agent/pull/3070))
|
||||
- Include tool tokens in preflight estimate, guard context probe persistence ([#3164](https://github.com/NousResearch/hermes-agent/pull/3164))
|
||||
- Update context compressor limits after fallback activation ([#3305](https://github.com/NousResearch/hermes-agent/pull/3305))
|
||||
- Validate empty user messages to prevent Anthropic API 400 errors ([#3322](https://github.com/NousResearch/hermes-agent/pull/3322))
|
||||
- GLM reasoning-only and max-length handling ([#3010](https://github.com/NousResearch/hermes-agent/pull/3010))
|
||||
- Increase API timeout default from 900s to 1800s for slow-thinking models ([#3431](https://github.com/NousResearch/hermes-agent/pull/3431))
|
||||
- Send `max_tokens` for Claude/OpenRouter + retry SSE connection errors ([#3497](https://github.com/NousResearch/hermes-agent/pull/3497))
|
||||
- Prevent AsyncOpenAI/httpx cross-loop deadlock in gateway mode ([#2701](https://github.com/NousResearch/hermes-agent/pull/2701)) by @ctlst
|
||||
|
||||
### Streaming & Reasoning
|
||||
- **Persist reasoning across gateway session turns** with new schema v6 columns (`reasoning`, `reasoning_details`, `codex_reasoning_items`) ([#2974](https://github.com/NousResearch/hermes-agent/pull/2974))
|
||||
- Detect and kill stale SSE connections ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Fix stale stream detector race causing spurious `RemoteProtocolError` ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Skip duplicate callback for `<think>`-extracted reasoning during streaming ([#3116](https://github.com/NousResearch/hermes-agent/pull/3116))
|
||||
- Preserve reasoning fields in `rewrite_transcript` ([#3311](https://github.com/NousResearch/hermes-agent/pull/3311))
|
||||
- Preserve Gemini thought signatures in streamed tool calls ([#2997](https://github.com/NousResearch/hermes-agent/pull/2997))
|
||||
- Ensure first delta is fired during reasoning updates ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
|
||||
### Session & Memory
|
||||
- **Session search recent sessions mode** — Omit query to browse recent sessions with titles, previews, and timestamps ([#2533](https://github.com/NousResearch/hermes-agent/pull/2533))
|
||||
- **Session config surfacing** on `/new`, `/reset`, and auto-reset ([#3321](https://github.com/NousResearch/hermes-agent/pull/3321))
|
||||
- **Third-party session isolation** — `--source` flag for isolating sessions by origin ([#3255](https://github.com/NousResearch/hermes-agent/pull/3255))
|
||||
- Add `/resume` CLI handler, session log truncation guard, `reopen_session` API ([#3315](https://github.com/NousResearch/hermes-agent/pull/3315))
|
||||
- Clear compressor summary and turn counter on `/clear` and `/new` ([#3102](https://github.com/NousResearch/hermes-agent/pull/3102))
|
||||
- Surface silent SessionDB failures that cause session data loss ([#2999](https://github.com/NousResearch/hermes-agent/pull/2999))
|
||||
- Session search fallback preview on summarization failure ([#3478](https://github.com/NousResearch/hermes-agent/pull/3478))
|
||||
- Prevent stale memory overwrites by flush agent ([#2687](https://github.com/NousResearch/hermes-agent/pull/2687))
|
||||
|
||||
### Context Compression
|
||||
- Replace dead `summary_target_tokens` with ratio-based scaling ([#2554](https://github.com/NousResearch/hermes-agent/pull/2554))
|
||||
- Expose `compression.target_ratio`, `protect_last_n`, and `threshold` in `DEFAULT_CONFIG` ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Restore sane defaults and cap summary at 12K tokens ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Preserve transcript on `/compress` and hygiene compression ([#3556](https://github.com/NousResearch/hermes-agent/pull/3556))
|
||||
- Update context pressure warnings and token estimates after compaction ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
|
||||
### Architecture & Dependencies
|
||||
- **Remove mini-swe-agent dependency** — Inline Docker and Modal backends directly ([#2804](https://github.com/NousResearch/hermes-agent/pull/2804))
|
||||
- **Replace swe-rex with native Modal SDK** for Modal backend ([#3538](https://github.com/NousResearch/hermes-agent/pull/3538))
|
||||
- **Plugin lifecycle hooks** — `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end` now fire in the agent loop ([#3542](https://github.com/NousResearch/hermes-agent/pull/3542))
|
||||
- Fix plugin toolsets invisible in `hermes tools` and standalone processes ([#3457](https://github.com/NousResearch/hermes-agent/pull/3457))
|
||||
- Consolidate `get_hermes_home()` and `parse_reasoning_effort()` ([#3062](https://github.com/NousResearch/hermes-agent/pull/3062))
|
||||
- Remove unused Hermes-native PKCE OAuth flow ([#3107](https://github.com/NousResearch/hermes-agent/pull/3107))
|
||||
- Remove ~100 unused imports across 55 files ([#3016](https://github.com/NousResearch/hermes-agent/pull/3016))
|
||||
- Fix 154 f-strings, simplify getattr/URL patterns, remove dead code ([#3119](https://github.com/NousResearch/hermes-agent/pull/3119))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### Telegram
|
||||
- **Private Chat Topics** — Project-based conversations with functional skill binding per topic, enabling isolated workflows within a single Telegram chat ([#3163](https://github.com/NousResearch/hermes-agent/pull/3163))
|
||||
- **Auto-discover fallback IPs via DNS-over-HTTPS** when `api.telegram.org` is unreachable ([#3376](https://github.com/NousResearch/hermes-agent/pull/3376))
|
||||
- **Configurable reply threading mode** ([#2907](https://github.com/NousResearch/hermes-agent/pull/2907))
|
||||
- Fall back to no `thread_id` on "Message thread not found" BadRequest ([#3390](https://github.com/NousResearch/hermes-agent/pull/3390))
|
||||
- Self-reschedule reconnect when `start_polling` fails after 502 ([#3268](https://github.com/NousResearch/hermes-agent/pull/3268))
|
||||
|
||||
### Discord
|
||||
- Stop phantom typing indicator after agent turn completes ([#3003](https://github.com/NousResearch/hermes-agent/pull/3003))
|
||||
|
||||
### Slack
|
||||
- Send tool call progress messages to correct Slack thread ([#3063](https://github.com/NousResearch/hermes-agent/pull/3063))
|
||||
- Scope progress thread fallback to Slack only ([#3488](https://github.com/NousResearch/hermes-agent/pull/3488))
|
||||
|
||||
### WhatsApp
|
||||
- Download documents, audio, and video media from messages ([#2978](https://github.com/NousResearch/hermes-agent/pull/2978))
|
||||
|
||||
### Matrix
|
||||
- Add missing Matrix entry in `PLATFORMS` dict ([#3473](https://github.com/NousResearch/hermes-agent/pull/3473))
|
||||
- Harden e2ee access-token handling ([#3562](https://github.com/NousResearch/hermes-agent/pull/3562))
|
||||
- Add backoff for `SyncError` in sync loop ([#3280](https://github.com/NousResearch/hermes-agent/pull/3280))
|
||||
|
||||
### Signal
|
||||
- Track SSE keepalive comments as connection activity ([#3316](https://github.com/NousResearch/hermes-agent/pull/3316))
|
||||
|
||||
### Email
|
||||
- Prevent unbounded growth of `_seen_uids` in EmailAdapter ([#3490](https://github.com/NousResearch/hermes-agent/pull/3490))
|
||||
|
||||
### Gateway Core
|
||||
- **Config-gated `/verbose` command** for messaging platforms — toggle tool output verbosity from chat ([#3262](https://github.com/NousResearch/hermes-agent/pull/3262))
|
||||
- **Background review notifications** delivered to user chat ([#3293](https://github.com/NousResearch/hermes-agent/pull/3293))
|
||||
- **Retry transient send failures** and notify user on exhaustion ([#3288](https://github.com/NousResearch/hermes-agent/pull/3288))
|
||||
- Recover from hung agents — `/stop` hard-kills session lock ([#3104](https://github.com/NousResearch/hermes-agent/pull/3104))
|
||||
- Thread-safe `SessionStore` — protect `_entries` with `threading.Lock` ([#3052](https://github.com/NousResearch/hermes-agent/pull/3052))
|
||||
- Fix gateway token double-counting with cached agents — use absolute set instead of increment ([#3306](https://github.com/NousResearch/hermes-agent/pull/3306), [#3317](https://github.com/NousResearch/hermes-agent/pull/3317))
|
||||
- Fingerprint full auth token in agent cache signature ([#3247](https://github.com/NousResearch/hermes-agent/pull/3247))
|
||||
- Silence background agent terminal output ([#3297](https://github.com/NousResearch/hermes-agent/pull/3297))
|
||||
- Include per-platform `ALLOW_ALL` and `SIGNAL_GROUP` in startup allowlist check ([#3313](https://github.com/NousResearch/hermes-agent/pull/3313))
|
||||
- Include user-local bin paths in systemd unit PATH ([#3527](https://github.com/NousResearch/hermes-agent/pull/3527))
|
||||
- Track background task references in `GatewayRunner` ([#3254](https://github.com/NousResearch/hermes-agent/pull/3254))
|
||||
- Add request timeouts to HA, Email, Mattermost, SMS adapters ([#3258](https://github.com/NousResearch/hermes-agent/pull/3258))
|
||||
- Add media download retry to Mattermost, Slack, and base cache ([#3323](https://github.com/NousResearch/hermes-agent/pull/3323))
|
||||
- Detect virtualenv path instead of hardcoding `venv/` ([#2797](https://github.com/NousResearch/hermes-agent/pull/2797))
|
||||
- Use `TERMINAL_CWD` for context file discovery, not process cwd ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Stop loading hermes repo AGENTS.md into gateway sessions (~10k wasted tokens) ([#2891](https://github.com/NousResearch/hermes-agent/pull/2891))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
### Interactive CLI
|
||||
- **Configurable busy input mode** + fix `/queue` always working ([#3298](https://github.com/NousResearch/hermes-agent/pull/3298))
|
||||
- **Preserve user input on multiline paste** ([#3065](https://github.com/NousResearch/hermes-agent/pull/3065))
|
||||
- **Tool generation callback** — streaming "preparing terminal…" updates during tool argument generation ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Show tool progress for substantive tools, not just "preparing" ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Buffer reasoning preview chunks and fix duplicate display ([#3013](https://github.com/NousResearch/hermes-agent/pull/3013))
|
||||
- Prevent reasoning box from rendering 3x during tool-calling loops ([#3405](https://github.com/NousResearch/hermes-agent/pull/3405))
|
||||
- Eliminate "Event loop is closed" / "Press ENTER to continue" during idle — three-layer fix with `neuter_async_httpx_del()`, custom exception handler, and stale client cleanup ([#3398](https://github.com/NousResearch/hermes-agent/pull/3398))
|
||||
- Fix status bar shows 26K instead of 260K for token counts with trailing zeros ([#3024](https://github.com/NousResearch/hermes-agent/pull/3024))
|
||||
- Fix status bar duplicates and degrades during long sessions ([#3291](https://github.com/NousResearch/hermes-agent/pull/3291))
|
||||
- Refresh TUI before background task output to prevent status bar overlap ([#3048](https://github.com/NousResearch/hermes-agent/pull/3048))
|
||||
- Suppress KawaiiSpinner animation under `patch_stdout` ([#2994](https://github.com/NousResearch/hermes-agent/pull/2994))
|
||||
- Skip KawaiiSpinner when TUI handles tool progress ([#2973](https://github.com/NousResearch/hermes-agent/pull/2973))
|
||||
- Guard `isatty()` against closed streams via `_is_tty` property ([#3056](https://github.com/NousResearch/hermes-agent/pull/3056))
|
||||
- Ensure single closure of streaming boxes during tool generation ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Cap context pressure percentage at 100% in display ([#3480](https://github.com/NousResearch/hermes-agent/pull/3480))
|
||||
- Clean up HTML error messages in CLI display ([#3069](https://github.com/NousResearch/hermes-agent/pull/3069))
|
||||
- Show HTTP status code and 400 body in API error output ([#3096](https://github.com/NousResearch/hermes-agent/pull/3096))
|
||||
- Extract useful info from HTML error pages, dump debug on max retries ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Prevent TypeError on startup when `base_url` is None ([#3068](https://github.com/NousResearch/hermes-agent/pull/3068))
|
||||
- Prevent update crash in non-TTY environments ([#3094](https://github.com/NousResearch/hermes-agent/pull/3094))
|
||||
- Handle EOFError in sessions delete/prune confirmation prompts ([#3101](https://github.com/NousResearch/hermes-agent/pull/3101))
|
||||
- Catch KeyboardInterrupt during `flush_memories` on exit and in exit cleanup handlers ([#3025](https://github.com/NousResearch/hermes-agent/pull/3025), [#3257](https://github.com/NousResearch/hermes-agent/pull/3257))
|
||||
- Guard `.strip()` against None values from YAML config ([#3552](https://github.com/NousResearch/hermes-agent/pull/3552))
|
||||
- Guard `config.get()` against YAML null values to prevent AttributeError ([#3377](https://github.com/NousResearch/hermes-agent/pull/3377))
|
||||
- Store asyncio task references to prevent GC mid-execution ([#3267](https://github.com/NousResearch/hermes-agent/pull/3267))
|
||||
|
||||
### Setup & Configuration
|
||||
- Use explicit key mapping for returning-user menu dispatch instead of positional index ([#3083](https://github.com/NousResearch/hermes-agent/pull/3083))
|
||||
- Use `sys.executable` for pip in update commands to fix PEP 668 ([#3099](https://github.com/NousResearch/hermes-agent/pull/3099))
|
||||
- Harden `hermes update` against diverged history, non-main branches, and gateway edge cases ([#3492](https://github.com/NousResearch/hermes-agent/pull/3492))
|
||||
- OpenClaw migration overwrites defaults and setup wizard skips imported sections — fixed ([#3282](https://github.com/NousResearch/hermes-agent/pull/3282))
|
||||
- Stop recursive AGENTS.md walk, load top-level only ([#3110](https://github.com/NousResearch/hermes-agent/pull/3110))
|
||||
- Add macOS Homebrew paths to browser and terminal PATH resolution ([#2713](https://github.com/NousResearch/hermes-agent/pull/2713))
|
||||
- YAML boolean handling for `tool_progress` config ([#3300](https://github.com/NousResearch/hermes-agent/pull/3300))
|
||||
- Reset default SOUL.md to baseline identity text ([#3159](https://github.com/NousResearch/hermes-agent/pull/3159))
|
||||
- Reject relative cwd paths for container terminal backends ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Add explicit `hermes-api-server` toolset for API server platform ([#3304](https://github.com/NousResearch/hermes-agent/pull/3304))
|
||||
- Reorder setup wizard providers — OpenRouter first ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### API Server
|
||||
- **Idempotency-Key support**, body size limit, and OpenAI error envelope ([#2903](https://github.com/NousResearch/hermes-agent/pull/2903))
|
||||
- Allow Idempotency-Key in CORS headers ([#3530](https://github.com/NousResearch/hermes-agent/pull/3530))
|
||||
- Cancel orphaned agent + true interrupt on SSE disconnect ([#3427](https://github.com/NousResearch/hermes-agent/pull/3427))
|
||||
- Fix streaming breaks when agent makes tool calls ([#2985](https://github.com/NousResearch/hermes-agent/pull/2985))
|
||||
|
||||
### Terminal & File Operations
|
||||
- Handle addition-only hunks in V4A patch parser ([#3325](https://github.com/NousResearch/hermes-agent/pull/3325))
|
||||
- Exponential backoff for persistent shell polling ([#2996](https://github.com/NousResearch/hermes-agent/pull/2996))
|
||||
- Add timeout to subprocess calls in `context_references` ([#3469](https://github.com/NousResearch/hermes-agent/pull/3469))
|
||||
|
||||
### Browser & Vision
|
||||
- Handle 402 insufficient credits error in vision tool ([#2802](https://github.com/NousResearch/hermes-agent/pull/2802))
|
||||
- Fix `browser_vision` ignores `auxiliary.vision.timeout` config ([#2901](https://github.com/NousResearch/hermes-agent/pull/2901))
|
||||
- Make browser command timeout configurable via config.yaml ([#2801](https://github.com/NousResearch/hermes-agent/pull/2801))
|
||||
|
||||
### MCP
|
||||
- MCP toolset resolution for runtime and config ([#3252](https://github.com/NousResearch/hermes-agent/pull/3252))
|
||||
- Add MCP tool name collision protection ([#3077](https://github.com/NousResearch/hermes-agent/pull/3077))
|
||||
|
||||
### Auxiliary LLM
|
||||
- Guard aux LLM calls against None content + reasoning fallback + retry ([#3449](https://github.com/NousResearch/hermes-agent/pull/3449))
|
||||
- Catch ImportError from `build_anthropic_client` in vision auto-detection ([#3312](https://github.com/NousResearch/hermes-agent/pull/3312))
|
||||
|
||||
### Other Tools
|
||||
- Add request timeouts to `send_message_tool` HTTP calls ([#3162](https://github.com/NousResearch/hermes-agent/pull/3162)) by @memosr
|
||||
- Auto-repair `jobs.json` with invalid control characters ([#3537](https://github.com/NousResearch/hermes-agent/pull/3537))
|
||||
- Enable fine-grained tool streaming for Claude/OpenRouter ([#3497](https://github.com/NousResearch/hermes-agent/pull/3497))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills Ecosystem
|
||||
|
||||
### Skills System
|
||||
- **Env var passthrough** for skills and user config — skills can declare environment variables to pass through ([#2807](https://github.com/NousResearch/hermes-agent/pull/2807))
|
||||
- Cache skills prompt with shared `skill_utils` module for faster TTFT ([#3421](https://github.com/NousResearch/hermes-agent/pull/3421))
|
||||
- Avoid redundant file re-read for skill conditions ([#2992](https://github.com/NousResearch/hermes-agent/pull/2992))
|
||||
- Use Git Trees API to prevent silent subdirectory loss during install ([#2995](https://github.com/NousResearch/hermes-agent/pull/2995))
|
||||
- Fix skills-sh install for deeply nested repo structures ([#2980](https://github.com/NousResearch/hermes-agent/pull/2980))
|
||||
- Handle null metadata in skill frontmatter ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Preserve trust for skills-sh identifiers + reduce resolution churn ([#3251](https://github.com/NousResearch/hermes-agent/pull/3251))
|
||||
- Agent-created skills were incorrectly treated as untrusted community content — fixed ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
|
||||
### New Skills
|
||||
- **G0DM0D3 godmode jailbreaking skill** + docs ([#3157](https://github.com/NousResearch/hermes-agent/pull/3157))
|
||||
- **Docker management skill** added to optional-skills ([#3060](https://github.com/NousResearch/hermes-agent/pull/3060))
|
||||
- **OpenClaw migration v2** — 17 new modules, terminal recap for migrating from OpenClaw to Hermes ([#2906](https://github.com/NousResearch/hermes-agent/pull/2906))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
### Security Hardening
|
||||
- **SSRF protection** added to `browser_navigate` ([#3058](https://github.com/NousResearch/hermes-agent/pull/3058))
|
||||
- **SSRF protection** added to `vision_tools` and `web_tools` (hardened) ([#2679](https://github.com/NousResearch/hermes-agent/pull/2679))
|
||||
- **Restrict subagent toolsets** to parent's enabled set ([#3269](https://github.com/NousResearch/hermes-agent/pull/3269))
|
||||
- **Prevent zip-slip path traversal** in self-update ([#3250](https://github.com/NousResearch/hermes-agent/pull/3250))
|
||||
- **Prevent shell injection** in `_expand_path` via `~user` path suffix ([#2685](https://github.com/NousResearch/hermes-agent/pull/2685))
|
||||
- **Normalize input** before dangerous command detection ([#3260](https://github.com/NousResearch/hermes-agent/pull/3260))
|
||||
- Make tirith block verdicts approvable instead of hard-blocking ([#3428](https://github.com/NousResearch/hermes-agent/pull/3428))
|
||||
- Remove compromised `litellm`/`typer`/`platformdirs` from deps ([#2796](https://github.com/NousResearch/hermes-agent/pull/2796))
|
||||
- Pin all dependency version ranges ([#2810](https://github.com/NousResearch/hermes-agent/pull/2810))
|
||||
- Regenerate `uv.lock` with hashes, use lockfile in setup ([#2812](https://github.com/NousResearch/hermes-agent/pull/2812))
|
||||
- Bump dependencies to fix CVEs + regenerate `uv.lock` ([#3073](https://github.com/NousResearch/hermes-agent/pull/3073))
|
||||
- Supply chain audit CI workflow for PR scanning ([#2816](https://github.com/NousResearch/hermes-agent/pull/2816))
|
||||
|
||||
### Reliability
|
||||
- **SQLite WAL write-lock contention** causing 15-20s TUI freeze — fixed ([#3385](https://github.com/NousResearch/hermes-agent/pull/3385))
|
||||
- **SQLite concurrency hardening** + session transcript integrity ([#3249](https://github.com/NousResearch/hermes-agent/pull/3249))
|
||||
- Prevent recurring cron job re-fire on gateway crash/restart loop ([#3396](https://github.com/NousResearch/hermes-agent/pull/3396))
|
||||
- Mark cron session as ended after job completes ([#2998](https://github.com/NousResearch/hermes-agent/pull/2998))
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
- **TTFT startup optimizations** — salvaged easy-win startup improvements ([#3395](https://github.com/NousResearch/hermes-agent/pull/3395))
|
||||
- Cache skills prompt with shared `skill_utils` module ([#3421](https://github.com/NousResearch/hermes-agent/pull/3421))
|
||||
- Avoid redundant file re-read for skill conditions in prompt builder ([#2992](https://github.com/NousResearch/hermes-agent/pull/2992))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
- Fix gateway token double-counting with cached agents ([#3306](https://github.com/NousResearch/hermes-agent/pull/3306), [#3317](https://github.com/NousResearch/hermes-agent/pull/3317))
|
||||
- Fix "Event loop is closed" / "Press ENTER to continue" during idle sessions ([#3398](https://github.com/NousResearch/hermes-agent/pull/3398))
|
||||
- Fix reasoning box rendering 3x during tool-calling loops ([#3405](https://github.com/NousResearch/hermes-agent/pull/3405))
|
||||
- Fix status bar shows 26K instead of 260K for token counts ([#3024](https://github.com/NousResearch/hermes-agent/pull/3024))
|
||||
- Fix `/queue` always working regardless of config ([#3298](https://github.com/NousResearch/hermes-agent/pull/3298))
|
||||
- Fix phantom Discord typing indicator after agent turn ([#3003](https://github.com/NousResearch/hermes-agent/pull/3003))
|
||||
- Fix Slack progress messages appearing in wrong thread ([#3063](https://github.com/NousResearch/hermes-agent/pull/3063))
|
||||
- Fix WhatsApp media downloads (documents, audio, video) ([#2978](https://github.com/NousResearch/hermes-agent/pull/2978))
|
||||
- Fix Telegram "Message thread not found" killing progress messages ([#3390](https://github.com/NousResearch/hermes-agent/pull/3390))
|
||||
- Fix OpenClaw migration overwriting defaults ([#3282](https://github.com/NousResearch/hermes-agent/pull/3282))
|
||||
- Fix returning-user setup menu dispatching wrong section ([#3083](https://github.com/NousResearch/hermes-agent/pull/3083))
|
||||
- Fix `hermes update` PEP 668 "externally-managed-environment" error ([#3099](https://github.com/NousResearch/hermes-agent/pull/3099))
|
||||
- Fix subagents hitting `max_iterations` prematurely via shared budget ([#3004](https://github.com/NousResearch/hermes-agent/pull/3004))
|
||||
- Fix YAML boolean handling for `tool_progress` config ([#3300](https://github.com/NousResearch/hermes-agent/pull/3300))
|
||||
- Fix `config.get()` crashes on YAML null values ([#3377](https://github.com/NousResearch/hermes-agent/pull/3377))
|
||||
- Fix `.strip()` crash on None values from YAML config ([#3552](https://github.com/NousResearch/hermes-agent/pull/3552))
|
||||
- Fix hung agents on gateway — `/stop` now hard-kills session lock ([#3104](https://github.com/NousResearch/hermes-agent/pull/3104))
|
||||
- Fix `_custom` provider silently remapped to `openrouter` ([#2792](https://github.com/NousResearch/hermes-agent/pull/2792))
|
||||
- Fix Matrix missing from `PLATFORMS` dict ([#3473](https://github.com/NousResearch/hermes-agent/pull/3473))
|
||||
- Fix Email adapter unbounded `_seen_uids` growth ([#3490](https://github.com/NousResearch/hermes-agent/pull/3490))
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- Pin `agent-client-protocol` < 0.9 to handle breaking upstream release ([#3320](https://github.com/NousResearch/hermes-agent/pull/3320))
|
||||
- Catch anthropic ImportError in vision auto-detection tests ([#3312](https://github.com/NousResearch/hermes-agent/pull/3312))
|
||||
- Update retry-exhaust test for new graceful return behavior ([#3320](https://github.com/NousResearch/hermes-agent/pull/3320))
|
||||
- Add regression tests for null metadata frontmatter ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- Update all docs for `/model` command overhaul and custom provider support ([#2800](https://github.com/NousResearch/hermes-agent/pull/2800))
|
||||
- Fix stale and incorrect documentation across 18 files ([#2805](https://github.com/NousResearch/hermes-agent/pull/2805))
|
||||
- Document 9 previously undocumented features ([#2814](https://github.com/NousResearch/hermes-agent/pull/2814))
|
||||
- Add missing skills, CLI commands, and messaging env vars to docs ([#2809](https://github.com/NousResearch/hermes-agent/pull/2809))
|
||||
- Fix api-server response storage documentation — SQLite, not in-memory ([#2819](https://github.com/NousResearch/hermes-agent/pull/2819))
|
||||
- Quote pip install extras to fix zsh glob errors ([#2815](https://github.com/NousResearch/hermes-agent/pull/2815))
|
||||
- Unify hooks documentation — add plugin hooks to hooks page, add `session:end` event ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Clarify two-mode behavior in `session_search` schema description ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
- Fix Discord Public Bot setting for Discord-provided invite link ([#3519](https://github.com/NousResearch/hermes-agent/pull/3519)) by @mehmoodosman
|
||||
- Revise v0.4.0 changelog — fix feature attribution, reorder sections ([untagged commit](https://github.com/NousResearch/hermes-agent))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- **@teknium1** — 157 PRs covering the full scope of this release
|
||||
|
||||
### Community Contributors
|
||||
- **@alt-glitch** (Siddharth Balyan) — 2 PRs: Nix flake with uv2nix build, NixOS module, and persistent container mode ([#20](https://github.com/NousResearch/hermes-agent/pull/20)); auto-generated config keys and suffix PATHs for Nix builds ([#3061](https://github.com/NousResearch/hermes-agent/pull/3061), [#3274](https://github.com/NousResearch/hermes-agent/pull/3274))
|
||||
- **@ctlst** — 1 PR: Prevent AsyncOpenAI/httpx cross-loop deadlock in gateway mode ([#2701](https://github.com/NousResearch/hermes-agent/pull/2701))
|
||||
- **@memosr** (memosr.eth) — 1 PR: Add request timeouts to `send_message_tool` HTTP calls ([#3162](https://github.com/NousResearch/hermes-agent/pull/3162))
|
||||
- **@mehmoodosman** (Osman Mehmood) — 1 PR: Fix Discord docs for Public Bot setting ([#3519](https://github.com/NousResearch/hermes-agent/pull/3519))
|
||||
|
||||
### All Contributors
|
||||
@alt-glitch, @ctlst, @mehmoodosman, @memosr, @teknium1
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.3.23...v2026.3.28](https://github.com/NousResearch/hermes-agent/compare/v2026.3.23...v2026.3.28)
|
||||
@@ -74,7 +74,7 @@ def main() -> None:
|
||||
|
||||
agent = HermesACPAgent()
|
||||
try:
|
||||
asyncio.run(acp.run_agent(agent, use_unstable_protocol=True))
|
||||
asyncio.run(acp.run_agent(agent))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down (KeyboardInterrupt)")
|
||||
except Exception:
|
||||
|
||||
+3
-46
@@ -25,9 +25,6 @@ from acp.schema import (
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeResponse,
|
||||
ResourceContentBlock,
|
||||
SessionCapabilities,
|
||||
SessionForkCapabilities,
|
||||
@@ -97,14 +94,11 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
protocol_version: int | None = None,
|
||||
protocol_version: int,
|
||||
client_capabilities: ClientCapabilities | None = None,
|
||||
client_info: Implementation | None = None,
|
||||
**kwargs: Any,
|
||||
) -> InitializeResponse:
|
||||
resolved_protocol_version = (
|
||||
protocol_version if isinstance(protocol_version, int) else acp.PROTOCOL_VERSION
|
||||
)
|
||||
provider = detect_provider()
|
||||
auth_methods = None
|
||||
if provider:
|
||||
@@ -117,11 +111,7 @@ class HermesACPAgent(acp.Agent):
|
||||
]
|
||||
|
||||
client_name = client_info.name if client_info else "unknown"
|
||||
logger.info(
|
||||
"Initialize from %s (protocol v%s)",
|
||||
client_name,
|
||||
resolved_protocol_version,
|
||||
)
|
||||
logger.info("Initialize from %s (protocol v%s)", client_name, protocol_version)
|
||||
|
||||
return InitializeResponse(
|
||||
protocol_version=acp.PROTOCOL_VERSION,
|
||||
@@ -481,7 +471,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
async def set_session_model(
|
||||
self, model_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModelResponse | None:
|
||||
):
|
||||
"""Switch the model for a session (called by ACP protocol)."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state:
|
||||
@@ -499,37 +489,4 @@ class HermesACPAgent(acp.Agent):
|
||||
)
|
||||
self.session_manager.save_session(session_id)
|
||||
logger.info("Session %s: model switched to %s", session_id, model_id)
|
||||
return SetSessionModelResponse()
|
||||
logger.warning("Session %s: model switch requested for missing session", session_id)
|
||||
return None
|
||||
|
||||
async def set_session_mode(
|
||||
self, mode_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModeResponse | None:
|
||||
"""Persist the editor-requested mode so ACP clients do not fail on mode switches."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state is None:
|
||||
logger.warning("Session %s: mode switch requested for missing session", session_id)
|
||||
return None
|
||||
setattr(state, "mode", mode_id)
|
||||
self.session_manager.save_session(session_id)
|
||||
logger.info("Session %s: mode switched to %s", session_id, mode_id)
|
||||
return SetSessionModeResponse()
|
||||
|
||||
async def set_config_option(
|
||||
self, config_id: str, session_id: str, value: str, **kwargs: Any
|
||||
) -> SetSessionConfigOptionResponse | None:
|
||||
"""Accept ACP config option updates even when Hermes has no typed ACP config surface yet."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state is None:
|
||||
logger.warning("Session %s: config update requested for missing session", session_id)
|
||||
return None
|
||||
|
||||
options = getattr(state, "config_options", None)
|
||||
if not isinstance(options, dict):
|
||||
options = {}
|
||||
options[str(config_id)] = value
|
||||
setattr(state, "config_options", options)
|
||||
self.session_manager.save_session(session_id)
|
||||
logger.info("Session %s: config option %s updated", session_id, config_id)
|
||||
return SetSessionConfigOptionResponse(config_options=[])
|
||||
|
||||
@@ -35,54 +35,6 @@ ADAPTIVE_EFFORT_MAP = {
|
||||
"minimal": "low",
|
||||
}
|
||||
|
||||
# ── Max output token limits per Anthropic model ───────────────────────
|
||||
# Source: Anthropic docs + Cline model catalog. Anthropic's API requires
|
||||
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
||||
# starves thinking-enabled models (thinking tokens count toward the limit).
|
||||
_ANTHROPIC_OUTPUT_LIMITS = {
|
||||
# Claude 4.6
|
||||
"claude-opus-4-6": 128_000,
|
||||
"claude-sonnet-4-6": 64_000,
|
||||
# Claude 4.5
|
||||
"claude-opus-4-5": 64_000,
|
||||
"claude-sonnet-4-5": 64_000,
|
||||
"claude-haiku-4-5": 64_000,
|
||||
# Claude 4
|
||||
"claude-opus-4": 32_000,
|
||||
"claude-sonnet-4": 64_000,
|
||||
# Claude 3.7
|
||||
"claude-3-7-sonnet": 128_000,
|
||||
# Claude 3.5
|
||||
"claude-3-5-sonnet": 8_192,
|
||||
"claude-3-5-haiku": 8_192,
|
||||
# Claude 3
|
||||
"claude-3-opus": 4_096,
|
||||
"claude-3-sonnet": 4_096,
|
||||
"claude-3-haiku": 4_096,
|
||||
}
|
||||
|
||||
# For any model not in the table, assume the highest current limit.
|
||||
# Future Anthropic models are unlikely to have *less* output capacity.
|
||||
_ANTHROPIC_DEFAULT_OUTPUT_LIMIT = 128_000
|
||||
|
||||
|
||||
def _get_anthropic_max_output(model: str) -> int:
|
||||
"""Look up the max output token limit for an Anthropic model.
|
||||
|
||||
Uses substring matching against _ANTHROPIC_OUTPUT_LIMITS so date-stamped
|
||||
model IDs (claude-sonnet-4-5-20250929) and variant suffixes (:1m, :fast)
|
||||
resolve correctly. Longest-prefix match wins to avoid e.g. "claude-3-5"
|
||||
matching before "claude-3-5-sonnet".
|
||||
"""
|
||||
m = model.lower()
|
||||
best_key = ""
|
||||
best_val = _ANTHROPIC_DEFAULT_OUTPUT_LIMIT
|
||||
for key, val in _ANTHROPIC_OUTPUT_LIMITS.items():
|
||||
if key in m and len(key) > len(best_key):
|
||||
best_key = key
|
||||
best_val = val
|
||||
return best_val
|
||||
|
||||
|
||||
def _supports_adaptive_thinking(model: str) -> bool:
|
||||
"""Return True for Claude 4.6 models that support adaptive thinking."""
|
||||
@@ -866,15 +818,9 @@ def build_anthropic_kwargs(
|
||||
tool_choice: Optional[str] = None,
|
||||
is_oauth: bool = False,
|
||||
preserve_dots: bool = False,
|
||||
context_length: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build kwargs for anthropic.messages.create().
|
||||
|
||||
When *max_tokens* is None, the model's native output limit is used
|
||||
(e.g. 128K for Opus 4.6, 64K for Sonnet 4.6). If *context_length*
|
||||
is provided, the effective limit is clamped so it doesn't exceed
|
||||
the context window.
|
||||
|
||||
When *is_oauth* is True, applies Claude Code compatibility transforms:
|
||||
system prompt prefix, tool name prefixing, and prompt sanitization.
|
||||
|
||||
@@ -885,12 +831,7 @@ def build_anthropic_kwargs(
|
||||
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
|
||||
|
||||
model = normalize_model_name(model, preserve_dots=preserve_dots)
|
||||
effective_max_tokens = max_tokens or _get_anthropic_max_output(model)
|
||||
|
||||
# Clamp to context window if the user set a lower context_length
|
||||
# (e.g. custom endpoint with limited capacity).
|
||||
if context_length and effective_max_tokens > context_length:
|
||||
effective_max_tokens = max(context_length - 1, 1)
|
||||
effective_max_tokens = max_tokens or 16384
|
||||
|
||||
# ── OAuth: Claude Code identity ──────────────────────────────────
|
||||
if is_oauth:
|
||||
|
||||
+12
-129
@@ -627,6 +627,8 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
|
||||
custom_key = runtime.get("api_key")
|
||||
if not isinstance(custom_base, str) or not custom_base.strip():
|
||||
return None, None
|
||||
if not isinstance(custom_key, str) or not custom_key.strip():
|
||||
return None, None
|
||||
|
||||
custom_base = custom_base.strip().rstrip("/")
|
||||
if "openrouter.ai" in custom_base.lower():
|
||||
@@ -634,13 +636,6 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
|
||||
# configured. Treat that as "no custom endpoint" for auxiliary routing.
|
||||
return None, None
|
||||
|
||||
# Local servers (Ollama, llama.cpp, vLLM, LM Studio) don't require auth.
|
||||
# Use a placeholder key — the OpenAI SDK requires a non-empty string but
|
||||
# local servers ignore the Authorization header. Same fix as cli.py
|
||||
# _ensure_runtime_credentials() (PR #2556).
|
||||
if not isinstance(custom_key, str) or not custom_key.strip():
|
||||
custom_key = "no-key-required"
|
||||
|
||||
return custom_base, custom_key.strip()
|
||||
|
||||
|
||||
@@ -742,37 +737,16 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st
|
||||
return None, None
|
||||
|
||||
|
||||
_AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
"_try_custom_endpoint": "local/custom",
|
||||
"_try_codex": "openai-codex",
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain: OpenRouter → Nous → custom → Codex → API-key → None."""
|
||||
global auxiliary_is_nous
|
||||
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
|
||||
tried = []
|
||||
for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint,
|
||||
_try_codex, _resolve_api_key_provider):
|
||||
fn_name = getattr(try_fn, "__name__", "unknown")
|
||||
label = _AUTO_PROVIDER_LABELS.get(fn_name, fn_name)
|
||||
client, model = try_fn()
|
||||
if client is not None:
|
||||
if tried:
|
||||
logger.info("Auxiliary auto-detect: using %s (%s) — skipped: %s",
|
||||
label, model or "default", ", ".join(tried))
|
||||
else:
|
||||
logger.info("Auxiliary auto-detect: using %s (%s)", label, model or "default")
|
||||
return client, model
|
||||
tried.append(label)
|
||||
logger.warning("Auxiliary auto-detect: no provider available (tried: %s). "
|
||||
"Compression, summarization, and memory flush will not work. "
|
||||
"Set OPENROUTER_API_KEY or configure a local model in config.yaml.",
|
||||
", ".join(tried))
|
||||
logger.debug("Auxiliary client: none available")
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -923,12 +897,11 @@ def resolve_provider_client(
|
||||
custom_key = (
|
||||
(explicit_api_key or "").strip()
|
||||
or os.getenv("OPENAI_API_KEY", "").strip()
|
||||
or "no-key-required" # local servers don't need auth
|
||||
)
|
||||
if not custom_base:
|
||||
if not custom_base or not custom_key:
|
||||
logger.warning(
|
||||
"resolve_provider_client: explicit custom endpoint requested "
|
||||
"but base_url is empty"
|
||||
"but no API key was found (set explicit_api_key or OPENAI_API_KEY)"
|
||||
)
|
||||
return None, None
|
||||
final_model = model or _read_main_model() or "gpt-4o-mini"
|
||||
@@ -1485,29 +1458,6 @@ def _resolve_task_provider_model(
|
||||
return "auto", resolved_model, None, None
|
||||
|
||||
|
||||
_DEFAULT_AUX_TIMEOUT = 30.0
|
||||
|
||||
|
||||
def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float:
|
||||
"""Read timeout from auxiliary.{task}.timeout in config, falling back to *default*."""
|
||||
if not task:
|
||||
return default
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
except ImportError:
|
||||
return default
|
||||
aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
|
||||
task_config = aux.get(task, {}) if isinstance(aux, dict) else {}
|
||||
raw = task_config.get("timeout")
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _build_call_kwargs(
|
||||
provider: str,
|
||||
model: str,
|
||||
@@ -1565,7 +1515,7 @@ def call_llm(
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
timeout: float = 30.0,
|
||||
extra_body: dict = None,
|
||||
) -> Any:
|
||||
"""Centralized synchronous LLM call.
|
||||
@@ -1583,7 +1533,7 @@ def call_llm(
|
||||
temperature: Sampling temperature (None = provider default).
|
||||
max_tokens: Max output tokens (handles max_tokens vs max_completion_tokens).
|
||||
tools: Tool definitions (for function calling).
|
||||
timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config).
|
||||
timeout: Request timeout in seconds.
|
||||
extra_body: Additional request body fields.
|
||||
|
||||
Returns:
|
||||
@@ -1639,8 +1589,8 @@ def call_llm(
|
||||
)
|
||||
# For auto/custom, fall back to OpenRouter
|
||||
if not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, falling back to openrouter",
|
||||
task or "call", resolved_provider)
|
||||
logger.warning("Provider %s unavailable, falling back to openrouter",
|
||||
resolved_provider)
|
||||
client, final_model = _get_cached_client(
|
||||
"openrouter", resolved_model or _OPENROUTER_MODEL)
|
||||
if client is None:
|
||||
@@ -1648,19 +1598,10 @@ def call_llm(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
|
||||
# Log what we're about to do — makes auxiliary operations visible
|
||||
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
|
||||
if task:
|
||||
logger.info("Auxiliary %s: using %s (%s)%s",
|
||||
task, resolved_provider or "auto", final_model or "default",
|
||||
f" at {_base_info}" if _base_info and "openrouter" not in _base_info else "")
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=extra_body,
|
||||
tools=tools, timeout=timeout, extra_body=extra_body,
|
||||
base_url=resolved_base_url)
|
||||
|
||||
# Handle max_tokens vs max_completion_tokens retry
|
||||
@@ -1675,62 +1616,6 @@ def call_llm(
|
||||
raise
|
||||
|
||||
|
||||
def extract_content_or_reasoning(response) -> str:
|
||||
"""Extract content from an LLM response, falling back to reasoning fields.
|
||||
|
||||
Mirrors the main agent loop's behavior when a reasoning model (DeepSeek-R1,
|
||||
Qwen-QwQ, etc.) returns ``content=None`` with reasoning in structured fields.
|
||||
|
||||
Resolution order:
|
||||
1. ``message.content`` — strip inline think/reasoning blocks, check for
|
||||
remaining non-whitespace text.
|
||||
2. ``message.reasoning`` / ``message.reasoning_content`` — direct
|
||||
structured reasoning fields (DeepSeek, Moonshot, Novita, etc.).
|
||||
3. ``message.reasoning_details`` — OpenRouter unified array format.
|
||||
|
||||
Returns the best available text, or ``""`` if nothing found.
|
||||
"""
|
||||
import re
|
||||
|
||||
msg = response.choices[0].message
|
||||
content = (msg.content or "").strip()
|
||||
|
||||
if content:
|
||||
# Strip inline think/reasoning blocks (mirrors _strip_think_blocks)
|
||||
cleaned = re.sub(
|
||||
r"<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>"
|
||||
r".*?"
|
||||
r"</(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>",
|
||||
"", content, flags=re.DOTALL | re.IGNORECASE,
|
||||
).strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
|
||||
# Content is empty or reasoning-only — try structured reasoning fields
|
||||
reasoning_parts: list[str] = []
|
||||
for field in ("reasoning", "reasoning_content"):
|
||||
val = getattr(msg, field, None)
|
||||
if val and isinstance(val, str) and val.strip() and val not in reasoning_parts:
|
||||
reasoning_parts.append(val.strip())
|
||||
|
||||
details = getattr(msg, "reasoning_details", None)
|
||||
if details and isinstance(details, list):
|
||||
for detail in details:
|
||||
if isinstance(detail, dict):
|
||||
summary = (
|
||||
detail.get("summary")
|
||||
or detail.get("content")
|
||||
or detail.get("text")
|
||||
)
|
||||
if summary and summary not in reasoning_parts:
|
||||
reasoning_parts.append(summary.strip() if isinstance(summary, str) else str(summary))
|
||||
|
||||
if reasoning_parts:
|
||||
return "\n\n".join(reasoning_parts)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
async def async_call_llm(
|
||||
task: str = None,
|
||||
*,
|
||||
@@ -1742,7 +1627,7 @@ async def async_call_llm(
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
timeout: float = 30.0,
|
||||
extra_body: dict = None,
|
||||
) -> Any:
|
||||
"""Centralized asynchronous LLM call.
|
||||
@@ -1803,12 +1688,10 @@ async def async_call_llm(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=extra_body,
|
||||
tools=tools, timeout=timeout, extra_body=extra_body,
|
||||
base_url=resolved_base_url)
|
||||
|
||||
try:
|
||||
|
||||
@@ -141,7 +141,7 @@ class ContextCompressor:
|
||||
"last_prompt_tokens": self.last_prompt_tokens,
|
||||
"threshold_tokens": self.threshold_tokens,
|
||||
"context_length": self.context_length,
|
||||
"usage_percent": min(100, (self.last_prompt_tokens / self.context_length * 100)) if self.context_length else 0,
|
||||
"usage_percent": (self.last_prompt_tokens / self.context_length * 100) if self.context_length else 0,
|
||||
"compression_count": self.compression_count,
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": summary_budget * 2,
|
||||
# timeout resolved from auxiliary.compression.timeout config by call_llm
|
||||
"timeout": 45.0,
|
||||
}
|
||||
if self.summary_model:
|
||||
call_kwargs["model"] = self.summary_model
|
||||
|
||||
@@ -286,16 +286,12 @@ def _expand_git_reference(
|
||||
args: list[str],
|
||||
label: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{ref.raw}: git command timed out (30s)", None
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip() or "git command failed"
|
||||
return f"{ref.raw}: {stderr}", None
|
||||
@@ -453,12 +449,9 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
files = [Path(line.strip()) for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
+10
-37
@@ -17,23 +17,6 @@ _RESET = "\033[0m"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =========================================================================
|
||||
# Configurable tool preview length (0 = no limit)
|
||||
# Set once at startup by CLI or gateway from display.tool_preview_length config.
|
||||
# =========================================================================
|
||||
_tool_preview_max_len: int = 0 # 0 = unlimited
|
||||
|
||||
|
||||
def set_tool_preview_max_len(n: int) -> None:
|
||||
"""Set the global max length for tool call previews. 0 = no limit."""
|
||||
global _tool_preview_max_len
|
||||
_tool_preview_max_len = max(int(n), 0) if n else 0
|
||||
|
||||
|
||||
def get_tool_preview_max_len() -> int:
|
||||
"""Return the configured max preview length (0 = unlimited)."""
|
||||
return _tool_preview_max_len
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Skin-aware helpers (lazy import to avoid circular deps)
|
||||
@@ -111,14 +94,8 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display.
|
||||
|
||||
*max_len* controls truncation. ``None`` (default) defers to the global
|
||||
``_tool_preview_max_len`` set via config; ``0`` means unlimited.
|
||||
"""
|
||||
if max_len is None:
|
||||
max_len = _tool_preview_max_len
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display."""
|
||||
if not args:
|
||||
return None
|
||||
primary_args = {
|
||||
@@ -213,7 +190,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
preview = _oneline(str(value))
|
||||
if not preview:
|
||||
return None
|
||||
if max_len > 0 and len(preview) > max_len:
|
||||
if len(preview) > max_len:
|
||||
preview = preview[:max_len - 3] + "..."
|
||||
return preview
|
||||
|
||||
@@ -307,11 +284,11 @@ class KawaiiSpinner:
|
||||
The CLI already drives a TUI widget (_spinner_text) for spinner display,
|
||||
so KawaiiSpinner's \\r-based animation is redundant under StdoutProxy.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.patch_stdout import StdoutProxy
|
||||
return isinstance(self._out, StdoutProxy)
|
||||
except ImportError:
|
||||
return False
|
||||
out = self._out
|
||||
# StdoutProxy has a 'raw' attribute (bool) that plain file objects lack.
|
||||
if hasattr(out, 'raw') and type(out).__name__ == 'StdoutProxy':
|
||||
return True
|
||||
return False
|
||||
|
||||
def _animate(self):
|
||||
# When stdout is not a real terminal (e.g. Docker, systemd, pipe),
|
||||
@@ -507,14 +484,10 @@ def get_cute_tool_message(
|
||||
|
||||
def _trunc(s, n=40):
|
||||
s = str(s)
|
||||
if _tool_preview_max_len == 0:
|
||||
return s # no limit
|
||||
return (s[:n-3] + "...") if len(s) > n else s
|
||||
|
||||
def _path(p, n=35):
|
||||
p = str(p)
|
||||
if _tool_preview_max_len == 0:
|
||||
return p # no limit
|
||||
return ("..." + p[-(n-3):]) if len(p) > n else p
|
||||
|
||||
def _wrap(line: str) -> str:
|
||||
@@ -726,7 +699,7 @@ def format_context_pressure(
|
||||
threshold_percent: Compaction threshold as a fraction of context window.
|
||||
compression_enabled: Whether auto-compression is active.
|
||||
"""
|
||||
pct_int = min(int(compaction_progress * 100), 100)
|
||||
pct_int = int(compaction_progress * 100)
|
||||
filled = min(int(compaction_progress * _BAR_WIDTH), _BAR_WIDTH)
|
||||
bar = _BAR_FILLED * filled + _BAR_EMPTY * (_BAR_WIDTH - filled)
|
||||
|
||||
@@ -756,7 +729,7 @@ def format_context_pressure_gateway(
|
||||
No ANSI — just Unicode and plain text suitable for Telegram/Discord/etc.
|
||||
The percentage shows progress toward the compaction threshold.
|
||||
"""
|
||||
pct_int = min(int(compaction_progress * 100), 100)
|
||||
pct_int = int(compaction_progress * 100)
|
||||
filled = min(int(compaction_progress * _BAR_WIDTH), _BAR_WIDTH)
|
||||
bar = _BAR_FILLED * filled + _BAR_EMPTY * (_BAR_WIDTH - filled)
|
||||
|
||||
|
||||
@@ -113,15 +113,6 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"glm": 202752,
|
||||
# Kimi
|
||||
"kimi": 262144,
|
||||
# Hugging Face Inference Providers — model IDs use org/name format
|
||||
"Qwen/Qwen3.5-397B-A17B": 131072,
|
||||
"Qwen/Qwen3.5-35B-A3B": 131072,
|
||||
"deepseek-ai/DeepSeek-V3.2": 65536,
|
||||
"moonshotai/Kimi-K2.5": 262144,
|
||||
"moonshotai/Kimi-K2-Thinking": 262144,
|
||||
"MiniMaxAI/MiniMax-M2.5": 204800,
|
||||
"XiaomiMiMo/MiMo-V2-Flash": 32768,
|
||||
"zai-org/GLM-5": 202752,
|
||||
}
|
||||
|
||||
_CONTEXT_LENGTH_KEYS = (
|
||||
@@ -171,7 +162,6 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"dashscope.aliyuncs.com": "alibaba",
|
||||
"dashscope-intl.aliyuncs.com": "alibaba",
|
||||
"openrouter.ai": "openrouter",
|
||||
"generativelanguage.googleapis.com": "google",
|
||||
"inference-api.nousresearch.com": "nous",
|
||||
"api.deepseek.com": "deepseek",
|
||||
"api.githubcopilot.com": "copilot",
|
||||
|
||||
+4
-4
@@ -15,8 +15,6 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils import atomic_json_write
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,10 +64,12 @@ def _load_disk_cache() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _save_disk_cache(data: Dict[str, Any]) -> None:
|
||||
"""Save models.dev data to disk cache atomically."""
|
||||
"""Save models.dev data to disk cache."""
|
||||
try:
|
||||
cache_path = _get_cache_path()
|
||||
atomic_json_write(cache_path, data, indent=None, separators=(",", ":"))
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save models.dev disk cache: %s", e)
|
||||
|
||||
|
||||
+1
-78
@@ -18,7 +18,6 @@ from typing import Optional
|
||||
from agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
get_all_skills_dirs,
|
||||
get_disabled_skill_names,
|
||||
iter_skill_index_files,
|
||||
parse_frontmatter,
|
||||
@@ -170,25 +169,6 @@ SKILLS_GUIDANCE = (
|
||||
"Skills that aren't maintained become liabilities."
|
||||
)
|
||||
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
"# Tool-use enforcement\n"
|
||||
"You MUST use your tools to take action — do not describe what you would do "
|
||||
"or plan to do without actually doing it. When you say you will perform an "
|
||||
"action (e.g. 'I will run the tests', 'Let me check the file', 'I will create "
|
||||
"the project'), you MUST immediately make the corresponding tool call in the same "
|
||||
"response. Never end your turn with a promise of future action — execute it now.\n"
|
||||
"Keep working until the task is actually complete. Do not stop with a summary of "
|
||||
"what you plan to do next time. If you have tools available that can accomplish "
|
||||
"the task, use them instead of telling the user what you would do.\n"
|
||||
"Every response should either (a) contain tool calls that make progress, or "
|
||||
"(b) deliver a final result to the user. Responses that only describe intentions "
|
||||
"without acting are not acceptable."
|
||||
)
|
||||
|
||||
# Model name substrings that trigger tool-use enforcement guidance.
|
||||
# Add new patterns here when a model family needs explicit steering.
|
||||
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex")
|
||||
|
||||
PLATFORM_HINTS = {
|
||||
"whatsapp": (
|
||||
"You are on a text messaging communication platform, WhatsApp. "
|
||||
@@ -445,23 +425,16 @@ def build_skills_system_prompt(
|
||||
mtime/size manifest — survives process restarts
|
||||
|
||||
Falls back to a full filesystem scan when both layers miss.
|
||||
|
||||
External skill directories (``skills.external_dirs`` in config.yaml) are
|
||||
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
||||
are read-only — they appear in the index but new skills are always created
|
||||
in the local dir. Local skills take precedence when names collide.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
skills_dir = hermes_home / "skills"
|
||||
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
||||
|
||||
if not skills_dir.exists() and not external_dirs:
|
||||
if not skills_dir.exists():
|
||||
return ""
|
||||
|
||||
# ── Layer 1: in-process LRU cache ─────────────────────────────────
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
tuple(sorted(str(t) for t in (available_tools or set()))),
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
)
|
||||
@@ -548,56 +521,6 @@ def build_skills_system_prompt(
|
||||
category_descriptions,
|
||||
)
|
||||
|
||||
# ── External skill directories ─────────────────────────────────────
|
||||
# Scan external dirs directly (no snapshot caching — they're read-only
|
||||
# and typically small). Local skills already in skills_by_category take
|
||||
# precedence: we track seen names and skip duplicates from external dirs.
|
||||
seen_skill_names: set[str] = set()
|
||||
for cat_skills in skills_by_category.values():
|
||||
for name, _desc in cat_skills:
|
||||
seen_skill_names.add(name)
|
||||
|
||||
for ext_dir in external_dirs:
|
||||
if not ext_dir.exists():
|
||||
continue
|
||||
for skill_file in iter_skill_index_files(ext_dir, "SKILL.md"):
|
||||
try:
|
||||
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
|
||||
if not is_compatible:
|
||||
continue
|
||||
entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc)
|
||||
skill_name = entry["skill_name"]
|
||||
if skill_name in seen_skill_names:
|
||||
continue
|
||||
if entry["frontmatter_name"] in disabled or skill_name in disabled:
|
||||
continue
|
||||
if not _skill_should_show(
|
||||
extract_skill_conditions(frontmatter),
|
||||
available_tools,
|
||||
available_toolsets,
|
||||
):
|
||||
continue
|
||||
seen_skill_names.add(skill_name)
|
||||
skills_by_category.setdefault(entry["category"], []).append(
|
||||
(skill_name, entry["description"])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error reading external skill %s: %s", skill_file, e)
|
||||
|
||||
# External category descriptions
|
||||
for desc_file in iter_skill_index_files(ext_dir, "DESCRIPTION.md"):
|
||||
try:
|
||||
content = desc_file.read_text(encoding="utf-8")
|
||||
fm, _ = parse_frontmatter(content)
|
||||
cat_desc = fm.get("description")
|
||||
if not cat_desc:
|
||||
continue
|
||||
rel = desc_file.relative_to(ext_dir)
|
||||
cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
|
||||
category_descriptions.setdefault(cat, str(cat_desc).strip().strip("'\""))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
||||
|
||||
if not skills_by_category:
|
||||
result = ""
|
||||
else:
|
||||
|
||||
+30
-45
@@ -128,11 +128,7 @@ def _build_skill_message(
|
||||
supporting.append(rel)
|
||||
|
||||
if supporting and skill_dir:
|
||||
try:
|
||||
skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
|
||||
except ValueError:
|
||||
# Skill is from an external dir — use the skill name instead
|
||||
skill_view_target = skill_dir.name
|
||||
skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
|
||||
parts.append("")
|
||||
parts.append("[This skill has supporting files you can load with the skill_view tool:]")
|
||||
for sf in supporting:
|
||||
@@ -162,49 +158,38 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
_skill_commands = {}
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
if not SKILLS_DIR.exists():
|
||||
return _skill_commands
|
||||
disabled = _get_disabled_skill_names()
|
||||
seen_names: set = set()
|
||||
|
||||
# Scan local dir first, then external dirs
|
||||
dirs_to_scan = []
|
||||
if SKILLS_DIR.exists():
|
||||
dirs_to_scan.append(SKILLS_DIR)
|
||||
dirs_to_scan.extend(get_external_skills_dirs())
|
||||
|
||||
for scan_dir in dirs_to_scan:
|
||||
for skill_md in scan_dir.rglob("SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
continue
|
||||
try:
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
try:
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
name = frontmatter.get('name', skill_md.parent.name)
|
||||
if name in seen_names:
|
||||
continue
|
||||
# Respect user's disabled skills config
|
||||
if name in disabled:
|
||||
continue
|
||||
description = frontmatter.get('description', '')
|
||||
if not description:
|
||||
for line in body.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
description = line[:80]
|
||||
break
|
||||
seen_names.add(name)
|
||||
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
|
||||
_skill_commands[f"/{cmd_name}"] = {
|
||||
"name": name,
|
||||
"description": description or f"Invoke the {name} skill",
|
||||
"skill_md_path": str(skill_md),
|
||||
"skill_dir": str(skill_md.parent),
|
||||
}
|
||||
except Exception:
|
||||
name = frontmatter.get('name', skill_md.parent.name)
|
||||
# Respect user's disabled skills config
|
||||
if name in disabled:
|
||||
continue
|
||||
description = frontmatter.get('description', '')
|
||||
if not description:
|
||||
for line in body.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
description = line[:80]
|
||||
break
|
||||
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
|
||||
_skill_commands[f"/{cmd_name}"] = {
|
||||
"name": name,
|
||||
"description": description or f"Invoke the {name} skill",
|
||||
"skill_md_path": str(skill_md),
|
||||
"skill_dir": str(skill_md.parent),
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return _skill_commands
|
||||
|
||||
@@ -158,73 +158,6 @@ def _normalize_string_set(values) -> Set[str]:
|
||||
return {str(v).strip() for v in values if str(v).strip()}
|
||||
|
||||
|
||||
# ── External skills directories ──────────────────────────────────────────
|
||||
|
||||
|
||||
def get_external_skills_dirs() -> List[Path]:
|
||||
"""Read ``skills.external_dirs`` from config.yaml and return validated paths.
|
||||
|
||||
Each entry is expanded (``~`` and ``${VAR}``) and resolved to an absolute
|
||||
path. Only directories that actually exist are returned. Duplicates and
|
||||
paths that resolve to the local ``~/.hermes/skills/`` are silently skipped.
|
||||
"""
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return []
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, dict):
|
||||
return []
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
if not isinstance(skills_cfg, dict):
|
||||
return []
|
||||
|
||||
raw_dirs = skills_cfg.get("external_dirs")
|
||||
if not raw_dirs:
|
||||
return []
|
||||
if isinstance(raw_dirs, str):
|
||||
raw_dirs = [raw_dirs]
|
||||
if not isinstance(raw_dirs, list):
|
||||
return []
|
||||
|
||||
local_skills = (get_hermes_home() / "skills").resolve()
|
||||
seen: Set[Path] = set()
|
||||
result: List[Path] = []
|
||||
|
||||
for entry in raw_dirs:
|
||||
entry = str(entry).strip()
|
||||
if not entry:
|
||||
continue
|
||||
# Expand ~ and environment variables
|
||||
expanded = os.path.expanduser(os.path.expandvars(entry))
|
||||
p = Path(expanded).resolve()
|
||||
if p == local_skills:
|
||||
continue
|
||||
if p in seen:
|
||||
continue
|
||||
if p.is_dir():
|
||||
seen.add(p)
|
||||
result.append(p)
|
||||
else:
|
||||
logger.debug("External skills dir does not exist, skipping: %s", p)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_all_skills_dirs() -> List[Path]:
|
||||
"""Return all skill directories: local ``~/.hermes/skills/`` first, then external.
|
||||
|
||||
The local dir is always first (and always included even if it doesn't exist
|
||||
yet — callers handle that). External dirs follow in config order.
|
||||
"""
|
||||
dirs = [get_hermes_home() / "skills"]
|
||||
dirs.extend(get_external_skills_dirs())
|
||||
return dirs
|
||||
|
||||
|
||||
# ── Condition extraction ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ _TITLE_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def generate_title(user_message: str, assistant_response: str, timeout: float = 30.0) -> Optional[str]:
|
||||
def generate_title(user_message: str, assistant_response: str, timeout: float = 15.0) -> Optional[str]:
|
||||
"""Generate a session title from the first exchange.
|
||||
|
||||
Uses the auxiliary LLM client (cheapest/fastest available model).
|
||||
|
||||
+8
-36
@@ -7,33 +7,17 @@
|
||||
# =============================================================================
|
||||
model:
|
||||
# Default model to use (can be overridden with --model flag)
|
||||
# Both "default" and "model" work as the key name here.
|
||||
default: "anthropic/claude-opus-4.6"
|
||||
|
||||
# Inference provider selection:
|
||||
# "auto" - Auto-detect from credentials (default)
|
||||
# "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY)
|
||||
# "nous" - Nous Portal OAuth (requires: hermes login)
|
||||
# "nous-api" - Nous Portal API key (requires: NOUS_API_KEY)
|
||||
# "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY)
|
||||
# "openai-codex" - OpenAI Codex (requires: hermes login --provider openai-codex)
|
||||
# "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN)
|
||||
# "zai" - z.ai / ZhipuAI GLM (requires: GLM_API_KEY)
|
||||
# "kimi-coding" - Kimi / Moonshot AI (requires: KIMI_API_KEY)
|
||||
# "minimax" - MiniMax global (requires: MINIMAX_API_KEY)
|
||||
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
|
||||
# "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
|
||||
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
|
||||
# "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default)
|
||||
# "nous-api" - Use Nous Portal via API key (requires: NOUS_API_KEY)
|
||||
# "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY
|
||||
# "nous" - Always use Nous Portal (requires: hermes login)
|
||||
# "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY)
|
||||
# "kimi-coding"- Use Kimi / Moonshot AI models (requires: KIMI_API_KEY)
|
||||
# "minimax" - Use MiniMax global endpoint (requires: MINIMAX_API_KEY)
|
||||
# "minimax-cn" - Use MiniMax China endpoint (requires: MINIMAX_CN_API_KEY)
|
||||
# Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
|
||||
provider: "auto"
|
||||
|
||||
@@ -324,9 +308,6 @@ compression:
|
||||
# vision:
|
||||
# provider: "auto"
|
||||
# model: "" # e.g. "google/gemini-2.5-flash", "openai/gpt-4o"
|
||||
# timeout: 30 # LLM API call timeout (seconds)
|
||||
# download_timeout: 30 # Image HTTP download timeout (seconds)
|
||||
# # Increase for slow connections or self-hosted image servers
|
||||
#
|
||||
# # Web page scraping / summarization + browser page text extraction
|
||||
# web_extract:
|
||||
@@ -420,15 +401,6 @@ skills:
|
||||
# Set to 0 to disable.
|
||||
creation_nudge_interval: 15
|
||||
|
||||
# External skill directories — share skills across tools/agents without
|
||||
# copying them into ~/.hermes/skills/. Each path is expanded (~ and ${VAR})
|
||||
# and resolved to an absolute path. External dirs are read-only: skill
|
||||
# creation always writes to ~/.hermes/skills/. Local skills take precedence
|
||||
# when names collide.
|
||||
# external_dirs:
|
||||
# - ~/.agents/skills
|
||||
# - /home/shared/team-skills
|
||||
|
||||
# =============================================================================
|
||||
# Agent Behavior
|
||||
# =============================================================================
|
||||
|
||||
@@ -70,7 +70,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
|
||||
|
||||
# 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_constants import get_hermes_home, display_hermes_home, OPENROUTER_BASE_URL
|
||||
from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
_hermes_home = get_hermes_home()
|
||||
@@ -449,14 +449,6 @@ try:
|
||||
except Exception:
|
||||
pass # Skin engine is optional — default skin used if unavailable
|
||||
|
||||
# Initialize tool preview length from config
|
||||
try:
|
||||
from agent.display import set_tool_preview_max_len
|
||||
_tpl = CLI_CONFIG.get("display", {}).get("tool_preview_length", 0)
|
||||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are
|
||||
# created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop()
|
||||
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
|
||||
@@ -1086,12 +1078,12 @@ class HermesCLI:
|
||||
# authoritative. This avoids conflicts in multi-agent setups where
|
||||
# env vars would stomp each other.
|
||||
_model_config = CLI_CONFIG.get("model", {})
|
||||
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
|
||||
_DEFAULT_CONFIG_MODEL = "anthropic/claude-opus-4.6"
|
||||
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
|
||||
# Auto-detect model from local server if still on default
|
||||
if self.model == _DEFAULT_CONFIG_MODEL:
|
||||
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
|
||||
_config_model = _model_config.get("default", "") if isinstance(_model_config, dict) else (_model_config or "")
|
||||
_FALLBACK_MODEL = "anthropic/claude-opus-4.6"
|
||||
self.model = model or _config_model or _FALLBACK_MODEL
|
||||
# Auto-detect model from local server if still on fallback
|
||||
if self.model == _FALLBACK_MODEL:
|
||||
_base_url = _model_config.get("base_url", "") if isinstance(_model_config, dict) else ""
|
||||
if "localhost" in _base_url or "127.0.0.1" in _base_url:
|
||||
from hermes_cli.runtime_provider import _auto_detect_local_model
|
||||
_detected = _auto_detect_local_model(_base_url)
|
||||
@@ -1104,7 +1096,7 @@ class HermesCLI:
|
||||
# explicit choice — the user just never changed it. But a config model
|
||||
# like "gpt-5.3-codex" IS explicit and must be preserved.
|
||||
self._model_is_default = not model and (
|
||||
not _config_model or _config_model == _DEFAULT_CONFIG_MODEL
|
||||
not _config_model or _config_model == _FALLBACK_MODEL
|
||||
)
|
||||
|
||||
self._explicit_api_key = api_key
|
||||
@@ -1190,13 +1182,9 @@ class HermesCLI:
|
||||
self._provider_require_params = pr.get("require_parameters", False)
|
||||
self._provider_data_collection = pr.get("data_collection")
|
||||
|
||||
# Fallback provider chain — tried in order when primary fails after retries.
|
||||
# Supports new list format (fallback_providers) and legacy single-dict (fallback_model).
|
||||
fb = CLI_CONFIG.get("fallback_providers") or CLI_CONFIG.get("fallback_model") or []
|
||||
# Normalize legacy single-dict to a one-element list
|
||||
if isinstance(fb, dict):
|
||||
fb = [fb] if fb.get("provider") and fb.get("model") else []
|
||||
self._fallback_model = fb
|
||||
# Fallback model config — tried when primary provider fails after retries
|
||||
fb = CLI_CONFIG.get("fallback_model") or {}
|
||||
self._fallback_model = fb if fb.get("provider") and fb.get("model") else None
|
||||
|
||||
# Optional cheap-vs-strong routing for simple turns
|
||||
self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {}
|
||||
@@ -1355,49 +1343,6 @@ class HermesCLI:
|
||||
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _status_bar_display_width(text: str) -> int:
|
||||
"""Return terminal cell width for status-bar text.
|
||||
|
||||
len() is not enough for prompt_toolkit layout decisions because some
|
||||
glyphs can render wider than one Python codepoint. Keeping the status
|
||||
bar within the real display width prevents it from wrapping onto a
|
||||
second line and leaving behind duplicate rows.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
return get_cwidth(text or "")
|
||||
except Exception:
|
||||
return len(text or "")
|
||||
|
||||
@classmethod
|
||||
def _trim_status_bar_text(cls, text: str, max_width: int) -> str:
|
||||
"""Trim status-bar text to a single terminal row."""
|
||||
if max_width <= 0:
|
||||
return ""
|
||||
try:
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
except Exception:
|
||||
get_cwidth = None
|
||||
|
||||
if cls._status_bar_display_width(text) <= max_width:
|
||||
return text
|
||||
|
||||
ellipsis = "..."
|
||||
ellipsis_width = cls._status_bar_display_width(ellipsis)
|
||||
if max_width <= ellipsis_width:
|
||||
return ellipsis[:max_width]
|
||||
|
||||
out = []
|
||||
width = 0
|
||||
for ch in text:
|
||||
ch_width = get_cwidth(ch) if get_cwidth else len(ch)
|
||||
if width + ch_width + ellipsis_width > max_width:
|
||||
break
|
||||
out.append(ch)
|
||||
width += ch_width
|
||||
return "".join(out).rstrip() + ellipsis
|
||||
|
||||
def _build_status_bar_text(self, width: Optional[int] = None) -> str:
|
||||
try:
|
||||
snapshot = self._get_status_bar_snapshot()
|
||||
@@ -1412,12 +1357,11 @@ class HermesCLI:
|
||||
duration_label = snapshot["duration"]
|
||||
|
||||
if width < 52:
|
||||
text = f"⚕ {snapshot['model_short']} · {duration_label}"
|
||||
return self._trim_status_bar_text(text, width)
|
||||
return f"⚕ {snapshot['model_short']} · {duration_label}"
|
||||
if width < 76:
|
||||
parts = [f"⚕ {snapshot['model_short']}", percent_label]
|
||||
parts.append(duration_label)
|
||||
return self._trim_status_bar_text(" · ".join(parts), width)
|
||||
return " · ".join(parts)
|
||||
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
@@ -1428,7 +1372,7 @@ class HermesCLI:
|
||||
|
||||
parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label]
|
||||
parts.append(duration_label)
|
||||
return self._trim_status_bar_text(" │ ".join(parts), width)
|
||||
return " │ ".join(parts)
|
||||
except Exception:
|
||||
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
||||
|
||||
@@ -1450,54 +1394,53 @@ class HermesCLI:
|
||||
duration_label = snapshot["duration"]
|
||||
|
||||
if width < 52:
|
||||
frags = [
|
||||
return [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " · "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
]
|
||||
|
||||
percent = snapshot["context_percent"]
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
if width < 76:
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " · "),
|
||||
(self._status_bar_context_style(percent), percent_label),
|
||||
]
|
||||
frags.extend([
|
||||
("class:status-bar-dim", " · "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
])
|
||||
return frags
|
||||
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
||||
context_label = f"{ctx_used}/{ctx_total}"
|
||||
else:
|
||||
percent = snapshot["context_percent"]
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
if width < 76:
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " · "),
|
||||
(self._status_bar_context_style(percent), percent_label),
|
||||
("class:status-bar-dim", " · "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
]
|
||||
else:
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
||||
context_label = f"{ctx_used}/{ctx_total}"
|
||||
else:
|
||||
context_label = "ctx --"
|
||||
context_label = "ctx --"
|
||||
|
||||
bar_style = self._status_bar_context_style(percent)
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", context_label),
|
||||
("class:status-bar-dim", " │ "),
|
||||
(bar_style, self._build_context_bar(percent)),
|
||||
("class:status-bar-dim", " "),
|
||||
(bar_style, percent_label),
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
]
|
||||
|
||||
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
|
||||
if total_width > width:
|
||||
plain_text = "".join(text for _, text in frags)
|
||||
trimmed = self._trim_status_bar_text(plain_text, width)
|
||||
return [("class:status-bar", trimmed)]
|
||||
bar_style = self._status_bar_context_style(percent)
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", context_label),
|
||||
("class:status-bar-dim", " │ "),
|
||||
(bar_style, self._build_context_bar(percent)),
|
||||
("class:status-bar-dim", " "),
|
||||
(bar_style, percent_label),
|
||||
]
|
||||
frags.extend([
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
])
|
||||
return frags
|
||||
except Exception:
|
||||
return [("class:status-bar", f" {self._build_status_bar_text()} ")]
|
||||
@@ -2789,12 +2732,22 @@ class HermesCLI:
|
||||
print(f" MCP tool: /tools {subcommand} github:create_issue")
|
||||
return
|
||||
|
||||
# Apply the change directly — the user typing the command is implicit
|
||||
# consent. Do NOT use input() here; it hangs inside prompt_toolkit's
|
||||
# TUI event loop (known pitfall).
|
||||
verb = "Disabling" if subcommand == "disable" else "Enabling"
|
||||
# Confirm session reset before applying
|
||||
verb = "Disable" if subcommand == "disable" else "Enable"
|
||||
label = ", ".join(names)
|
||||
_cprint(f"{_GOLD}{verb} {label}...{_RST}")
|
||||
_cprint(f"{_GOLD}{verb} {label}?{_RST}")
|
||||
_cprint(f"{_DIM}This will save to config and reset your session so the "
|
||||
f"change takes effect cleanly.{_RST}")
|
||||
try:
|
||||
answer = input(" Continue? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
_cprint(f"{_DIM}Cancelled.{_RST}")
|
||||
return
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
_cprint(f"{_DIM}Cancelled.{_RST}")
|
||||
return
|
||||
|
||||
tools_disable_enable_command(
|
||||
Namespace(tools_action=subcommand, names=names, platform="cli"))
|
||||
@@ -3641,7 +3594,7 @@ class HermesCLI:
|
||||
print(" To start the gateway:")
|
||||
print(" python cli.py --gateway")
|
||||
print()
|
||||
print(f" Configuration file: {display_hermes_home()}/config.yaml")
|
||||
print(" Configuration file: ~/.hermes/config.yaml")
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
@@ -3651,7 +3604,7 @@ class HermesCLI:
|
||||
print(" 1. Set environment variables:")
|
||||
print(" TELEGRAM_BOT_TOKEN=your_token")
|
||||
print(" DISCORD_BOT_TOKEN=your_token")
|
||||
print(f" 2. Or configure settings in {display_hermes_home()}/config.yaml")
|
||||
print(" 2. Or configure settings in ~/.hermes/config.yaml")
|
||||
print()
|
||||
|
||||
def process_command(self, command: str) -> bool:
|
||||
@@ -3846,10 +3799,6 @@ class HermesCLI:
|
||||
self._show_insights(cmd_original)
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "reload":
|
||||
from hermes_cli.config import reload_env
|
||||
count = reload_env()
|
||||
print(f" Reloaded .env ({count} var(s) updated)")
|
||||
elif canonical == "reload-mcp":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
@@ -3862,7 +3811,7 @@ class HermesCLI:
|
||||
plugins = mgr.list_plugins()
|
||||
if not plugins:
|
||||
print("No plugins installed.")
|
||||
print(f"Drop plugin directories into {display_hermes_home()}/plugins/ to get started.")
|
||||
print("Drop plugin directories into ~/.hermes/plugins/ to get started.")
|
||||
else:
|
||||
print(f"Plugins ({len(plugins)}):")
|
||||
for p in plugins:
|
||||
@@ -4085,17 +4034,6 @@ class HermesCLI:
|
||||
provider_data_collection=self._provider_data_collection,
|
||||
fallback_model=self._fallback_model,
|
||||
)
|
||||
# Silence raw spinner; route thinking through TUI widget when no foreground agent is active.
|
||||
bg_agent._print_fn = lambda *_a, **_kw: None
|
||||
|
||||
def _bg_thinking(text: str) -> None:
|
||||
# Concurrent bg tasks may race on _spinner_text; acceptable for best-effort UI.
|
||||
if not self._agent_running:
|
||||
self._spinner_text = text
|
||||
if self._app:
|
||||
self._app.invalidate()
|
||||
|
||||
bg_agent.thinking_callback = _bg_thinking
|
||||
|
||||
result = bg_agent.run_conversation(
|
||||
user_message=prompt,
|
||||
@@ -4158,9 +4096,6 @@ class HermesCLI:
|
||||
_cprint(f" ❌ Background task #{task_num} failed: {e}")
|
||||
finally:
|
||||
self._background_tasks.pop(task_id, None)
|
||||
# Clear spinner only if no foreground agent owns it
|
||||
if not self._agent_running:
|
||||
self._spinner_text = ""
|
||||
if self._app:
|
||||
self._invalidate(min_interval=0)
|
||||
|
||||
@@ -4391,7 +4326,7 @@ class HermesCLI:
|
||||
source = f" ({s['source']})" if s["source"] == "user" else ""
|
||||
print(f" {marker} {s['name']}{source} — {s['description']}")
|
||||
print("\n Usage: /skin <name>")
|
||||
print(f" Custom skins: drop a YAML file in {display_hermes_home()}/skins/\n")
|
||||
print(" Custom skins: drop a YAML file in ~/.hermes/skins/\n")
|
||||
return
|
||||
|
||||
new_skin = parts[1].strip().lower()
|
||||
@@ -4571,7 +4506,7 @@ class HermesCLI:
|
||||
compressor = agent.context_compressor
|
||||
last_prompt = compressor.last_prompt_tokens
|
||||
ctx_len = compressor.context_length
|
||||
pct = min(100, (last_prompt / ctx_len * 100)) if ctx_len else 0
|
||||
pct = (last_prompt / ctx_len * 100) if ctx_len else 0
|
||||
compressions = compressor.compression_count
|
||||
|
||||
msg_count = len(self.conversation_history)
|
||||
@@ -4829,10 +4764,8 @@ class HermesCLI:
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(function_name)
|
||||
label = preview or function_name
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
if _pl > 0 and len(label) > _pl:
|
||||
label = label[:_pl - 3] + "..."
|
||||
if len(label) > 50:
|
||||
label = label[:47] + "..."
|
||||
self._spinner_text = f"{emoji} {label}"
|
||||
self._invalidate()
|
||||
|
||||
@@ -5601,13 +5534,6 @@ class HermesCLI:
|
||||
except Exception as e:
|
||||
logging.debug("@ context reference expansion failed: %s", e)
|
||||
|
||||
# Sanitize surrogate characters that can arrive via clipboard paste from
|
||||
# rich-text editors (Google Docs, Word, etc.). Lone surrogates are invalid
|
||||
# UTF-8 and crash JSON serialization in the OpenAI SDK.
|
||||
if isinstance(message, str):
|
||||
from run_agent import _sanitize_surrogates
|
||||
message = _sanitize_surrogates(message)
|
||||
|
||||
# Add user message to history
|
||||
self.conversation_history.append({"role": "user", "content": message})
|
||||
|
||||
@@ -5965,22 +5891,10 @@ class HermesCLI:
|
||||
else:
|
||||
duration_str = f"{seconds}s"
|
||||
|
||||
# Look up session title for resume-by-name hint
|
||||
session_title = None
|
||||
if self._session_db:
|
||||
try:
|
||||
session_title = self._session_db.get_session_title(self.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Resume this session with:")
|
||||
print(f" hermes --resume {self.session_id}")
|
||||
if session_title:
|
||||
print(f" hermes -c \"{session_title}\"")
|
||||
print()
|
||||
print(f"Session: {self.session_id}")
|
||||
if session_title:
|
||||
print(f"Title: {session_title}")
|
||||
print(f"Duration: {duration_str}")
|
||||
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
|
||||
else:
|
||||
@@ -5997,9 +5911,6 @@ class HermesCLI:
|
||||
``normal_prompt`` is the full ``branding.prompt_symbol``.
|
||||
``state_suffix`` is what special states (sudo/secret/approval/agent)
|
||||
should render after their leading icon.
|
||||
|
||||
When a profile is active (not "default"), the profile name is
|
||||
prepended to the prompt symbol: ``coder ❯`` instead of ``❯``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_prompt_symbol
|
||||
@@ -6008,15 +5919,6 @@ class HermesCLI:
|
||||
symbol = "❯ "
|
||||
|
||||
symbol = (symbol or "❯ ").rstrip() + " "
|
||||
|
||||
# Prepend profile name when not default
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name()
|
||||
if profile not in ("default", "custom"):
|
||||
symbol = f"{profile} {symbol}"
|
||||
except Exception:
|
||||
pass
|
||||
stripped = symbol.rstrip()
|
||||
if not stripped:
|
||||
return "❯ ", "❯ "
|
||||
@@ -6168,7 +6070,7 @@ class HermesCLI:
|
||||
from honcho_integration.client import HonchoClientConfig
|
||||
from agent.display import honcho_session_line, write_tty
|
||||
hcfg = HonchoClientConfig.from_global_config()
|
||||
if hcfg.enabled and (hcfg.api_key or hcfg.base_url) and hcfg.explicitly_configured:
|
||||
if hcfg.enabled and hcfg.api_key and hcfg.explicitly_configured:
|
||||
sname = hcfg.resolve_session_name(session_id=self.session_id)
|
||||
if sname:
|
||||
write_tty(honcho_session_line(hcfg.workspace_id, sname) + "\n")
|
||||
@@ -6204,11 +6106,6 @@ class HermesCLI:
|
||||
self._interrupt_queue = queue.Queue() # For messages typed while agent is running
|
||||
self._should_exit = False
|
||||
self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit
|
||||
|
||||
# Give plugin manager a CLI reference so plugins can inject messages
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
get_plugin_manager()._cli_ref = self
|
||||
|
||||
# Config file watcher — detect mcp_servers changes and auto-reload
|
||||
from hermes_cli.config import get_config_path as _get_config_path
|
||||
_cfg_path = _get_config_path()
|
||||
@@ -6260,18 +6157,10 @@ class HermesCLI:
|
||||
set_approval_callback(self._approval_callback)
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed).
|
||||
# Warn the user if tirith is enabled in config but not available,
|
||||
# so they know command security scanning is degraded.
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
tirith_path = ensure_installed(log_failures=False)
|
||||
if tirith_path is None:
|
||||
security_cfg = self.config.get("security", {}) or {}
|
||||
tirith_enabled = security_cfg.get("tirith_enabled", True)
|
||||
if tirith_enabled:
|
||||
_cprint(f" {_DIM}⚠ tirith security scanner enabled but not available "
|
||||
f"— command scanning will use pattern matching only{_RST}")
|
||||
ensure_installed(log_failures=False)
|
||||
except Exception:
|
||||
pass # Non-fatal — fail-open at scan time if unavailable
|
||||
|
||||
@@ -6558,24 +6447,6 @@ class HermesCLI:
|
||||
self._should_exit = True
|
||||
event.app.exit()
|
||||
|
||||
@kb.add('c-z')
|
||||
def handle_ctrl_z(event):
|
||||
"""Handle Ctrl+Z - suspend process to background (Unix only)."""
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
_cprint(f"\n{_DIM}Suspend (Ctrl+Z) is not supported on Windows.{_RST}")
|
||||
event.app.invalidate()
|
||||
return
|
||||
import os, signal as _sig
|
||||
from prompt_toolkit.application import run_in_terminal
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent")
|
||||
msg = f"\n{agent_name} has been suspended. Run `fg` to bring {agent_name} back."
|
||||
def _suspend():
|
||||
os.write(1, msg.encode())
|
||||
os.kill(0, _sig.SIGTSTP)
|
||||
run_in_terminal(_suspend)
|
||||
|
||||
# Voice push-to-talk key: configurable via config.yaml (voice.record_key)
|
||||
# Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search)
|
||||
# Config uses "ctrl+b" format; prompt_toolkit expects "c-b" format.
|
||||
@@ -6765,7 +6636,6 @@ class HermesCLI:
|
||||
# Paste collapsing: detect large pastes and save to temp file
|
||||
_paste_counter = [0]
|
||||
_prev_text_len = [0]
|
||||
_prev_newline_count = [0]
|
||||
_paste_just_collapsed = [False]
|
||||
|
||||
def _on_text_changed(buf):
|
||||
@@ -6774,27 +6644,18 @@ class HermesCLI:
|
||||
When bracketed paste is available, handle_paste collapses
|
||||
large pastes directly. This handler is a fallback for
|
||||
terminals without bracketed paste support.
|
||||
|
||||
Two heuristics (either triggers collapse):
|
||||
1. Many characters added at once (chars_added > 1) — works
|
||||
when the terminal delivers the paste in one event-loop tick.
|
||||
2. Newline count jumped by 4+ in a single text-change event —
|
||||
catches terminals that feed characters individually but
|
||||
still batch newlines. Alt+Enter only adds 1 newline per
|
||||
event so it never triggers this.
|
||||
"""
|
||||
text = buf.text
|
||||
chars_added = len(text) - _prev_text_len[0]
|
||||
_prev_text_len[0] = len(text)
|
||||
if _paste_just_collapsed[0]:
|
||||
_paste_just_collapsed[0] = False
|
||||
_prev_newline_count[0] = text.count('\n')
|
||||
return
|
||||
line_count = text.count('\n')
|
||||
newlines_added = line_count - _prev_newline_count[0]
|
||||
_prev_newline_count[0] = line_count
|
||||
is_paste = chars_added > 1 or newlines_added >= 4
|
||||
if line_count >= 5 and is_paste and not text.startswith('/'):
|
||||
# Heuristic: a real paste adds many characters at once (not just a
|
||||
# single newline from Alt+Enter) AND the result has 5+ lines.
|
||||
# Fallback for terminals without bracketed paste support.
|
||||
if line_count >= 5 and chars_added > 1 and not text.startswith('/'):
|
||||
_paste_counter[0] += 1
|
||||
# Save to temp file
|
||||
paste_dir = _hermes_home / "pastes"
|
||||
@@ -6802,7 +6663,6 @@ class HermesCLI:
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(text, encoding="utf-8")
|
||||
# Replace buffer with compact reference
|
||||
_paste_just_collapsed[0] = True
|
||||
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
||||
buf.cursor_position = len(buf.text)
|
||||
|
||||
|
||||
+1
-14
@@ -327,20 +327,7 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get("jobs", [])
|
||||
except json.JSONDecodeError:
|
||||
# Retry with strict=False to handle bare control chars in string values
|
||||
try:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(f.read(), strict=False)
|
||||
jobs = data.get("jobs", [])
|
||||
if jobs:
|
||||
# Auto-repair: rewrite with proper escaping
|
||||
save_jobs(jobs)
|
||||
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
|
||||
return jobs
|
||||
except Exception:
|
||||
return []
|
||||
except IOError:
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return []
|
||||
|
||||
|
||||
|
||||
+17
-48
@@ -26,7 +26,6 @@ except ImportError:
|
||||
msvcrt = None
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_config
|
||||
from typing import Optional
|
||||
|
||||
from hermes_time import now as _hermes_now
|
||||
@@ -87,22 +86,6 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||
chat_id, thread_id = rest.split(":", 1)
|
||||
else:
|
||||
chat_id, thread_id = rest, None
|
||||
|
||||
# Resolve human-friendly labels like "Alice (dm)" to real IDs.
|
||||
# send_message(action="list") shows labels with display suffixes
|
||||
# that aren't valid platform IDs (e.g. WhatsApp JIDs).
|
||||
try:
|
||||
from gateway.channel_directory import resolve_channel_name
|
||||
target = chat_id
|
||||
# Strip display suffix like " (dm)" or " (group)"
|
||||
if target.endswith(")") and " (" in target:
|
||||
target = target.rsplit(" (", 1)[0].strip()
|
||||
resolved = resolve_channel_name(platform_name.lower(), target)
|
||||
if resolved:
|
||||
chat_id = resolved
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"platform": platform_name,
|
||||
"chat_id": chat_id,
|
||||
@@ -162,8 +145,6 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||
"mattermost": Platform.MATTERMOST,
|
||||
"homeassistant": Platform.HOMEASSISTANT,
|
||||
"dingtalk": Platform.DINGTALK,
|
||||
"feishu": Platform.FEISHU,
|
||||
"wecom": Platform.WECOM,
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
}
|
||||
@@ -183,29 +164,18 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||
logger.warning("Job '%s': platform '%s' not configured/enabled", job["id"], platform_name)
|
||||
return
|
||||
|
||||
# Optionally wrap the content with a header/footer so the user knows this
|
||||
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
|
||||
# in config.yaml for clean output.
|
||||
wrap_response = True
|
||||
try:
|
||||
user_cfg = load_config()
|
||||
wrap_response = user_cfg.get("cron", {}).get("wrap_response", True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if wrap_response:
|
||||
task_name = job.get("name", job["id"])
|
||||
delivery_content = (
|
||||
f"Cronjob Response: {task_name}\n"
|
||||
f"-------------\n\n"
|
||||
f"{content}\n\n"
|
||||
f"Note: The agent cannot see this message, and therefore cannot respond to it."
|
||||
)
|
||||
else:
|
||||
delivery_content = content
|
||||
# Wrap the content so the user knows this is a cron delivery and that
|
||||
# the interactive agent has no visibility into it.
|
||||
task_name = job.get("name", job["id"])
|
||||
wrapped = (
|
||||
f"Cronjob Response: {task_name}\n"
|
||||
f"-------------\n\n"
|
||||
f"{content}\n\n"
|
||||
f"Note: The agent cannot see this message, and therefore cannot respond to it."
|
||||
)
|
||||
|
||||
# Run the async send in a fresh event loop (safe from any thread)
|
||||
coro = _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id)
|
||||
coro = _send_to_platform(platform, pconfig, chat_id, wrapped, thread_id=thread_id)
|
||||
try:
|
||||
result = asyncio.run(coro)
|
||||
except RuntimeError:
|
||||
@@ -216,7 +186,7 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||
coro.close()
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id))
|
||||
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, wrapped, thread_id=thread_id))
|
||||
result = future.result(timeout=30)
|
||||
except Exception as e:
|
||||
logger.error("Job '%s': delivery to %s:%s failed: %s", job["id"], platform_name, chat_id, e)
|
||||
@@ -236,12 +206,11 @@ def _build_job_prompt(job: dict) -> str:
|
||||
# Always prepend [SILENT] guidance so the cron agent can suppress
|
||||
# delivery when it has nothing new or noteworthy to report.
|
||||
silent_hint = (
|
||||
"[SYSTEM: If you have a meaningful status report or findings, "
|
||||
"send them — that is the whole point of this job. Only respond "
|
||||
"with exactly \"[SILENT]\" (nothing else) when there is genuinely "
|
||||
"nothing new to report. [SILENT] suppresses delivery to the user. "
|
||||
"Never combine [SILENT] with content — either report your "
|
||||
"findings normally, or say [SILENT] and nothing more.]\n\n"
|
||||
"[SYSTEM: If you have nothing new or noteworthy to report, respond "
|
||||
"with exactly \"[SILENT]\" (optionally followed by a brief internal "
|
||||
"note). This suppresses delivery to the user while still saving "
|
||||
"output locally. Only use [SILENT] when there are genuinely no "
|
||||
"changes worth reporting.]\n\n"
|
||||
)
|
||||
prompt = silent_hint + prompt
|
||||
if skills is None:
|
||||
@@ -339,7 +308,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
if delivery_target.get("thread_id") is not None:
|
||||
os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"])
|
||||
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
|
||||
|
||||
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
|
||||
_cfg = {}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Hermes Agent Persona
|
||||
|
||||
<!--
|
||||
This file defines the agent's personality and tone.
|
||||
The agent will embody whatever you write here.
|
||||
Edit this to customize how Hermes communicates with you.
|
||||
|
||||
Examples:
|
||||
- "You are a warm, playful assistant who uses kaomoji occasionally."
|
||||
- "You are a concise technical expert. No fluff, just facts."
|
||||
- "You speak like a friendly coworker who happens to know everything."
|
||||
|
||||
This file is loaded fresh each message -- no restart needed.
|
||||
Delete the contents (or this file) to use the default personality.
|
||||
-->
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Docker entrypoint: bootstrap config files into the mounted volume, then run hermes.
|
||||
set -e
|
||||
|
||||
HERMES_HOME="/opt/data"
|
||||
INSTALL_DIR="/opt/hermes"
|
||||
|
||||
# Create essential directory structure. Cache and platform directories
|
||||
# (cache/images, cache/audio, platforms/whatsapp, etc.) are created on
|
||||
# demand by the application — don't pre-create them here so new installs
|
||||
# get the consolidated layout from get_hermes_dir().
|
||||
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills}
|
||||
|
||||
# .env
|
||||
if [ ! -f "$HERMES_HOME/.env" ]; then
|
||||
cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env"
|
||||
fi
|
||||
|
||||
# config.yaml
|
||||
if [ ! -f "$HERMES_HOME/config.yaml" ]; then
|
||||
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# SOUL.md
|
||||
if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
|
||||
cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md"
|
||||
fi
|
||||
|
||||
# Sync bundled skills (manifest-based so user edits are preserved)
|
||||
if [ -d "$INSTALL_DIR/skills" ]; then
|
||||
python3 "$INSTALL_DIR/tools/skills_sync.py"
|
||||
fi
|
||||
|
||||
exec hermes "$@"
|
||||
+13
-3
@@ -101,11 +101,21 @@ Available methods:
|
||||
|
||||
### Patches (`patches.py`)
|
||||
|
||||
**Problem**: Some hermes-agent tools use `asyncio.run()` internally (e.g., the Modal backend). This crashes when called from inside Atropos's event loop because `asyncio.run()` cannot be nested.
|
||||
**Problem**: Some hermes-agent tools use `asyncio.run()` internally (e.g., the Modal backend via SWE-ReX). This crashes when called from inside Atropos's event loop because `asyncio.run()` cannot be nested.
|
||||
|
||||
**Solution**: `ModalEnvironment` uses a dedicated `_AsyncWorker` background thread with its own event loop. The calling code sees a sync interface, but internally all async Modal SDK calls happen on the worker thread so they don't conflict with Atropos's loop. This is built directly into `tools/environments/modal.py` — no monkey-patching required.
|
||||
**Solution**: `patches.py` monkey-patches `SwerexModalEnvironment` to use a dedicated background thread (`_AsyncWorker`) with its own event loop. The calling code sees the same sync interface, but internally the async work happens on a separate thread that doesn't conflict with Atropos's loop.
|
||||
|
||||
`patches.py` is now a no-op (kept for backward compatibility with imports).
|
||||
What gets patched:
|
||||
- `SwerexModalEnvironment.__init__` -- creates Modal deployment on a background thread
|
||||
- `SwerexModalEnvironment.execute` -- runs commands on the same background thread
|
||||
- `SwerexModalEnvironment.stop` -- stops deployment on the background thread
|
||||
|
||||
The patches are:
|
||||
- **Idempotent** -- calling `apply_patches()` multiple times is safe
|
||||
- **Transparent** -- same interface and behavior, only the internal async execution changes
|
||||
- **Universal** -- works identically in normal CLI use (no running event loop)
|
||||
|
||||
Applied automatically at import time by `hermes_base_env.py`.
|
||||
|
||||
### Tool Call Parsers (`tool_call_parsers/`)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from model_tools import handle_function_call
|
||||
|
||||
@@ -138,6 +138,7 @@ class HermesAgentLoop:
|
||||
temperature: float = 1.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
extra_body: Optional[Dict[str, Any]] = None,
|
||||
early_stop_check: Optional[Callable[[List[Dict[str, Any]]], bool]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the agent loop.
|
||||
@@ -154,6 +155,9 @@ class HermesAgentLoop:
|
||||
extra_body: Extra parameters passed to the OpenAI client's create() call.
|
||||
Used for OpenRouter provider preferences, transforms, etc.
|
||||
e.g. {"provider": {"ignore": ["DeepInfra"]}}
|
||||
early_stop_check: Optional callback that inspects messages after each tool
|
||||
turn. If it returns True, the loop ends with finished_naturally=True.
|
||||
Used for environment-level completion signals (e.g., flag accepted).
|
||||
"""
|
||||
self.server = server
|
||||
self.tool_schemas = tool_schemas
|
||||
@@ -163,6 +167,7 @@ class HermesAgentLoop:
|
||||
self.temperature = temperature
|
||||
self.max_tokens = max_tokens
|
||||
self.extra_body = extra_body
|
||||
self.early_stop_check = early_stop_check
|
||||
|
||||
async def run(self, messages: List[Dict[str, Any]]) -> AgentResult:
|
||||
"""
|
||||
@@ -456,6 +461,23 @@ class HermesAgentLoop:
|
||||
}
|
||||
)
|
||||
|
||||
# Check if environment signals early stop (e.g., flag accepted)
|
||||
if self.early_stop_check and self.early_stop_check(messages):
|
||||
turn_elapsed = _time.monotonic() - turn_start
|
||||
logger.info(
|
||||
"[%s] turn %d: early stop triggered after %d tools (%.1fs)",
|
||||
self.task_id[:8], turn + 1,
|
||||
len(assistant_msg.tool_calls), turn_elapsed,
|
||||
)
|
||||
return AgentResult(
|
||||
messages=messages,
|
||||
managed_state=self._get_managed_state(),
|
||||
turns_used=turn + 1,
|
||||
finished_naturally=True,
|
||||
reasoning_per_turn=reasoning_per_turn,
|
||||
tool_errors=tool_errors,
|
||||
)
|
||||
|
||||
turn_elapsed = _time.monotonic() - turn_start
|
||||
logger.info(
|
||||
"[%s] turn %d: api=%.1fs, %d tools, turn_total=%.1fs",
|
||||
|
||||
@@ -209,7 +209,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv):
|
||||
|
||||
# Agent settings -- TB2 tasks are complex, need many turns
|
||||
max_agent_turns=60,
|
||||
max_token_length=16000,
|
||||
max_token_length=***
|
||||
agent_temperature=0.6,
|
||||
system_prompt=None,
|
||||
|
||||
@@ -233,7 +233,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv):
|
||||
steps_per_eval=1,
|
||||
total_steps=1,
|
||||
|
||||
tokenizer_name="NousResearch/Hermes-3-Llama-3.1-8B",
|
||||
tokenizer_name="NousRe...1-8B",
|
||||
use_wandb=True,
|
||||
wandb_name="terminal-bench-2",
|
||||
ensure_scores_are_not_same=False, # Binary rewards may all be 0 or 1
|
||||
@@ -245,7 +245,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv):
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model_name="anthropic/claude-sonnet-4",
|
||||
server_type="openai",
|
||||
api_key=os.getenv("OPENROUTER_API_KEY", ""),
|
||||
api_key=os.get...EY", ""),
|
||||
health_check=False,
|
||||
)
|
||||
]
|
||||
@@ -513,446 +513,3 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv):
|
||||
reward = 0.0
|
||||
else:
|
||||
# Run tests in a thread so the blocking ctx.terminal() calls
|
||||
# don't freeze the entire event loop (which would stall all
|
||||
# other tasks, tqdm updates, and timeout timers).
|
||||
ctx = ToolContext(task_id)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
reward = await loop.run_in_executor(
|
||||
None, # default thread pool
|
||||
self._run_tests, eval_item, ctx, task_name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Task %s: test verification failed: %s", task_name, e)
|
||||
reward = 0.0
|
||||
finally:
|
||||
ctx.cleanup()
|
||||
|
||||
passed = reward == 1.0
|
||||
status = "PASS" if passed else "FAIL"
|
||||
elapsed = time.time() - task_start
|
||||
tqdm.write(f" [{status}] {task_name} (turns={result.turns_used}, {elapsed:.0f}s)")
|
||||
logger.info(
|
||||
"Task %s: reward=%.1f, turns=%d, finished=%s",
|
||||
task_name, reward, result.turns_used, result.finished_naturally,
|
||||
)
|
||||
|
||||
out = {
|
||||
"passed": passed,
|
||||
"reward": reward,
|
||||
"task_name": task_name,
|
||||
"category": category,
|
||||
"turns_used": result.turns_used,
|
||||
"finished_naturally": result.finished_naturally,
|
||||
"messages": result.messages,
|
||||
}
|
||||
self._save_result(out)
|
||||
return out
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.time() - task_start
|
||||
logger.error("Task %s: rollout failed: %s", task_name, e, exc_info=True)
|
||||
tqdm.write(f" [ERROR] {task_name}: {e} ({elapsed:.0f}s)")
|
||||
out = {
|
||||
"passed": False, "reward": 0.0,
|
||||
"task_name": task_name, "category": category,
|
||||
"error": str(e),
|
||||
}
|
||||
self._save_result(out)
|
||||
return out
|
||||
|
||||
finally:
|
||||
# --- Cleanup: clear overrides, sandbox, and temp files ---
|
||||
clear_task_env_overrides(task_id)
|
||||
try:
|
||||
cleanup_vm(task_id)
|
||||
except Exception as e:
|
||||
logger.debug("VM cleanup for %s: %s", task_id[:8], e)
|
||||
if task_dir and task_dir.exists():
|
||||
shutil.rmtree(task_dir, ignore_errors=True)
|
||||
|
||||
def _run_tests(
|
||||
self, item: Dict[str, Any], ctx: ToolContext, task_name: str
|
||||
) -> float:
|
||||
"""
|
||||
Upload and execute the test suite in the agent's sandbox, then
|
||||
download the verifier output locally to read the reward.
|
||||
|
||||
Follows Harbor's verification pattern:
|
||||
1. Upload tests/ directory into the sandbox
|
||||
2. Execute test.sh inside the sandbox
|
||||
3. Download /logs/verifier/ directory to a local temp dir
|
||||
4. Read reward.txt locally with native Python I/O
|
||||
|
||||
Downloading locally avoids issues with the file_read tool on
|
||||
the Modal VM and matches how Harbor handles verification.
|
||||
|
||||
TB2 test scripts (test.sh) typically:
|
||||
1. Install pytest via uv/pip
|
||||
2. Run pytest against the test files in /tests/
|
||||
3. Write results to /logs/verifier/reward.txt
|
||||
|
||||
Args:
|
||||
item: The TB2 task dict (contains tests_tar, test_sh)
|
||||
ctx: ToolContext scoped to this task's sandbox
|
||||
task_name: For logging
|
||||
|
||||
Returns:
|
||||
1.0 if tests pass, 0.0 otherwise
|
||||
"""
|
||||
tests_tar = item.get("tests_tar", "")
|
||||
test_sh = item.get("test_sh", "")
|
||||
|
||||
if not test_sh:
|
||||
logger.warning("Task %s: no test_sh content, reward=0", task_name)
|
||||
return 0.0
|
||||
|
||||
# Create required directories in the sandbox
|
||||
ctx.terminal("mkdir -p /tests /logs/verifier")
|
||||
|
||||
# Upload test files into the sandbox (binary-safe via base64)
|
||||
if tests_tar:
|
||||
tests_temp = Path(tempfile.mkdtemp(prefix=f"tb2-tests-{task_name}-"))
|
||||
try:
|
||||
_extract_base64_tar(tests_tar, tests_temp)
|
||||
ctx.upload_dir(str(tests_temp), "/tests")
|
||||
except Exception as e:
|
||||
logger.warning("Task %s: failed to upload test files: %s", task_name, e)
|
||||
finally:
|
||||
shutil.rmtree(tests_temp, ignore_errors=True)
|
||||
|
||||
# Write the test runner script (test.sh)
|
||||
ctx.write_file("/tests/test.sh", test_sh)
|
||||
ctx.terminal("chmod +x /tests/test.sh")
|
||||
|
||||
# Execute the test suite
|
||||
logger.info(
|
||||
"Task %s: running test suite (timeout=%ds)",
|
||||
task_name, self.config.test_timeout,
|
||||
)
|
||||
test_result = ctx.terminal(
|
||||
"bash /tests/test.sh",
|
||||
timeout=self.config.test_timeout,
|
||||
)
|
||||
|
||||
exit_code = test_result.get("exit_code", -1)
|
||||
output = test_result.get("output", "")
|
||||
|
||||
# Download the verifier output directory locally, then read reward.txt
|
||||
# with native Python I/O. This avoids issues with file_read on the
|
||||
# Modal VM and matches Harbor's verification pattern.
|
||||
reward = 0.0
|
||||
local_verifier_dir = Path(tempfile.mkdtemp(prefix=f"tb2-verifier-{task_name}-"))
|
||||
try:
|
||||
ctx.download_dir("/logs/verifier", str(local_verifier_dir))
|
||||
|
||||
reward_file = local_verifier_dir / "reward.txt"
|
||||
if reward_file.exists() and reward_file.stat().st_size > 0:
|
||||
content = reward_file.read_text().strip()
|
||||
if content == "1":
|
||||
reward = 1.0
|
||||
elif content == "0":
|
||||
reward = 0.0
|
||||
else:
|
||||
# Unexpected content -- try parsing as float
|
||||
try:
|
||||
reward = float(content)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"Task %s: reward.txt content unexpected (%r), "
|
||||
"falling back to exit_code=%d",
|
||||
task_name, content, exit_code,
|
||||
)
|
||||
reward = 1.0 if exit_code == 0 else 0.0
|
||||
else:
|
||||
# reward.txt not written -- fall back to exit code
|
||||
logger.warning(
|
||||
"Task %s: reward.txt not found after download, "
|
||||
"falling back to exit_code=%d",
|
||||
task_name, exit_code,
|
||||
)
|
||||
reward = 1.0 if exit_code == 0 else 0.0
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Task %s: failed to download verifier dir: %s, "
|
||||
"falling back to exit_code=%d",
|
||||
task_name, e, exit_code,
|
||||
)
|
||||
reward = 1.0 if exit_code == 0 else 0.0
|
||||
finally:
|
||||
shutil.rmtree(local_verifier_dir, ignore_errors=True)
|
||||
|
||||
# Log test output for debugging failures
|
||||
if reward == 0.0:
|
||||
output_preview = output[-500:] if output else "(no output)"
|
||||
logger.info(
|
||||
"Task %s: FAIL (exit_code=%d)\n%s",
|
||||
task_name, exit_code, output_preview,
|
||||
)
|
||||
|
||||
return reward
|
||||
|
||||
# =========================================================================
|
||||
# Evaluate -- main entry point for the eval subcommand
|
||||
# =========================================================================
|
||||
|
||||
async def _eval_with_timeout(self, item: Dict[str, Any]) -> Dict:
|
||||
"""
|
||||
Wrap rollout_and_score_eval with a per-task wall-clock timeout.
|
||||
|
||||
If the task exceeds task_timeout seconds, it's automatically scored
|
||||
as FAIL. This prevents any single task from hanging indefinitely.
|
||||
"""
|
||||
task_name = item.get("task_name", "unknown")
|
||||
category = item.get("category", "unknown")
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self.rollout_and_score_eval(item),
|
||||
timeout=self.config.task_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
from tqdm import tqdm
|
||||
elapsed = self.config.task_timeout
|
||||
tqdm.write(f" [TIMEOUT] {task_name} (exceeded {elapsed}s wall-clock limit)")
|
||||
logger.error("Task %s: wall-clock timeout after %ds", task_name, elapsed)
|
||||
out = {
|
||||
"passed": False, "reward": 0.0,
|
||||
"task_name": task_name, "category": category,
|
||||
"error": f"timeout ({elapsed}s)",
|
||||
}
|
||||
self._save_result(out)
|
||||
return out
|
||||
|
||||
async def evaluate(self, *args, **kwargs) -> None:
|
||||
"""
|
||||
Run Terminal-Bench 2.0 evaluation over all tasks.
|
||||
|
||||
This is the main entry point when invoked via:
|
||||
python environments/terminalbench2_env.py evaluate
|
||||
|
||||
Runs all tasks through rollout_and_score_eval() via asyncio.gather()
|
||||
(same pattern as GPQA and other Atropos eval envs). Each task is
|
||||
wrapped with a wall-clock timeout so hung tasks auto-fail.
|
||||
|
||||
Suppresses noisy Modal/terminal output (HERMES_QUIET) so the tqdm
|
||||
bar stays visible.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Route all logging through tqdm.write() so the progress bar stays
|
||||
# pinned at the bottom while log lines scroll above it.
|
||||
from tqdm import tqdm
|
||||
|
||||
class _TqdmHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
try:
|
||||
tqdm.write(self.format(record))
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
handler = _TqdmHandler()
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
))
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler] # Replace any existing handlers
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
# Silence noisy third-party loggers that flood the output
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING) # Every HTTP request
|
||||
logging.getLogger("openai").setLevel(logging.WARNING) # OpenAI client retries
|
||||
logging.getLogger("rex-deploy").setLevel(logging.WARNING) # Swerex deployment
|
||||
logging.getLogger("rex_image_builder").setLevel(logging.WARNING) # Image builds
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Starting Terminal-Bench 2.0 Evaluation")
|
||||
print(f"{'='*60}")
|
||||
print(f" Dataset: {self.config.dataset_name}")
|
||||
print(f" Total tasks: {len(self.all_eval_items)}")
|
||||
print(f" Max agent turns: {self.config.max_agent_turns}")
|
||||
print(f" Task timeout: {self.config.task_timeout}s")
|
||||
print(f" Terminal backend: {self.config.terminal_backend}")
|
||||
print(f" Tool thread pool: {self.config.tool_pool_size}")
|
||||
print(f" Terminal timeout: {self.config.terminal_timeout}s/cmd")
|
||||
print(f" Terminal lifetime: {self.config.terminal_lifetime}s (auto: task_timeout + 120)")
|
||||
print(f" Max concurrent tasks: {self.config.max_concurrent_tasks}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# Semaphore to limit concurrent Modal sandbox creations.
|
||||
# Without this, all 86 tasks fire simultaneously, each creating a Modal
|
||||
# sandbox via asyncio.run() inside a thread pool worker. Modal's blocking
|
||||
# calls (App.lookup, etc.) deadlock when too many are created at once.
|
||||
semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
|
||||
|
||||
async def _eval_with_semaphore(item):
|
||||
async with semaphore:
|
||||
return await self._eval_with_timeout(item)
|
||||
|
||||
# Fire all tasks with wall-clock timeout, track live accuracy on the bar
|
||||
total_tasks = len(self.all_eval_items)
|
||||
eval_tasks = [
|
||||
asyncio.ensure_future(_eval_with_semaphore(item))
|
||||
for item in self.all_eval_items
|
||||
]
|
||||
|
||||
results = []
|
||||
passed_count = 0
|
||||
pbar = tqdm(total=total_tasks, desc="Evaluating TB2", dynamic_ncols=True)
|
||||
try:
|
||||
for coro in asyncio.as_completed(eval_tasks):
|
||||
result = await coro
|
||||
results.append(result)
|
||||
if result and result.get("passed"):
|
||||
passed_count += 1
|
||||
done = len(results)
|
||||
pct = (passed_count / done * 100) if done else 0
|
||||
pbar.set_postfix_str(f"pass={passed_count}/{done} ({pct:.1f}%)")
|
||||
pbar.update(1)
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
pbar.close()
|
||||
print(f"\n\nInterrupted! Cleaning up {len(eval_tasks)} tasks...")
|
||||
# Cancel all pending tasks
|
||||
for task in eval_tasks:
|
||||
task.cancel()
|
||||
# Let cancellations propagate (finally blocks run cleanup_vm)
|
||||
await asyncio.gather(*eval_tasks, return_exceptions=True)
|
||||
# Belt-and-suspenders: clean up any remaining sandboxes
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
cleanup_all_environments()
|
||||
print("All sandboxes cleaned up.")
|
||||
return
|
||||
finally:
|
||||
pbar.close()
|
||||
|
||||
end_time = time.time()
|
||||
|
||||
# Filter out None results (shouldn't happen, but be safe)
|
||||
valid_results = [r for r in results if r is not None]
|
||||
|
||||
if not valid_results:
|
||||
print("Warning: No valid evaluation results obtained")
|
||||
return
|
||||
|
||||
# ---- Compute metrics ----
|
||||
total = len(valid_results)
|
||||
passed = sum(1 for r in valid_results if r.get("passed"))
|
||||
overall_pass_rate = passed / total if total > 0 else 0.0
|
||||
|
||||
# Per-category breakdown
|
||||
cat_results: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for r in valid_results:
|
||||
cat_results[r.get("category", "unknown")].append(r)
|
||||
|
||||
# Build metrics dict
|
||||
eval_metrics = {
|
||||
"eval/pass_rate": overall_pass_rate,
|
||||
"eval/total_tasks": total,
|
||||
"eval/passed_tasks": passed,
|
||||
"eval/evaluation_time_seconds": end_time - start_time,
|
||||
}
|
||||
|
||||
# Per-category metrics
|
||||
for category, cat_items in sorted(cat_results.items()):
|
||||
cat_passed = sum(1 for r in cat_items if r.get("passed"))
|
||||
cat_total = len(cat_items)
|
||||
cat_pass_rate = cat_passed / cat_total if cat_total > 0 else 0.0
|
||||
cat_key = category.replace(" ", "_").replace("-", "_").lower()
|
||||
eval_metrics[f"eval/pass_rate_{cat_key}"] = cat_pass_rate
|
||||
|
||||
# Store metrics for wandb_log
|
||||
self.eval_metrics = [(k, v) for k, v in eval_metrics.items()]
|
||||
|
||||
# ---- Print summary ----
|
||||
print(f"\n{'='*60}")
|
||||
print("Terminal-Bench 2.0 Evaluation Results")
|
||||
print(f"{'='*60}")
|
||||
print(f"Overall Pass Rate: {overall_pass_rate:.4f} ({passed}/{total})")
|
||||
print(f"Evaluation Time: {end_time - start_time:.1f} seconds")
|
||||
|
||||
print("\nCategory Breakdown:")
|
||||
for category, cat_items in sorted(cat_results.items()):
|
||||
cat_passed = sum(1 for r in cat_items if r.get("passed"))
|
||||
cat_total = len(cat_items)
|
||||
cat_rate = cat_passed / cat_total if cat_total > 0 else 0.0
|
||||
print(f" {category}: {cat_rate:.1%} ({cat_passed}/{cat_total})")
|
||||
|
||||
# Print individual task results
|
||||
print("\nTask Results:")
|
||||
for r in sorted(valid_results, key=lambda x: x.get("task_name", "")):
|
||||
status = "PASS" if r.get("passed") else "FAIL"
|
||||
turns = r.get("turns_used", "?")
|
||||
error = r.get("error", "")
|
||||
extra = f" (error: {error})" if error else ""
|
||||
print(f" [{status}] {r['task_name']} (turns={turns}){extra}")
|
||||
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# Build sample records for evaluate_log (includes full conversations)
|
||||
samples = [
|
||||
{
|
||||
"task_name": r.get("task_name"),
|
||||
"category": r.get("category"),
|
||||
"passed": r.get("passed"),
|
||||
"reward": r.get("reward"),
|
||||
"turns_used": r.get("turns_used"),
|
||||
"error": r.get("error"),
|
||||
"messages": r.get("messages"),
|
||||
}
|
||||
for r in valid_results
|
||||
]
|
||||
|
||||
# Log evaluation results
|
||||
try:
|
||||
await self.evaluate_log(
|
||||
metrics=eval_metrics,
|
||||
samples=samples,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
generation_parameters={
|
||||
"temperature": self.config.agent_temperature,
|
||||
"max_tokens": self.config.max_token_length,
|
||||
"max_agent_turns": self.config.max_agent_turns,
|
||||
"terminal_backend": self.config.terminal_backend,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error logging evaluation results: {e}")
|
||||
|
||||
# Close streaming file
|
||||
if hasattr(self, "_streaming_file") and not self._streaming_file.closed:
|
||||
self._streaming_file.close()
|
||||
print(f" Live results saved to: {self._streaming_path}")
|
||||
|
||||
# Kill all remaining sandboxes. Timed-out tasks leave orphaned thread
|
||||
# pool workers still executing commands -- cleanup_all stops them.
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
print("\nCleaning up all sandboxes...")
|
||||
cleanup_all_environments()
|
||||
|
||||
# Shut down the tool thread pool so orphaned workers from timed-out
|
||||
# tasks are killed immediately instead of retrying against dead
|
||||
# sandboxes and spamming the console with TimeoutError warnings.
|
||||
from environments.agent_loop import _tool_executor
|
||||
_tool_executor.shutdown(wait=False, cancel_futures=True)
|
||||
print("Done.")
|
||||
|
||||
# =========================================================================
|
||||
# Wandb logging
|
||||
# =========================================================================
|
||||
|
||||
async def wandb_log(self, wandb_metrics: Optional[Dict] = None):
|
||||
"""Log TB2-specific metrics to wandb."""
|
||||
if wandb_metrics is None:
|
||||
wandb_metrics = {}
|
||||
|
||||
# Add stored eval metrics
|
||||
for metric_name, metric_value in self.eval_metrics:
|
||||
wandb_metrics[metric_name] = metric_value
|
||||
self.eval_metrics = []
|
||||
|
||||
await super().wandb_log(wandb_metrics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
TerminalBench2EvalEnv.cli()
|
||||
|
||||
@@ -176,6 +176,22 @@ class HermesAgentEnvConfig(BaseEnvConfig):
|
||||
"transforms, and other provider-specific settings.",
|
||||
)
|
||||
|
||||
# --- Security guards ---
|
||||
disable_command_guards: bool = Field(
|
||||
default=False,
|
||||
description="Disable terminal command security guards (dangerous command "
|
||||
"detection, tirith scanning, approval prompts). Enable this for RL "
|
||||
"environment runs where the agent operates inside isolated containers "
|
||||
"and needs unrestricted command execution (e.g., pwn.college challenges "
|
||||
"that require inline Python, raw sockets, binary exploitation, etc.).",
|
||||
)
|
||||
disable_secret_redaction: bool = Field(
|
||||
default=False,
|
||||
description="Disable secret/password redaction in tool output. Enable this "
|
||||
"for RL environments where the agent needs to read source code containing "
|
||||
"password fields (e.g. Flask apps in web-security challenges).",
|
||||
)
|
||||
|
||||
|
||||
class HermesAgentBaseEnv(BaseEnv):
|
||||
"""
|
||||
@@ -218,6 +234,15 @@ class HermesAgentBaseEnv(BaseEnv):
|
||||
os.environ["TERMINAL_ENV"] = config.terminal_backend
|
||||
os.environ["TERMINAL_TIMEOUT"] = str(config.terminal_timeout)
|
||||
os.environ["TERMINAL_LIFETIME_SECONDS"] = str(config.terminal_lifetime)
|
||||
|
||||
# Disable command security guards for RL environments that need
|
||||
# unrestricted execution (agent runs inside isolated containers).
|
||||
if config.disable_command_guards:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
print("🔓 Command guards disabled (disable_command_guards=true)")
|
||||
if config.disable_secret_redaction:
|
||||
os.environ["HERMES_REDACT_SECRETS"] = "false"
|
||||
print("🔓 Secret redaction disabled (disable_secret_redaction=true)")
|
||||
print(
|
||||
f"🖥️ Terminal: backend={config.terminal_backend}, "
|
||||
f"timeout={config.terminal_timeout}s, lifetime={config.terminal_lifetime}s"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .pwncollege_env import PwnCollegeEnv, PwnCollegeEnvConfig
|
||||
@@ -0,0 +1,47 @@
|
||||
# PwnCollege Training Environment
|
||||
#
|
||||
# Usage:
|
||||
# python environments/pwncollege_env/pwncollege_env.py serve \
|
||||
# --config environments/pwncollege_env/default.yaml
|
||||
#
|
||||
# python environments/pwncollege_env/pwncollege_env.py process \
|
||||
# --config environments/pwncollege_env/default.yaml \
|
||||
# --env.data_path_to_save_groups sft_data.jsonl
|
||||
|
||||
env:
|
||||
enabled_toolsets: ["terminal", "file", "pwncollege"]
|
||||
max_agent_turns: 20
|
||||
max_token_length: 16384
|
||||
agent_temperature: 0.7
|
||||
terminal_backend: "ssh"
|
||||
|
||||
# Dojo connection
|
||||
base_url: "http://100.120.55.25:8080"
|
||||
ssh_host: "100.120.55.25"
|
||||
ssh_port: 2222
|
||||
ssh_key: "environments/pwncollege_env/keys/rl_test_key"
|
||||
|
||||
# Training: challenge selection
|
||||
# challenge: "hello/hello" # Single challenge (training fallback)
|
||||
# dojo_filter: "linux-luminarium" # Filter training set by dojo
|
||||
# module_filter: "hello" # Filter training set by module
|
||||
|
||||
# Eval settings (null = all)
|
||||
eval_dojo: null
|
||||
eval_module: null
|
||||
eval_exclude_dojos: ["archive"]
|
||||
eval_concurrency: 16
|
||||
|
||||
# Atropos settings
|
||||
data_dir_to_save_evals: "eval_output/pwncollege"
|
||||
use_wandb: false
|
||||
wandb_name: "pwncollege"
|
||||
ensure_scores_are_not_same: false
|
||||
tokenizer_name: "NousResearch/Hermes-3-Llama-3.1-8B"
|
||||
|
||||
openai:
|
||||
base_url: "https://openrouter.ai/api/v1"
|
||||
model_name: "anthropic/claude-sonnet-4.5"
|
||||
server_type: "openai"
|
||||
health_check: false
|
||||
# api_key: set OPENROUTER_API_KEY in .env or shell
|
||||
@@ -0,0 +1,74 @@
|
||||
env:
|
||||
group_size: 4
|
||||
max_num_workers: -1
|
||||
max_eval_workers: 16
|
||||
max_num_workers_per_node: 8
|
||||
steps_per_eval: 100
|
||||
max_token_length: 16384
|
||||
eval_handling: STOP_TRAIN
|
||||
eval_limit_ratio: 0.5
|
||||
inference_weight: 1.0
|
||||
batch_size: -1
|
||||
max_batches_offpolicy: 3
|
||||
tokenizer_name: NousResearch/Hermes-3-Llama-3.1-8B
|
||||
use_wandb: false
|
||||
rollout_server_url: http://localhost:8000
|
||||
total_steps: 1000
|
||||
wandb_name: pwncollege-intro-cybersec-flash
|
||||
num_rollouts_to_keep: 32
|
||||
num_rollouts_per_group_for_logging: 1
|
||||
ensure_scores_are_not_same: false
|
||||
data_path_to_save_groups: null
|
||||
data_dir_to_save_evals: environments/pwncollege_env/eval_runs/intro_cybersec_flash
|
||||
min_items_sent_before_logging: 2
|
||||
include_messages: false
|
||||
min_batch_allocation: null
|
||||
worker_timeout: 600.0
|
||||
thinking_mode: false
|
||||
reasoning_effort: null
|
||||
max_reasoning_tokens: null
|
||||
custom_thinking_prompt: null
|
||||
enabled_toolsets:
|
||||
- terminal
|
||||
- file
|
||||
- pwncollege
|
||||
disabled_toolsets: null
|
||||
distribution: null
|
||||
max_agent_turns: 80
|
||||
agent_temperature: 0.7
|
||||
terminal_backend: ssh
|
||||
terminal_timeout: 120
|
||||
terminal_lifetime: 3600
|
||||
disable_command_guards: true
|
||||
dataset_name: null
|
||||
dataset_split: train
|
||||
prompt_field: prompt
|
||||
tool_pool_size: 128
|
||||
tool_call_parser: hermes
|
||||
extra_body: null
|
||||
base_url: http://100.120.55.25:8080
|
||||
ssh_host: 100.120.55.25
|
||||
ssh_port: 2222
|
||||
ssh_key: environments/pwncollege_env/keys/rl_test_key
|
||||
challenge: hello/hello
|
||||
dojo_filter: null
|
||||
module_filter: null
|
||||
eval_dojo: intro-to-cybersecurity
|
||||
eval_exclude_dojos:
|
||||
- archive
|
||||
eval_module: null
|
||||
eval_concurrency: 8
|
||||
openai:
|
||||
- timeout: 1200
|
||||
num_max_requests_at_once: 512
|
||||
num_requests_for_eval: 64
|
||||
model_name: xiaomi/mimo-v2-flash
|
||||
rolling_buffer_length: 1000
|
||||
server_type: openai
|
||||
tokenizer_name: none
|
||||
api_key: ""
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
n_kwarg_is_ignored: false
|
||||
health_check: false
|
||||
slurm: false
|
||||
testing: false
|
||||
@@ -0,0 +1,73 @@
|
||||
env:
|
||||
group_size: 4
|
||||
max_num_workers: -1
|
||||
max_eval_workers: 16
|
||||
max_num_workers_per_node: 8
|
||||
steps_per_eval: 100
|
||||
max_token_length: 16384
|
||||
eval_handling: STOP_TRAIN
|
||||
eval_limit_ratio: 0.5
|
||||
inference_weight: 1.0
|
||||
batch_size: -1
|
||||
max_batches_offpolicy: 3
|
||||
tokenizer_name: NousResearch/Hermes-3-Llama-3.1-8B
|
||||
use_wandb: false
|
||||
rollout_server_url: http://localhost:8000
|
||||
total_steps: 1000
|
||||
wandb_name: pwncollege
|
||||
num_rollouts_to_keep: 32
|
||||
num_rollouts_per_group_for_logging: 1
|
||||
ensure_scores_are_not_same: false
|
||||
data_path_to_save_groups: null
|
||||
data_dir_to_save_evals: eval_output/pwncollege
|
||||
min_items_sent_before_logging: 2
|
||||
include_messages: false
|
||||
min_batch_allocation: null
|
||||
worker_timeout: 600.0
|
||||
thinking_mode: false
|
||||
reasoning_effort: null
|
||||
max_reasoning_tokens: null
|
||||
custom_thinking_prompt: null
|
||||
enabled_toolsets:
|
||||
- terminal
|
||||
- file
|
||||
- pwncollege
|
||||
disabled_toolsets: null
|
||||
distribution: null
|
||||
max_agent_turns: 50
|
||||
agent_temperature: 0.7
|
||||
terminal_backend: ssh
|
||||
terminal_timeout: 120
|
||||
terminal_lifetime: 3600
|
||||
dataset_name: null
|
||||
dataset_split: train
|
||||
prompt_field: prompt
|
||||
tool_pool_size: 128
|
||||
tool_call_parser: hermes
|
||||
extra_body: null
|
||||
base_url: http://100.120.55.25:8080
|
||||
ssh_host: 100.120.55.25
|
||||
ssh_port: 2222
|
||||
ssh_key: environments/pwncollege_env/keys/rl_test_key
|
||||
challenge: hello/hello
|
||||
dojo_filter: null
|
||||
module_filter: null
|
||||
eval_dojo: linux-luminarium
|
||||
eval_exclude_dojos:
|
||||
- archive
|
||||
eval_module: hello
|
||||
eval_concurrency: 16
|
||||
openai:
|
||||
- timeout: 1200
|
||||
num_max_requests_at_once: 512
|
||||
num_requests_for_eval: 64
|
||||
model_name: xiaomi/mimo-v2-flash
|
||||
rolling_buffer_length: 1000
|
||||
server_type: openai
|
||||
tokenizer_name: none
|
||||
api_key: ""
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
n_kwarg_is_ignored: false
|
||||
health_check: false
|
||||
slurm: false
|
||||
testing: false
|
||||
@@ -0,0 +1,3 @@
|
||||
# SSH private keys -- never commit
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,54 @@
|
||||
env:
|
||||
# Breadth: total items to process (>= 842 challenges in dojo)
|
||||
total_steps: 850
|
||||
# Depth: completions per item (1 = max coverage speed)
|
||||
group_size: 1
|
||||
# Concurrency: match dojo max_instances (16 slots)
|
||||
eval_concurrency: 16
|
||||
|
||||
max_agent_turns: 30
|
||||
max_token_length: 16384
|
||||
agent_temperature: 0.7
|
||||
enabled_toolsets:
|
||||
- terminal
|
||||
- file
|
||||
- pwncollege
|
||||
terminal_backend: ssh
|
||||
terminal_timeout: 120
|
||||
terminal_lifetime: 3600
|
||||
disable_command_guards: true
|
||||
disable_secret_redaction: true
|
||||
tool_call_parser: hermes
|
||||
|
||||
# Dojo connection
|
||||
base_url: http://100.120.55.25:8080
|
||||
ssh_host: 100.120.55.25
|
||||
ssh_port: 2222
|
||||
ssh_key: ""
|
||||
|
||||
# No filters = all challenges
|
||||
challenge: hello/hello
|
||||
dojo_filter: null
|
||||
module_filter: null
|
||||
|
||||
# Output
|
||||
data_path_to_save_groups: environments/pwncollege_env/eval_runs/process_all.jsonl
|
||||
data_dir_to_save_evals: environments/pwncollege_env/eval_runs/process_all
|
||||
ensure_scores_are_not_same: false
|
||||
include_messages: true
|
||||
use_wandb: false
|
||||
wandb_name: pwncollege-process
|
||||
|
||||
# Not needed for process mode but required by config schema
|
||||
tokenizer_name: NousResearch/Hermes-3-Llama-3.1-8B
|
||||
rollout_server_url: http://localhost:8000
|
||||
openai:
|
||||
- timeout: 1200
|
||||
num_max_requests_at_once: 128
|
||||
model_name: xiaomi/mimo-v2-flash
|
||||
server_type: openai
|
||||
api_key: ""
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
health_check: false
|
||||
slurm: false
|
||||
testing: false
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Prompt templates for pwn.college environments."""
|
||||
|
||||
SYSTEM_PROMPT = """You are a CTF player solving security challenges on pwn.college.
|
||||
|
||||
Your goal is to find and capture the flag. Flags follow the format: `pwn.college{...}`
|
||||
|
||||
When you find a flag, use the submit_flag tool to verify it.
|
||||
|
||||
## How pwn.college Challenges Work
|
||||
|
||||
- `/challenge/run` or `/challenge/bin/<name>` — The challenge binary that checks your work
|
||||
- The flag is printed ONLY when you run the challenge binary after correctly completing the task
|
||||
- Do NOT search for a pre-existing flag file — you must earn it by solving the challenge
|
||||
|
||||
## Tips
|
||||
|
||||
- If a challenge needs a long-running process (e.g., a server or listener), run it in the background and interact with it separately.
|
||||
- For binary exploitation, check protections with `checksec` and use `python3` with `pwntools` if available.
|
||||
- Read error messages carefully — they often hint at what the challenge expects.
|
||||
- Sometimes `/challenge/run` drops you into an interactive shell inside a network namespace. To interact with it, use `tmux`:
|
||||
```
|
||||
tmux new-session -d -s challenge '/challenge/run'
|
||||
tmux send-keys -t challenge 'your-command-here' Enter
|
||||
tmux capture-pane -t challenge -p
|
||||
```
|
||||
"""
|
||||
|
||||
USER_PROMPT_TEMPLATE = """Solve this pwn.college challenge.
|
||||
|
||||
Module: {module_name}
|
||||
Challenge: {challenge_name}
|
||||
|
||||
## Challenge Description
|
||||
|
||||
{challenge_description}"""
|
||||
@@ -0,0 +1,852 @@
|
||||
"""
|
||||
PwnCollege Training Environment for Hermes-Agent + Atropos
|
||||
|
||||
Uses hermes-agent's tool system and HermesAgentLoop for the agent,
|
||||
with pwn.college SDK + SSH for challenge container management.
|
||||
|
||||
Usage:
|
||||
python environments/pwncollege_env/pwncollege_env.py serve \
|
||||
--config environments/pwncollege_env/default.yaml
|
||||
|
||||
python environments/pwncollege_env/pwncollege_env.py process \
|
||||
--config environments/pwncollege_env/default.yaml \
|
||||
--env.data_path_to_save_groups sft_data.jsonl
|
||||
|
||||
python environments/pwncollege_env/pwncollege_env.py evaluate \
|
||||
--config environments/pwncollege_env/default.yaml
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
# Ensure repo root is on sys.path
|
||||
_repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_repo_root) not in sys.path:
|
||||
sys.path.insert(0, str(_repo_root))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_env_path = _repo_root / ".env"
|
||||
if _env_path.exists():
|
||||
load_dotenv(dotenv_path=_env_path)
|
||||
|
||||
from environments.patches import apply_patches
|
||||
|
||||
apply_patches()
|
||||
|
||||
from atroposlib.envs.base import APIServerConfig, ScoredDataItem
|
||||
from atroposlib.type_definitions import Item
|
||||
|
||||
from environments.agent_loop import AgentResult, HermesAgentLoop
|
||||
from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig
|
||||
|
||||
# Import submit_flag_tool to trigger registry.register() at module load
|
||||
from environments.pwncollege_env import submit_flag_tool # noqa: F401
|
||||
from environments.pwncollege_env.prompts import SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
||||
from environments.pwncollege_env.sdk import (
|
||||
DojoRLClient, DojoRLSyncClient, RLChallenge, RLInstance,
|
||||
)
|
||||
from environments.pwncollege_env.submit_flag_tool import (
|
||||
clear_flag_context,
|
||||
register_flag_context,
|
||||
)
|
||||
from environments.tool_context import ToolContext
|
||||
from tools.terminal_tool import (
|
||||
cleanup_vm,
|
||||
clear_task_env_overrides,
|
||||
register_task_env_overrides,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PwnCollegeEnvConfig(HermesAgentEnvConfig):
|
||||
"""Configuration for PwnCollege environment."""
|
||||
|
||||
# Dojo connection
|
||||
base_url: str = Field(
|
||||
default="http://100.120.55.25:8080",
|
||||
description="Dojo API base URL",
|
||||
)
|
||||
ssh_host: str = Field(
|
||||
default="100.120.55.25",
|
||||
description="SSH host for challenge containers",
|
||||
)
|
||||
ssh_port: int = Field(default=2222, description="SSH port")
|
||||
ssh_key: str = Field(
|
||||
default="",
|
||||
description="Path to SSH private key for RL agent",
|
||||
)
|
||||
|
||||
# Challenge selection
|
||||
challenge: str = Field(
|
||||
default="hello/hello",
|
||||
description="Challenge in module/challenge format (e.g., 'hello/hello', 'paths/root')",
|
||||
)
|
||||
dojo_filter: Optional[str] = Field(default=None, description="Filter by dojo ID")
|
||||
module_filter: Optional[str] = Field(
|
||||
default=None, description="Filter by module ID"
|
||||
)
|
||||
include_challenges: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Specific challenge keys to include in training "
|
||||
"(format: module_id/challenge_id). Overrides dojo/module "
|
||||
"filters. Use for retry runs.",
|
||||
)
|
||||
|
||||
# Eval settings
|
||||
eval_dojo: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Dojo to evaluate on (None = all dojos)",
|
||||
)
|
||||
eval_exclude_dojos: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Dojos to exclude from evaluation",
|
||||
)
|
||||
eval_module: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Module to evaluate on (None = all modules)",
|
||||
)
|
||||
eval_exclude_modules: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Modules to exclude from evaluation",
|
||||
)
|
||||
eval_challenges: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Specific challenges to evaluate (format: module_id/challenge_id). Overrides dojo/module filters.",
|
||||
)
|
||||
eval_concurrency: int = Field(
|
||||
default=4,
|
||||
description="Max concurrent eval episodes (limited by dojo slots)",
|
||||
)
|
||||
|
||||
|
||||
class PwnCollegeEnv(HermesAgentBaseEnv):
|
||||
"""PwnCollege training environment.
|
||||
|
||||
Lifecycle per rollout:
|
||||
1. Create dojo instance (SDK) → get slot + ssh_user
|
||||
2. Register SSH overrides so terminal tool routes to that instance
|
||||
3. Register flag context so submit_flag tool can verify flags
|
||||
4. Run hermes-agent loop (terminal + file + submit_flag tools)
|
||||
5. Score: did agent submit the correct flag?
|
||||
6. Cleanup: destroy instance, clear overrides
|
||||
"""
|
||||
|
||||
name = "pwncollege"
|
||||
env_config_cls = PwnCollegeEnvConfig
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PwnCollegeEnvConfig,
|
||||
server_configs: List[APIServerConfig],
|
||||
slurm: bool = False,
|
||||
testing: bool = False,
|
||||
):
|
||||
# Set global SSH env vars before super().__init__ triggers terminal validation.
|
||||
# Per-task overrides (ssh_user) are registered before each rollout.
|
||||
os.environ.setdefault("TERMINAL_SSH_HOST", config.ssh_host)
|
||||
os.environ.setdefault("TERMINAL_SSH_USER", "rl_0")
|
||||
os.environ.setdefault("TERMINAL_SSH_KEY", config.ssh_key)
|
||||
|
||||
# Patch api_key from env var before super().__init__ bakes it into openai.AsyncClient
|
||||
api_key = os.getenv("OPENROUTER_API_KEY", "")
|
||||
if api_key:
|
||||
for sc in server_configs:
|
||||
if not sc.api_key:
|
||||
sc.api_key = api_key
|
||||
|
||||
super().__init__(config, server_configs, slurm, testing)
|
||||
self.config: PwnCollegeEnvConfig = config
|
||||
|
||||
self.train: list[RLChallenge] = []
|
||||
self.iter = 0
|
||||
self.solve_rate_buffer: list[float] = []
|
||||
self._active_slots: set[int] = set()
|
||||
|
||||
# SDK clients — async for setup/lifecycle, sync for submit_flag handler
|
||||
self.client: Optional[DojoRLClient] = None
|
||||
self.sync_client: Optional[DojoRLSyncClient] = None
|
||||
|
||||
@classmethod
|
||||
def config_init(cls) -> Tuple[PwnCollegeEnvConfig, List[APIServerConfig]]:
|
||||
env_config = PwnCollegeEnvConfig(
|
||||
enabled_toolsets=["terminal", "file", "pwncollege"],
|
||||
max_agent_turns=20,
|
||||
max_token_length=16384,
|
||||
agent_temperature=0.7,
|
||||
terminal_backend="ssh",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
use_wandb=True,
|
||||
wandb_name="pwncollege",
|
||||
ensure_scores_are_not_same=False,
|
||||
)
|
||||
server_configs = [
|
||||
APIServerConfig(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model_name="anthropic/claude-sonnet-4.5",
|
||||
server_type="openai",
|
||||
api_key=os.getenv("OPENROUTER_API_KEY", ""),
|
||||
health_check=False,
|
||||
),
|
||||
]
|
||||
return env_config, server_configs
|
||||
|
||||
def _cleanup_instances(self):
|
||||
"""Destroy all running dojo instances. Called on exit/signal."""
|
||||
if not self.sync_client:
|
||||
return
|
||||
try:
|
||||
n = self.sync_client.destroy_all()
|
||||
if n:
|
||||
logger.info("Cleaned up %d dojo instance(s)", n)
|
||||
except Exception as e:
|
||||
logger.warning("Instance cleanup failed: %s", e)
|
||||
|
||||
if hasattr(self, "_auto_ssh_key_dir"):
|
||||
import shutil
|
||||
shutil.rmtree(self._auto_ssh_key_dir, ignore_errors=True)
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle SIGINT/SIGTERM: clean up instances, then re-raise."""
|
||||
logger.info("Signal %d received, cleaning up dojo instances...", signum)
|
||||
self._cleanup_instances()
|
||||
signal.signal(signum, signal.SIG_DFL)
|
||||
os.kill(os.getpid(), signum)
|
||||
|
||||
async def _ensure_ssh_key(self):
|
||||
"""Auto-generate and register an SSH key if none configured."""
|
||||
if self.config.ssh_key and Path(self.config.ssh_key).exists():
|
||||
return
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
key_dir = Path(tempfile.mkdtemp(prefix="hermes-ssh-"))
|
||||
key_path = key_dir / "id_ed25519"
|
||||
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", "", "-q"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
pub_key = key_path.with_suffix(".pub").read_text().strip()
|
||||
registered = await self.client.register_ssh_key(pub_key)
|
||||
if not registered:
|
||||
raise RuntimeError("Failed to register SSH key with dojo")
|
||||
|
||||
self.config.ssh_key = str(key_path)
|
||||
os.environ["TERMINAL_SSH_KEY"] = str(key_path)
|
||||
self._auto_ssh_key_dir = key_dir
|
||||
|
||||
logger.info("Auto-generated SSH key and registered with dojo")
|
||||
|
||||
async def setup(self):
|
||||
"""Load challenges from dojo and initialize SDK clients."""
|
||||
self.client = DojoRLClient(self.config.base_url)
|
||||
self.sync_client = DojoRLSyncClient(self.config.base_url)
|
||||
|
||||
await self._ensure_ssh_key()
|
||||
|
||||
atexit.register(self._cleanup_instances)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
# Fetch challenges
|
||||
challenges = await self.client.list_challenges()
|
||||
logger.info("Fetched %d challenges from dojo", len(challenges))
|
||||
|
||||
# Apply filters
|
||||
if self.config.include_challenges:
|
||||
# Explicit include list overrides all other filters
|
||||
include_set = set(self.config.include_challenges)
|
||||
for c in challenges:
|
||||
if c.challenge_key in include_set:
|
||||
self.train.append(c)
|
||||
else:
|
||||
for c in challenges:
|
||||
if (self.config.dojo_filter
|
||||
and c.dojo_id != self.config.dojo_filter):
|
||||
continue
|
||||
if (self.config.module_filter
|
||||
and c.module_id != self.config.module_filter):
|
||||
continue
|
||||
self.train.append(c)
|
||||
|
||||
# If a specific challenge is set and no filters matched, use it directly
|
||||
if not self.train and self.config.challenge:
|
||||
parts = self.config.challenge.split("/")
|
||||
self.train.append(
|
||||
RLChallenge(
|
||||
id=parts[-1],
|
||||
module_id=parts[0],
|
||||
dojo_id="unknown",
|
||||
name=self.config.challenge,
|
||||
description="",
|
||||
)
|
||||
)
|
||||
|
||||
if not self.train:
|
||||
raise RuntimeError(
|
||||
f"No challenges matched filters (dojo_filter={self.config.dojo_filter}, "
|
||||
f"module_filter={self.config.module_filter}, challenge={self.config.challenge}). "
|
||||
f"Total available: {len(challenges)}"
|
||||
)
|
||||
|
||||
logger.info("Training on %d challenges", len(self.train))
|
||||
|
||||
async def get_next_item(self) -> RLChallenge:
|
||||
"""Return next challenge item (round-robin)."""
|
||||
item = self.train[self.iter % len(self.train)]
|
||||
self.iter += 1
|
||||
return item
|
||||
|
||||
def _get_challenge_key(self, item: RLChallenge) -> str:
|
||||
"""Extract the challenge key from a challenge."""
|
||||
return item.challenge_key or f"{item.module_id or ''}/{item.id}"
|
||||
|
||||
def format_prompt(self, item: RLChallenge) -> str:
|
||||
"""Build user prompt from challenge metadata."""
|
||||
challenge_key = self._get_challenge_key(item)
|
||||
return USER_PROMPT_TEMPLATE.format(
|
||||
module_name=item.module_id or "unknown",
|
||||
challenge_name=item.name or item.id,
|
||||
challenge_description=item.description or f"Solve the challenge: {challenge_key}",
|
||||
)
|
||||
|
||||
async def _acquire_instance(
|
||||
self, challenge_key: str, *, pool_slot: Optional[int] = None,
|
||||
) -> Optional[RLInstance]:
|
||||
"""Acquire a dojo instance for a challenge.
|
||||
|
||||
If *pool_slot* is given (process mode), try to reset the slot.
|
||||
If the slot is dead on the dojo, destroy it and create a fresh
|
||||
one. The returned instance may have a different slot ID than
|
||||
*pool_slot* — callers must use ``inst.slot`` going forward.
|
||||
|
||||
If *pool_slot* is ``None`` (evaluate / serve modes), create a
|
||||
new instance with transient-error retries.
|
||||
"""
|
||||
if pool_slot is not None:
|
||||
# Pool mode: try reset first (fast path)
|
||||
try:
|
||||
return await self.client.reset_instance(
|
||||
pool_slot, challenge=challenge_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"reset_instance(%d, %s) failed: %s — "
|
||||
"destroying and creating fresh slot",
|
||||
pool_slot, challenge_key, str(e)[:80],
|
||||
)
|
||||
try:
|
||||
await self.client.destroy_instance(pool_slot)
|
||||
except Exception:
|
||||
pass
|
||||
# Fall through to create mode
|
||||
|
||||
# Create mode: new instance with transient-error retries
|
||||
max_retries = 10 if pool_slot is not None else 5
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await self.client.create_instance(
|
||||
challenge_key,
|
||||
)
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
is_transient = (
|
||||
isinstance(e, httpx.HTTPStatusError)
|
||||
and e.response.status_code >= 500
|
||||
or isinstance(e, (
|
||||
httpx.ReadTimeout,
|
||||
httpx.ConnectTimeout,
|
||||
httpx.ConnectError,
|
||||
))
|
||||
or "No available slots" in err_str
|
||||
)
|
||||
if is_transient and attempt < max_retries - 1:
|
||||
wait = min(2 ** (attempt + 1), 60)
|
||||
logger.warning(
|
||||
"Transient error creating instance "
|
||||
"for %s (attempt %d/%d): %s, "
|
||||
"retrying in %ds",
|
||||
challenge_key, attempt + 1,
|
||||
max_retries, err_str[:80], wait,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to create instance for %s "
|
||||
"after %d attempts: %s",
|
||||
challenge_key, attempt + 1, e,
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
async def collect_trajectory(
|
||||
self, item: Item, *, pool_instance: Optional[RLInstance] = None,
|
||||
) -> Tuple[Optional[Union[ScoredDataItem, Any]], List[Item]]:
|
||||
"""Run a single rollout with dojo instance lifecycle.
|
||||
|
||||
Wraps the agent loop with:
|
||||
1. Dojo instance creation (SSH-accessible challenge container)
|
||||
2. SSH override registration (routes terminal tool to the instance)
|
||||
3. Flag context registration (enables submit_flag tool)
|
||||
4. Cleanup on completion
|
||||
|
||||
When *pool_instance* is provided (process mode), that
|
||||
pre-acquired instance is used directly and NOT destroyed on
|
||||
completion — the caller manages its lifecycle.
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
challenge_key = self._get_challenge_key(item)
|
||||
owns_slot = pool_instance is None
|
||||
|
||||
if pool_instance is not None:
|
||||
inst = pool_instance
|
||||
else:
|
||||
inst = await self._acquire_instance(challenge_key)
|
||||
if inst is None:
|
||||
return None, []
|
||||
|
||||
slot = inst.slot
|
||||
self._active_slots.add(slot)
|
||||
register_task_env_overrides(
|
||||
task_id,
|
||||
{
|
||||
"ssh_user": inst.ssh_user,
|
||||
"ssh_host": self.config.ssh_host,
|
||||
"ssh_port": self.config.ssh_port,
|
||||
"ssh_key": self.config.ssh_key,
|
||||
},
|
||||
)
|
||||
register_flag_context(task_id, self.sync_client, slot)
|
||||
|
||||
try:
|
||||
# Resolve tools (includes submit_flag via "pwncollege" toolset)
|
||||
if self._current_group_tools is None:
|
||||
tools, valid_names = self._resolve_tools_for_group()
|
||||
else:
|
||||
tools, valid_names = self._current_group_tools
|
||||
|
||||
messages: List[Dict[str, Any]] = []
|
||||
if self.config.system_prompt:
|
||||
messages.append({"role": "system", "content": self.config.system_prompt})
|
||||
messages.append({"role": "user", "content": self.format_prompt(item)})
|
||||
|
||||
agent = HermesAgentLoop(
|
||||
server=self.server,
|
||||
tool_schemas=tools,
|
||||
valid_tool_names=valid_names,
|
||||
max_turns=self.config.max_agent_turns,
|
||||
task_id=task_id,
|
||||
temperature=self.config.agent_temperature,
|
||||
max_tokens=self.config.max_token_length,
|
||||
extra_body=self.config.extra_body,
|
||||
)
|
||||
result = await agent.run(messages)
|
||||
|
||||
# Skip reward if agent produced no output
|
||||
only_system_and_user = all(
|
||||
msg.get("role") in ("system", "user") for msg in result.messages
|
||||
)
|
||||
if result.turns_used == 0 or only_system_and_user:
|
||||
logger.warning("Agent produced no output for %s", challenge_key)
|
||||
reward = 0.0
|
||||
else:
|
||||
ctx = ToolContext(task_id)
|
||||
try:
|
||||
reward = await self.compute_reward(item, result, ctx)
|
||||
finally:
|
||||
ctx.cleanup()
|
||||
|
||||
# Track tool errors
|
||||
if result.tool_errors:
|
||||
for err in result.tool_errors:
|
||||
self._tool_error_buffer.append({
|
||||
"turn": err.turn,
|
||||
"tool": err.tool_name,
|
||||
"args": err.arguments[:150],
|
||||
"error": err.error[:300],
|
||||
"result": err.tool_result[:300],
|
||||
})
|
||||
|
||||
# Build scored item (Phase 1: placeholder tokens)
|
||||
full_text = "\n".join(
|
||||
msg.get("content", "") for msg in result.messages if msg.get("content")
|
||||
)
|
||||
if self.tokenizer:
|
||||
tokens = self.tokenizer.encode(full_text, add_special_tokens=True)
|
||||
else:
|
||||
tokens = list(range(min(len(full_text) // 4, 128)))
|
||||
|
||||
scored_item = {
|
||||
"tokens": tokens,
|
||||
"masks": [-100] + tokens[1:],
|
||||
"scores": reward,
|
||||
"messages": result.messages,
|
||||
}
|
||||
return scored_item, []
|
||||
|
||||
finally:
|
||||
clear_flag_context(task_id)
|
||||
clear_task_env_overrides(task_id)
|
||||
cleanup_vm(task_id)
|
||||
if owns_slot:
|
||||
# Evaluate/serve mode: we created it, we destroy it
|
||||
try:
|
||||
await self.client.destroy_instance(slot)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to destroy instance slot %d: %s", slot, e)
|
||||
# Pool mode: caller is responsible for the slot lifecycle
|
||||
self._active_slots.discard(slot)
|
||||
|
||||
async def compute_reward(
|
||||
self, item: Item, result: AgentResult, ctx: ToolContext
|
||||
) -> float:
|
||||
"""Score the rollout: 1.0 if flag was correctly submitted, 0.0 otherwise.
|
||||
|
||||
Checks two signals:
|
||||
1. Did submit_flag return {"success": true}?
|
||||
2. Fallback: extract pwn.college{...} from terminal output and verify via SDK.
|
||||
"""
|
||||
# Check submit_flag tool results in the conversation
|
||||
for msg in result.messages:
|
||||
if msg.get("role") == "tool":
|
||||
try:
|
||||
data = json.loads(msg.get("content", ""))
|
||||
if isinstance(data, dict) and data.get("success") is True:
|
||||
self.solve_rate_buffer.append(1.0)
|
||||
return 1.0
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Fallback: scan for flag pattern in all messages
|
||||
for msg in result.messages:
|
||||
content = msg.get("content", "")
|
||||
if not content:
|
||||
continue
|
||||
flag_match = re.search(r"pwn\.college\{[^}]+\}", content)
|
||||
if flag_match:
|
||||
# We can't verify here since instance is being torn down,
|
||||
# but the flag pattern presence suggests partial progress
|
||||
self.solve_rate_buffer.append(0.0)
|
||||
return 0.0
|
||||
|
||||
self.solve_rate_buffer.append(0.0)
|
||||
return 0.0
|
||||
|
||||
async def process_manager(self):
|
||||
"""Override: process items concurrently with pre-allocated slot pool.
|
||||
|
||||
Uses a pool of dojo instances (asyncio.Queue) instead of a semaphore.
|
||||
Each task waits for a real dojo slot to become available, resets it
|
||||
to the target challenge, and returns it to the pool on completion.
|
||||
This guarantees zero silent drops from slot contention.
|
||||
"""
|
||||
from atroposlib.frontend.jsonl2html import generate_html
|
||||
|
||||
await self.setup()
|
||||
|
||||
if self.config.use_wandb:
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime
|
||||
|
||||
import wandb
|
||||
|
||||
random_id = "".join(random.choices(string.ascii_lowercase, k=6))
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
wandb.init(
|
||||
project=self.wandb_project,
|
||||
name=f"{self.name}-{current_date}-{random_id}",
|
||||
group=self.wandb_group,
|
||||
config=self.config.model_dump(),
|
||||
)
|
||||
|
||||
self.config.group_size = self.group_size_to_process
|
||||
items = self.train[:self.n_groups_to_process]
|
||||
|
||||
total = len(items)
|
||||
concurrency = self.config.eval_concurrency
|
||||
completed = 0
|
||||
|
||||
# --- Pre-allocate slot pool ---
|
||||
# Use the first challenge as a throwaway target; each task will
|
||||
# reset_instance to its own challenge before running.
|
||||
first_key = self._get_challenge_key(items[0]) if items else "hello/hello"
|
||||
slot_pool: asyncio.Queue[int] = asyncio.Queue()
|
||||
pool_size = 0
|
||||
|
||||
logger.info("Pre-allocating %d dojo slots...", concurrency)
|
||||
for i in range(concurrency):
|
||||
try:
|
||||
inst = await self.client.create_instance(first_key)
|
||||
slot_pool.put_nowait(inst.slot)
|
||||
pool_size += 1
|
||||
except Exception as e:
|
||||
# Dojo has a hard slot cap; once full, stop trying
|
||||
logger.info(
|
||||
"Pre-allocated %d/%d slots (dojo full: %s)",
|
||||
i, concurrency, e,
|
||||
)
|
||||
break
|
||||
|
||||
if pool_size == 0:
|
||||
raise RuntimeError("Could not allocate any dojo slots")
|
||||
|
||||
logger.info(
|
||||
"Processing %d items (pool_size=%d, group_size=%d)",
|
||||
total, pool_size, self.group_size_to_process,
|
||||
)
|
||||
|
||||
# Resolve tools once before launching concurrent tasks
|
||||
self._current_group_tools = self._resolve_tools_for_group()
|
||||
|
||||
async def process_one(item):
|
||||
nonlocal completed
|
||||
challenge_key = self._get_challenge_key(item)
|
||||
|
||||
# Wait for a real slot (blocks until one is returned)
|
||||
original_slot = await slot_pool.get()
|
||||
# _acquire_instance may create a new slot if the original
|
||||
# died on the dojo, so we track the actual slot to return.
|
||||
actual_slot: int | None = original_slot
|
||||
|
||||
try:
|
||||
# Acquire instance (reset or create)
|
||||
inst = await self._acquire_instance(
|
||||
challenge_key, pool_slot=original_slot,
|
||||
)
|
||||
if inst is None:
|
||||
logger.warning(
|
||||
"Could not acquire instance for %s",
|
||||
challenge_key,
|
||||
)
|
||||
actual_slot = None # don't poison pool
|
||||
return
|
||||
actual_slot = inst.slot
|
||||
if actual_slot != original_slot:
|
||||
logger.info(
|
||||
"Slot %d replaced with %d for %s",
|
||||
original_slot, actual_slot,
|
||||
challenge_key,
|
||||
)
|
||||
|
||||
# Run the trajectory with the acquired instance
|
||||
scored, _ = await self.collect_trajectory(
|
||||
item, pool_instance=inst,
|
||||
)
|
||||
if scored is None:
|
||||
logger.warning(
|
||||
"No scored data for %s (slot %d)",
|
||||
challenge_key, actual_slot,
|
||||
)
|
||||
return
|
||||
|
||||
# Wrap in ScoredDataGroup for postprocessing
|
||||
to_postprocess = {
|
||||
"tokens": [scored["tokens"]],
|
||||
"masks": [scored["masks"]],
|
||||
"scores": [scored["scores"]],
|
||||
"advantages": [],
|
||||
"ref_logprobs": [],
|
||||
"messages": [scored.get("messages", [])],
|
||||
"group_overrides": {},
|
||||
"overrides": [],
|
||||
"images": [],
|
||||
}
|
||||
processed = await self.postprocess_histories(
|
||||
to_postprocess,
|
||||
)
|
||||
await self.handle_send_to_api(
|
||||
processed, item,
|
||||
do_send_to_api=False,
|
||||
abort_on_any_max_length_exceeded=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to process %s: %s", challenge_key, e,
|
||||
)
|
||||
finally:
|
||||
completed += 1
|
||||
logger.info(
|
||||
"Processed %d/%d (%s)",
|
||||
completed, total, challenge_key,
|
||||
)
|
||||
# Return the actual slot to pool (may differ from
|
||||
# original_slot if reset failed and a new one was
|
||||
# created). None means acquisition failed entirely.
|
||||
if actual_slot is not None:
|
||||
slot_pool.put_nowait(actual_slot)
|
||||
|
||||
await asyncio.gather(*[process_one(item) for item in items])
|
||||
|
||||
logger.info("Completed processing %d items", completed)
|
||||
|
||||
# Cleanup: destroy all pooled slots
|
||||
while not slot_pool.empty():
|
||||
slot = slot_pool.get_nowait()
|
||||
try:
|
||||
await self.client.destroy_instance(slot)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to destroy pool slot %d: %s", slot, e)
|
||||
|
||||
if self.jsonl_writer is not None:
|
||||
self.jsonl_writer.close()
|
||||
|
||||
if self.config.data_path_to_save_groups:
|
||||
generate_html(self.config.data_path_to_save_groups)
|
||||
|
||||
async def evaluate(self, *args, **kwargs):
|
||||
"""Run evaluation on a dojo/module and report solve rate.
|
||||
|
||||
Fetches challenges matching eval_dojo/eval_module, runs each through
|
||||
the agent loop with concurrency control, and logs results.
|
||||
"""
|
||||
import time
|
||||
|
||||
if not self.client:
|
||||
logger.error("SDK client not initialized. Call setup() first.")
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Fetch and filter eval challenges
|
||||
all_challenges = await self.client.list_challenges()
|
||||
if self.config.eval_challenges:
|
||||
challenge_set = set(self.config.eval_challenges)
|
||||
eval_challenges = [c for c in all_challenges if c.challenge_key in challenge_set]
|
||||
else:
|
||||
eval_challenges = [
|
||||
c for c in all_challenges
|
||||
if (self.config.eval_dojo is None or c.dojo_id == self.config.eval_dojo)
|
||||
and (self.config.eval_module is None or c.module_id == self.config.eval_module)
|
||||
and c.dojo_id not in self.config.eval_exclude_dojos
|
||||
and c.module_id not in self.config.eval_exclude_modules
|
||||
]
|
||||
|
||||
if not eval_challenges:
|
||||
logger.warning(
|
||||
"No challenges found for eval_dojo=%s eval_module=%s",
|
||||
self.config.eval_dojo, self.config.eval_module,
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"Evaluating {len(eval_challenges)} challenges from "
|
||||
f"{self.config.eval_dojo or '*'}/{self.config.eval_module or '*'} "
|
||||
f"(concurrency={self.config.eval_concurrency})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
semaphore = asyncio.Semaphore(self.config.eval_concurrency)
|
||||
completed = 0
|
||||
total = len(eval_challenges)
|
||||
|
||||
async def eval_one(challenge: RLChallenge) -> dict:
|
||||
nonlocal completed
|
||||
challenge_key = self._get_challenge_key(challenge)
|
||||
async with semaphore:
|
||||
try:
|
||||
scored, _ = await self.collect_trajectory(challenge)
|
||||
solved = scored is not None and scored.get("scores", 0.0) >= 1.0
|
||||
completed += 1
|
||||
status = "PASS" if solved else "FAIL"
|
||||
reward = scored.get("scores", 0.0) if scored else 0.0
|
||||
print(
|
||||
f" [{completed}/{total}] [{status}] {challenge_key} "
|
||||
f"(reward={reward:.1f})",
|
||||
flush=True,
|
||||
)
|
||||
result = {
|
||||
"challenge": challenge_key,
|
||||
"name": challenge.name,
|
||||
"solved": solved,
|
||||
"reward": reward,
|
||||
}
|
||||
# Stream-write sample with full conversation for HTML viewer
|
||||
self.log_eval_sample({
|
||||
"score": reward,
|
||||
"challenge": challenge_key,
|
||||
"solved": solved,
|
||||
"messages": scored.get("messages", []) if scored else [],
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
completed += 1
|
||||
print(
|
||||
f" [{completed}/{total}] [ERR ] {challenge_key}: {e}",
|
||||
flush=True,
|
||||
)
|
||||
self.log_eval_sample({
|
||||
"score": 0.0,
|
||||
"challenge": challenge_key,
|
||||
"solved": False,
|
||||
"messages": [{"role": "system", "content": f"Error: {e}"}],
|
||||
})
|
||||
return {
|
||||
"challenge": challenge_key,
|
||||
"name": challenge.name,
|
||||
"solved": False,
|
||||
"reward": 0.0,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
tasks = [eval_one(c) for c in eval_challenges]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
end_time = time.time()
|
||||
|
||||
# Aggregate
|
||||
n = len(results)
|
||||
solved = sum(1 for r in results if r["solved"])
|
||||
solve_rate = solved / n if n else 0.0
|
||||
|
||||
print("=" * 60, flush=True)
|
||||
print(
|
||||
f"Eval: {solved}/{n} solved ({solve_rate * 100:.1f}%) "
|
||||
f"in {end_time - start_time:.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
eval_metrics = {
|
||||
"eval/solve_rate": solve_rate,
|
||||
"eval/solved": solved,
|
||||
"eval/total": n,
|
||||
}
|
||||
|
||||
await self.evaluate_log(
|
||||
metrics=eval_metrics,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
async def wandb_log(self, wandb_metrics: Optional[Dict] = None):
|
||||
"""Log solve rate metrics to wandb."""
|
||||
if wandb_metrics is None:
|
||||
wandb_metrics = {}
|
||||
if self.solve_rate_buffer:
|
||||
n = len(self.solve_rate_buffer)
|
||||
wandb_metrics["train/solve_rate"] = sum(self.solve_rate_buffer) / n
|
||||
wandb_metrics["train/num_rollouts"] = n
|
||||
self.solve_rate_buffer = []
|
||||
await super().wandb_log(wandb_metrics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
PwnCollegeEnv.cli()
|
||||
@@ -0,0 +1,468 @@
|
||||
"""SDK for pwncollege dojo"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_csrf_nonce(html: str) -> str | None:
|
||||
match = re.search(r"'csrfNonce': \"([^\"]+)\"", html)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RLInstance:
|
||||
slot: int
|
||||
ssh_user: str
|
||||
challenge_id: str
|
||||
module_id: str
|
||||
dojo_id: str
|
||||
flag: str | None = None
|
||||
created_at: float | None = None
|
||||
status: str | None = None
|
||||
|
||||
@property
|
||||
def challenge_key(self) -> str:
|
||||
return f"{self.module_id}/{self.challenge_id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RLResource:
|
||||
type: str
|
||||
name: str
|
||||
content: str | None = None
|
||||
video: str | None = None
|
||||
slides: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RLChallenge:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
module_id: str | None = None
|
||||
module_name: str | None = None
|
||||
module_description: str | None = None
|
||||
dojo_id: str | None = None
|
||||
dojo_name: str | None = None
|
||||
dojo_description: str | None = None
|
||||
resources: list[RLResource] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def challenge_key(self) -> str | None:
|
||||
if self.module_id:
|
||||
return f"{self.module_id}/{self.id}"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RLStatus:
|
||||
enabled: bool
|
||||
max_instances: int
|
||||
running: int
|
||||
instances: list[RLInstance]
|
||||
|
||||
|
||||
class DojoRLClient:
|
||||
"""Client for the dojo RL API. No auth required."""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 120.0):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
def _rl_url(self, path: str) -> str:
|
||||
return f"/pwncollege_api/v1/rl{path}"
|
||||
|
||||
async def _get(self, path: str) -> dict[str, Any]:
|
||||
resp = await self.client.get(self._rl_url(path))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def _post(self, path: str, json: dict | None = None) -> dict[str, Any]:
|
||||
resp = await self.client.post(self._rl_url(path), json=json or {})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def _delete(self, path: str) -> dict[str, Any]:
|
||||
resp = await self.client.delete(self._rl_url(path))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── Response Parsing ──────────────────────────────────────────────────────
|
||||
# The API uses different field names in create/reset vs get/list responses.
|
||||
# These parsers normalize everything into RLInstance.
|
||||
|
||||
@staticmethod
|
||||
def _parse_create_response(data: dict[str, Any]) -> RLInstance:
|
||||
return RLInstance(
|
||||
slot=data["slot"],
|
||||
ssh_user=data["ssh_user"],
|
||||
challenge_id=data["challenge"],
|
||||
module_id=data["module"],
|
||||
dojo_id=data["dojo"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_instance_detail(data: dict[str, Any]) -> RLInstance:
|
||||
created_at = data.get("created_at")
|
||||
return RLInstance(
|
||||
slot=data["slot"],
|
||||
ssh_user=data.get("ssh_user", f"rl_{data['slot']}"),
|
||||
challenge_id=data["challenge_id"],
|
||||
module_id=data["module_id"],
|
||||
dojo_id=data["dojo_id"],
|
||||
flag=data.get("flag"),
|
||||
created_at=float(created_at) if created_at else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_instance_listing(data: dict[str, Any]) -> RLInstance:
|
||||
created_at = data.get("created_at")
|
||||
return RLInstance(
|
||||
slot=data["slot"],
|
||||
ssh_user=f"rl_{data['slot']}",
|
||||
challenge_id=data["challenge_id"],
|
||||
module_id=data["module_id"],
|
||||
dojo_id=data["dojo_id"],
|
||||
created_at=float(created_at) if created_at else None,
|
||||
status=data.get("status"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_challenge(data: dict[str, Any]) -> RLChallenge:
|
||||
resources = [
|
||||
RLResource(
|
||||
type=r["type"],
|
||||
name=r["name"],
|
||||
content=r.get("content"),
|
||||
video=r.get("video"),
|
||||
slides=r.get("slides"),
|
||||
)
|
||||
for r in data.get("resources", [])
|
||||
]
|
||||
return RLChallenge(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
module_id=data.get("module_id"),
|
||||
module_name=data.get("module_name"),
|
||||
module_description=data.get("module_description"),
|
||||
dojo_id=data.get("dojo_id"),
|
||||
dojo_name=data.get("dojo_name"),
|
||||
dojo_description=data.get("dojo_description"),
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
# ── RL Instance Lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
async def status(self) -> RLStatus:
|
||||
result = await self._get("/status")
|
||||
instances = [
|
||||
self._parse_instance_listing(inst) for inst in result.get("instances", [])
|
||||
]
|
||||
return RLStatus(
|
||||
enabled=result["enabled"],
|
||||
max_instances=result["max_instances"],
|
||||
running=result["running"],
|
||||
instances=instances,
|
||||
)
|
||||
|
||||
async def create_instance(
|
||||
self, challenge: str, *, variant: int | None = None
|
||||
) -> RLInstance:
|
||||
data: dict[str, Any] = {"challenge": challenge}
|
||||
if variant is not None:
|
||||
data["variant"] = variant
|
||||
result = await self._post("/instances", json=data)
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"Failed to create instance: {result.get('error')}")
|
||||
return self._parse_create_response(result)
|
||||
|
||||
async def get_instance(self, slot: int) -> RLInstance:
|
||||
result = await self._get(f"/instances/{slot}")
|
||||
if not result.get("success"):
|
||||
raise KeyError(f"No instance at slot {slot}")
|
||||
return self._parse_instance_detail(result)
|
||||
|
||||
async def list_instances(self) -> list[RLInstance]:
|
||||
result = await self._get("/instances")
|
||||
return [
|
||||
self._parse_instance_listing(inst) for inst in result.get("instances", [])
|
||||
]
|
||||
|
||||
async def destroy_instance(self, slot: int) -> None:
|
||||
result = await self._delete(f"/instances/{slot}")
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"Failed to destroy instance: {result.get('error')}")
|
||||
|
||||
async def reset_instance(
|
||||
self, slot: int, *, challenge: str | None = None
|
||||
) -> RLInstance:
|
||||
data: dict[str, Any] = {}
|
||||
if challenge is not None:
|
||||
data["challenge"] = challenge
|
||||
result = await self._post(f"/instances/{slot}/reset", json=data)
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"Failed to reset instance: {result.get('error')}")
|
||||
return self._parse_create_response(result)
|
||||
|
||||
async def check_flag(self, slot: int, flag: str) -> bool:
|
||||
result = await self._post(f"/instances/{slot}/check", json={"flag": flag})
|
||||
return result.get("correct", False)
|
||||
|
||||
async def get_flag(self, slot: int) -> str:
|
||||
instance = await self.get_instance(slot)
|
||||
if instance.flag is None:
|
||||
raise RuntimeError(f"No flag available for slot {slot}")
|
||||
return instance.flag
|
||||
|
||||
# ── SSH Key Management ────────────────────────────────────────────────────
|
||||
|
||||
async def register_ssh_key(self, public_key: str) -> bool:
|
||||
result = await self._post("/ssh_key", json={"public_key": public_key})
|
||||
return result.get("success", False)
|
||||
|
||||
async def get_ssh_key(self) -> dict[str, Any]:
|
||||
return await self._get("/ssh_key")
|
||||
|
||||
# ── Challenge Discovery ───────────────────────────────────────────────────
|
||||
|
||||
async def list_challenges(self) -> list[RLChallenge]:
|
||||
result = await self._get("/challenges")
|
||||
return [self._parse_challenge(ch) for ch in result.get("challenges", [])]
|
||||
|
||||
# ── Admin (requires auth) ─────────────────────────────────────────────────
|
||||
|
||||
async def admin_login(
|
||||
self, username: str = "admin", password: str = "admin"
|
||||
) -> None:
|
||||
resp = await self.client.get("/login")
|
||||
nonce = _extract_csrf_nonce(resp.text)
|
||||
if not nonce:
|
||||
raise RuntimeError("Could not extract CSRF nonce")
|
||||
self._admin_csrf = nonce
|
||||
resp = await self.client.post(
|
||||
"/login",
|
||||
data={"name": username, "password": password, "nonce": nonce},
|
||||
)
|
||||
if resp.status_code not in (200, 302):
|
||||
raise RuntimeError(f"Login failed: {resp.status_code}")
|
||||
resp = await self.client.get("/")
|
||||
self._admin_csrf = _extract_csrf_nonce(resp.text) or self._admin_csrf
|
||||
|
||||
async def load_dojo(self, repository: str) -> str:
|
||||
if not hasattr(self, "_admin_csrf"):
|
||||
raise RuntimeError("Must call admin_login() first")
|
||||
resp = await self.client.post(
|
||||
"/pwncollege_api/v1/dojos/create",
|
||||
json={
|
||||
"repository": repository,
|
||||
"public_key": f"public/{repository}",
|
||||
"private_key": f"private/{repository}",
|
||||
},
|
||||
headers={"CSRF-Token": self._admin_csrf},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not data.get("success", True):
|
||||
raise RuntimeError(f"Failed to load dojo: {data.get('error', data)}")
|
||||
return data.get("dojo", repository)
|
||||
|
||||
async def promote_dojo(self, dojo_id: str) -> None:
|
||||
if not hasattr(self, "_admin_csrf"):
|
||||
raise RuntimeError("Must call admin_login() first")
|
||||
resp = await self.client.post(
|
||||
f"/pwncollege_api/v1/dojos/{dojo_id}/promote",
|
||||
json={},
|
||||
headers={"CSRF-Token": self._admin_csrf},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
# ── Bulk Operations ───────────────────────────────────────────────────────
|
||||
|
||||
async def create_batch(self, challenge: str, count: int) -> list[RLInstance]:
|
||||
tasks = [self.create_instance(challenge) for _ in range(count)]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
async def destroy_all(self) -> int:
|
||||
instances = await self.list_instances()
|
||||
for inst in instances:
|
||||
await self.destroy_instance(inst.slot)
|
||||
return len(instances)
|
||||
|
||||
|
||||
class DojoRLSyncClient:
|
||||
"""Sync wrapper for DojoRLClient.
|
||||
|
||||
Runs all async operations on a dedicated background thread with its own
|
||||
event loop, so it's safe to call from any context — including from inside
|
||||
another running event loop (e.g., Atropos's loop or tool dispatch threads).
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 120.0):
|
||||
import threading
|
||||
|
||||
self._async = DojoRLClient(base_url, timeout)
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop.run_forever,
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self, coro):
|
||||
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
if not self._loop.is_running():
|
||||
return
|
||||
try:
|
||||
self._run(self._async.close())
|
||||
except Exception:
|
||||
pass
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def status(self) -> RLStatus:
|
||||
return self._run(self._async.status())
|
||||
|
||||
def create_instance(
|
||||
self, challenge: str, *, variant: int | None = None
|
||||
) -> RLInstance:
|
||||
return self._run(self._async.create_instance(challenge, variant=variant))
|
||||
|
||||
def get_instance(self, slot: int) -> RLInstance:
|
||||
return self._run(self._async.get_instance(slot))
|
||||
|
||||
def list_instances(self) -> list[RLInstance]:
|
||||
return self._run(self._async.list_instances())
|
||||
|
||||
def destroy_instance(self, slot: int) -> None:
|
||||
return self._run(self._async.destroy_instance(slot))
|
||||
|
||||
def reset_instance(self, slot: int, *, challenge: str | None = None) -> RLInstance:
|
||||
return self._run(self._async.reset_instance(slot, challenge=challenge))
|
||||
|
||||
def check_flag(self, slot: int, flag: str) -> bool:
|
||||
return self._run(self._async.check_flag(slot, flag))
|
||||
|
||||
def get_flag(self, slot: int) -> str:
|
||||
return self._run(self._async.get_flag(slot))
|
||||
|
||||
def list_challenges(self) -> list[RLChallenge]:
|
||||
return self._run(self._async.list_challenges())
|
||||
|
||||
def register_ssh_key(self, public_key: str) -> bool:
|
||||
return self._run(self._async.register_ssh_key(public_key))
|
||||
|
||||
def get_ssh_key(self) -> dict[str, Any]:
|
||||
return self._run(self._async.get_ssh_key())
|
||||
|
||||
def admin_login(self, username: str = "admin", password: str = "admin") -> None:
|
||||
return self._run(self._async.admin_login(username, password))
|
||||
|
||||
def load_dojo(self, repository: str) -> str:
|
||||
return self._run(self._async.load_dojo(repository))
|
||||
|
||||
def promote_dojo(self, dojo_id: str) -> None:
|
||||
return self._run(self._async.promote_dojo(dojo_id))
|
||||
|
||||
def destroy_all(self) -> int:
|
||||
return self._run(self._async.destroy_all())
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodePool:
|
||||
"""Manages a pool of RL instances for parallel episode collection."""
|
||||
|
||||
client: DojoRLClient
|
||||
challenge: str
|
||||
pool_size: int = 32
|
||||
acquisition_timeout: float = 300.0
|
||||
|
||||
_available: asyncio.Queue[RLInstance] = field(
|
||||
default_factory=asyncio.Queue, init=False
|
||||
)
|
||||
_all_instances: dict[int, RLInstance] = field(default_factory=dict, init=False)
|
||||
_initialized: bool = field(default=False, init=False)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
for _ in range(self.pool_size):
|
||||
instance = await self.client.create_instance(self.challenge)
|
||||
full = await self.client.get_instance(instance.slot)
|
||||
self._all_instances[instance.slot] = full
|
||||
await self._available.put(full)
|
||||
self._initialized = True
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
if not self._initialized:
|
||||
raise RuntimeError("EpisodePool not initialized")
|
||||
try:
|
||||
instance = await asyncio.wait_for(
|
||||
self._available.get(), timeout=self.acquisition_timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(
|
||||
f"No instance available within {self.acquisition_timeout}s"
|
||||
)
|
||||
try:
|
||||
yield instance
|
||||
finally:
|
||||
try:
|
||||
reset = await self.client.reset_instance(
|
||||
instance.slot, challenge=self.challenge
|
||||
)
|
||||
full = await self.client.get_instance(reset.slot)
|
||||
self._all_instances[reset.slot] = full
|
||||
await self._available.put(full)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to reset instance slot %d, returning stale instance: %s",
|
||||
instance.slot,
|
||||
e,
|
||||
)
|
||||
await self._available.put(instance)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
errors = []
|
||||
for slot in list(self._all_instances.keys()):
|
||||
try:
|
||||
await self.client.destroy_instance(slot)
|
||||
except Exception as e:
|
||||
errors.append((slot, e))
|
||||
logger.warning("Failed to destroy instance slot %d: %s", slot, e)
|
||||
self._all_instances.clear()
|
||||
self._initialized = False
|
||||
if errors:
|
||||
logger.error(
|
||||
"EpisodePool shutdown: %d instance(s) failed to destroy", len(errors)
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
env:
|
||||
group_size: 4
|
||||
max_num_workers: -1
|
||||
max_eval_workers: 16
|
||||
max_num_workers_per_node: 8
|
||||
steps_per_eval: 100
|
||||
max_token_length: 16384
|
||||
eval_handling: STOP_TRAIN
|
||||
eval_limit_ratio: 0.5
|
||||
inference_weight: 1.0
|
||||
batch_size: -1
|
||||
max_batches_offpolicy: 3
|
||||
tokenizer_name: NousResearch/Hermes-3-Llama-3.1-8B
|
||||
use_wandb: false
|
||||
rollout_server_url: http://localhost:8000
|
||||
total_steps: 1000
|
||||
wandb_name: pwncollege-smoke-hello
|
||||
num_rollouts_to_keep: 32
|
||||
num_rollouts_per_group_for_logging: 1
|
||||
ensure_scores_are_not_same: false
|
||||
data_path_to_save_groups: null
|
||||
data_dir_to_save_evals: environments/pwncollege_env/eval_runs/smoke_hello
|
||||
min_items_sent_before_logging: 2
|
||||
include_messages: false
|
||||
min_batch_allocation: null
|
||||
worker_timeout: 600.0
|
||||
thinking_mode: false
|
||||
reasoning_effort: null
|
||||
max_reasoning_tokens: null
|
||||
custom_thinking_prompt: null
|
||||
enabled_toolsets:
|
||||
- terminal
|
||||
- file
|
||||
- pwncollege
|
||||
disabled_toolsets: null
|
||||
distribution: null
|
||||
max_agent_turns: 20
|
||||
agent_temperature: 0.7
|
||||
terminal_backend: ssh
|
||||
terminal_timeout: 120
|
||||
terminal_lifetime: 3600
|
||||
disable_command_guards: true
|
||||
dataset_name: null
|
||||
dataset_split: train
|
||||
prompt_field: prompt
|
||||
tool_pool_size: 128
|
||||
tool_call_parser: hermes
|
||||
extra_body: null
|
||||
base_url: http://100.120.55.25:8080
|
||||
ssh_host: 100.120.55.25
|
||||
ssh_port: 2222
|
||||
ssh_key: environments/pwncollege_env/keys/rl_test_key
|
||||
challenge: hello/hello
|
||||
dojo_filter: null
|
||||
module_filter: null
|
||||
eval_dojo: linux-luminarium
|
||||
eval_exclude_dojos:
|
||||
- archive
|
||||
eval_module: hello
|
||||
eval_concurrency: 3
|
||||
openai:
|
||||
- timeout: 1200
|
||||
num_max_requests_at_once: 512
|
||||
num_requests_for_eval: 64
|
||||
model_name: xiaomi/mimo-v2-flash
|
||||
rolling_buffer_length: 1000
|
||||
server_type: openai
|
||||
tokenizer_name: none
|
||||
api_key: ""
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
n_kwarg_is_ignored: false
|
||||
health_check: false
|
||||
slurm: false
|
||||
testing: false
|
||||
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
Capability verification test for pwn-dojo RL infrastructure.
|
||||
|
||||
Verifies that RL containers are provisioned with the correct Linux capabilities,
|
||||
resource limits, and host configuration for each challenge type.
|
||||
|
||||
Usage:
|
||||
python environments/pwncollege_env/stress_test.py -y
|
||||
python environments/pwncollege_env/stress_test.py -y -o report.json --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_repo_root) not in sys.path:
|
||||
sys.path.insert(0, str(_repo_root))
|
||||
|
||||
from environments.pwncollege_env.sdk import DojoRLClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHConfig:
|
||||
host: str
|
||||
port: int
|
||||
key: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
passed: bool
|
||||
message: str
|
||||
duration: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
challenge: str
|
||||
checks: list[CheckResult] = field(default_factory=list)
|
||||
passed: bool = False
|
||||
skipped: bool = False
|
||||
error: str | None = None
|
||||
duration: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestCase:
|
||||
name: str
|
||||
challenge: str
|
||||
checks: list
|
||||
|
||||
|
||||
async def ssh_run(
|
||||
cfg: SSHConfig, user: str, command: str, timeout: float = 30.0
|
||||
) -> tuple[int, str]:
|
||||
"""Run a command over SSH via subprocess. Returns (returncode, output)."""
|
||||
cmd = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
"-p",
|
||||
str(cfg.port),
|
||||
"-i",
|
||||
cfg.key,
|
||||
f"{user}@{cfg.host}",
|
||||
command,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
return proc.returncode, stdout.decode(errors="replace")
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return -1, f"[SSH timeout after {timeout}s]"
|
||||
|
||||
|
||||
async def wait_ssh_ready(cfg: SSHConfig, user: str, retries: int = 10) -> bool:
|
||||
for i in range(retries):
|
||||
rc, out = await ssh_run(cfg, user, "echo ready", timeout=10)
|
||||
if rc == 0 and "ready" in out:
|
||||
return True
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
# ── Check functions ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def check_ssh_echo(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(cfg, user, "echo ok")
|
||||
dur = time.monotonic() - t0
|
||||
if rc == 0 and "ok" in out:
|
||||
return CheckResult("ssh_echo", True, "connected", dur)
|
||||
return CheckResult("ssh_echo", False, f"rc={rc}: {out.strip()[:100]}", dur)
|
||||
|
||||
|
||||
async def check_unshare_net(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(cfg, user, "unshare --net echo ok")
|
||||
dur = time.monotonic() - t0
|
||||
if rc == 0 and "ok" in out:
|
||||
return CheckResult("unshare_net", True, "namespace creation works", dur)
|
||||
return CheckResult("unshare_net", False, f"rc={rc}: {out.strip()[:120]}", dur)
|
||||
|
||||
|
||||
async def check_unshare_user(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(cfg, user, "unshare --user --map-root-user bash -c 'id'")
|
||||
dur = time.monotonic() - t0
|
||||
if rc == 0 and "uid=0" in out:
|
||||
return CheckResult("unshare_user", True, "user namespace works", dur)
|
||||
return CheckResult("unshare_user", False, f"rc={rc}: {out.strip()[:120]}", dur)
|
||||
|
||||
|
||||
async def check_capeff(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
"""Check that the container init (PID 1) has SYS_ADMIN capability."""
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(cfg, user, "cat /proc/1/status")
|
||||
dur = time.monotonic() - t0
|
||||
if rc != 0:
|
||||
return CheckResult(
|
||||
"capeff", False, f"Cannot read /proc/1/status: {out.strip()[:80]}", dur
|
||||
)
|
||||
for line in out.splitlines():
|
||||
if line.startswith("CapEff:") or line.startswith("CapBnd:"):
|
||||
hex_val = line.split(":")[1].strip()
|
||||
try:
|
||||
val = int(hex_val, 16)
|
||||
has_sysadmin = bool(val & (1 << 21))
|
||||
if has_sysadmin:
|
||||
label = line.split(":")[0]
|
||||
return CheckResult(
|
||||
"capeff", True, f"{label}={hex_val} has SYS_ADMIN", dur
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return CheckResult(
|
||||
"capeff", False, "SYS_ADMIN (bit 21) not found in capabilities", dur
|
||||
)
|
||||
|
||||
|
||||
async def check_hosts_resolution(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(cfg, user, "getent hosts challenge.localhost")
|
||||
dur = time.monotonic() - t0
|
||||
if rc == 0 and out.strip():
|
||||
return CheckResult(
|
||||
"hosts_resolution", True, f"resolves to {out.strip()[:40]}", dur
|
||||
)
|
||||
rc2, out2 = await ssh_run(cfg, user, "grep challenge.localhost /etc/hosts")
|
||||
dur = time.monotonic() - t0
|
||||
if rc2 == 0 and "challenge.localhost" in out2:
|
||||
return CheckResult(
|
||||
"hosts_resolution", True, "/etc/hosts has entry", dur
|
||||
)
|
||||
return CheckResult(
|
||||
"hosts_resolution", False, "challenge.localhost not resolvable", dur
|
||||
)
|
||||
|
||||
|
||||
async def check_pids_limit(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(
|
||||
cfg,
|
||||
user,
|
||||
"cat /sys/fs/cgroup/pids.max 2>/dev/null || cat /sys/fs/cgroup/pids/pids.max 2>/dev/null",
|
||||
)
|
||||
dur = time.monotonic() - t0
|
||||
val = out.strip()
|
||||
if val == "max":
|
||||
return CheckResult("pids_limit", True, "unlimited", dur)
|
||||
try:
|
||||
limit = int(val)
|
||||
if limit >= 1024:
|
||||
return CheckResult("pids_limit", True, f"pids_limit={limit}", dur)
|
||||
return CheckResult(
|
||||
"pids_limit", False, f"pids_limit={limit} (need >= 1024)", dur
|
||||
)
|
||||
except ValueError:
|
||||
return CheckResult("pids_limit", False, f"Cannot parse: {val[:60]}", dur)
|
||||
|
||||
|
||||
async def check_mem_limit(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(
|
||||
cfg,
|
||||
user,
|
||||
"cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null",
|
||||
)
|
||||
dur = time.monotonic() - t0
|
||||
val = out.strip()
|
||||
if val == "max":
|
||||
return CheckResult("mem_limit", True, "unlimited", dur)
|
||||
try:
|
||||
limit = int(val)
|
||||
limit_gb = limit / (1024**3)
|
||||
if (
|
||||
limit_gb >= 1.8
|
||||
): # 2GB for privileged RL containers (not 4GB to manage memory pressure)
|
||||
return CheckResult("mem_limit", True, f"mem={limit_gb:.1f}GB", dur)
|
||||
return CheckResult(
|
||||
"mem_limit", False, f"mem={limit_gb:.1f}GB (need >= 2GB)", dur
|
||||
)
|
||||
except ValueError:
|
||||
return CheckResult("mem_limit", False, f"Cannot parse: {val[:60]}", dur)
|
||||
|
||||
|
||||
async def check_challenge_run(cfg: SSHConfig, user: str) -> CheckResult:
|
||||
"""Run /challenge/run and verify no PermissionError."""
|
||||
t0 = time.monotonic()
|
||||
rc, out = await ssh_run(cfg, user, "/challenge/run < /dev/null", timeout=15)
|
||||
dur = time.monotonic() - t0
|
||||
if "PermissionError" in out or "Operation not permitted" in out:
|
||||
snippet = [l for l in out.splitlines() if "Permission" in l or "Operation" in l]
|
||||
return CheckResult(
|
||||
"challenge_run",
|
||||
False,
|
||||
snippet[0][:120] if snippet else "PermissionError",
|
||||
dur,
|
||||
)
|
||||
return CheckResult("challenge_run", True, f"No permission errors (rc={rc})", dur)
|
||||
|
||||
|
||||
# ── Test cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASES = [
|
||||
TestCase("unprivileged_basic", "hello/hello", [check_ssh_echo]),
|
||||
TestCase(
|
||||
"privileged_caps",
|
||||
"intercepting-communication/udp-1",
|
||||
[check_ssh_echo, check_capeff],
|
||||
),
|
||||
TestCase(
|
||||
"privileged_challenge_run",
|
||||
"intercepting-communication/udp-1",
|
||||
[check_challenge_run],
|
||||
),
|
||||
TestCase(
|
||||
"web_challenge_hosts",
|
||||
"web-security/path-traversal-1",
|
||||
[check_ssh_echo, check_hosts_resolution],
|
||||
),
|
||||
TestCase(
|
||||
"resource_limits",
|
||||
"intercepting-communication/udp-1",
|
||||
[check_pids_limit, check_mem_limit],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── Runner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def run_tests(args) -> dict:
|
||||
cfg = SSHConfig(host=args.ssh_host, port=args.ssh_port, key=args.ssh_key)
|
||||
client = DojoRLClient(args.base_url)
|
||||
|
||||
status = await client.status()
|
||||
print(
|
||||
f"Server: {args.base_url} (RL={'enabled' if status.enabled else 'DISABLED'}, "
|
||||
f"{status.max_instances} max, {status.running} running)"
|
||||
)
|
||||
if status.running > 0:
|
||||
n = await client.destroy_all()
|
||||
print(f"Cleaned up {n} instance(s)")
|
||||
print()
|
||||
|
||||
results: list[TestResult] = []
|
||||
test_num = 0
|
||||
total = len(TEST_CASES) + (0 if args.skip_concurrent else 1)
|
||||
start_time = time.monotonic()
|
||||
|
||||
for tc in TEST_CASES:
|
||||
test_num += 1
|
||||
t0 = time.monotonic()
|
||||
tr = TestResult(name=tc.name, challenge=tc.challenge)
|
||||
print(f"[{test_num}/{total}] {tc.name} ({tc.challenge})")
|
||||
|
||||
try:
|
||||
inst = await client.create_instance(tc.challenge)
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if "404" in err or "not found" in err.lower() or "Invalid" in err:
|
||||
tr.skipped = True
|
||||
tr.error = f"Challenge not available: {err[:80]}"
|
||||
print(f" SKIP {tr.error}")
|
||||
else:
|
||||
tr.error = f"create_instance failed: {err[:100]}"
|
||||
print(f" ERR {tr.error}")
|
||||
tr.duration = time.monotonic() - t0
|
||||
results.append(tr)
|
||||
print(f" --- {'SKIP' if tr.skipped else 'FAIL'} ({tr.duration:.1f}s)\n")
|
||||
continue
|
||||
|
||||
try:
|
||||
ready = await wait_ssh_ready(cfg, inst.ssh_user)
|
||||
if not ready:
|
||||
tr.error = "SSH not ready after 10 retries"
|
||||
tr.checks.append(
|
||||
CheckResult("ssh_ready", False, tr.error, time.monotonic() - t0)
|
||||
)
|
||||
print(f" FAIL ssh_ready: {tr.error}")
|
||||
else:
|
||||
for check_fn in tc.checks:
|
||||
cr = await check_fn(cfg, inst.ssh_user)
|
||||
tr.checks.append(cr)
|
||||
tag = "PASS" if cr.passed else "FAIL"
|
||||
extra = f" ({cr.message})" if args.verbose or not cr.passed else ""
|
||||
print(f" {tag} {cr.name:30s} {cr.duration:.1f}s{extra}")
|
||||
if not cr.passed:
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
await client.destroy_instance(inst.slot)
|
||||
except Exception as e:
|
||||
print(f" WARN destroy failed: {e}")
|
||||
|
||||
tr.passed = all(c.passed for c in tr.checks) and not tr.error
|
||||
tr.duration = time.monotonic() - t0
|
||||
results.append(tr)
|
||||
print(f" --- {'PASS' if tr.passed else 'FAIL'} ({tr.duration:.1f}s)\n")
|
||||
|
||||
if not args.skip_concurrent:
|
||||
test_num += 1
|
||||
t0 = time.monotonic()
|
||||
tr = TestResult(name="concurrent_lifecycle", challenge="8x hello/hello")
|
||||
n_concurrent = min(8, status.max_instances)
|
||||
print(
|
||||
f"[{test_num}/{total}] concurrent_lifecycle ({n_concurrent}x hello/hello)"
|
||||
)
|
||||
|
||||
try:
|
||||
ct0 = time.monotonic()
|
||||
tasks = [client.create_instance("hello/hello") for _ in range(n_concurrent)]
|
||||
instances = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
create_dur = time.monotonic() - ct0
|
||||
|
||||
created = [i for i in instances if not isinstance(i, Exception)]
|
||||
errors = [i for i in instances if isinstance(i, Exception)]
|
||||
if errors:
|
||||
tr.checks.append(
|
||||
CheckResult(
|
||||
"create_all",
|
||||
False,
|
||||
f"{len(errors)}/{n_concurrent} failed: {errors[0]}",
|
||||
create_dur,
|
||||
)
|
||||
)
|
||||
else:
|
||||
tr.checks.append(
|
||||
CheckResult(
|
||||
"create_all", True, f"{n_concurrent} created", create_dur
|
||||
)
|
||||
)
|
||||
|
||||
if created:
|
||||
await asyncio.sleep(3)
|
||||
et0 = time.monotonic()
|
||||
echo_tasks = [
|
||||
ssh_run(cfg, i.ssh_user, "echo ok", timeout=15) for i in created
|
||||
]
|
||||
echo_results = await asyncio.gather(*echo_tasks, return_exceptions=True)
|
||||
echo_ok = sum(
|
||||
1
|
||||
for r in echo_results
|
||||
if not isinstance(r, Exception) and r[0] == 0
|
||||
)
|
||||
tr.checks.append(
|
||||
CheckResult(
|
||||
"ssh_echo_all",
|
||||
echo_ok == len(created),
|
||||
f"{echo_ok}/{len(created)} connected",
|
||||
time.monotonic() - et0,
|
||||
)
|
||||
)
|
||||
|
||||
dt0 = time.monotonic()
|
||||
destroyed = await client.destroy_all()
|
||||
tr.checks.append(
|
||||
CheckResult(
|
||||
"destroy_all",
|
||||
True,
|
||||
f"destroyed {destroyed}",
|
||||
time.monotonic() - dt0,
|
||||
)
|
||||
)
|
||||
|
||||
st = await client.status()
|
||||
live = sum(1 for i in st.instances if i.status == "running")
|
||||
tr.checks.append(
|
||||
CheckResult(
|
||||
"slot_cleanup",
|
||||
live == 0,
|
||||
f"running={live} (total listed={st.running})",
|
||||
0.0,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
tr.error = str(e)[:200]
|
||||
tr.checks.append(CheckResult("concurrent", False, str(e)[:100], 0.0))
|
||||
|
||||
tr.passed = all(c.passed for c in tr.checks) and not tr.error
|
||||
tr.duration = time.monotonic() - t0
|
||||
results.append(tr)
|
||||
for cr in tr.checks:
|
||||
tag = "PASS" if cr.passed else "FAIL"
|
||||
extra = f" ({cr.message})" if args.verbose or not cr.passed else ""
|
||||
print(f" {tag} {cr.name:30s} {cr.duration:.1f}s{extra}")
|
||||
print(f" --- {'PASS' if tr.passed else 'FAIL'} ({tr.duration:.1f}s)\n")
|
||||
|
||||
total_dur = time.monotonic() - start_time
|
||||
passed = sum(1 for r in results if r.passed)
|
||||
failed = sum(1 for r in results if not r.passed and not r.skipped)
|
||||
skipped = sum(1 for r in results if r.skipped)
|
||||
|
||||
print("=" * 50)
|
||||
parts = [f"{passed}/{len(results)} passed"]
|
||||
if failed:
|
||||
parts.append(f"{failed} failed")
|
||||
if skipped:
|
||||
parts.append(f"{skipped} skipped")
|
||||
print(f"RESULTS: {', '.join(parts)} in {total_dur:.0f}s")
|
||||
print("=" * 50)
|
||||
|
||||
return {
|
||||
"test": "capability_verification",
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"server": args.base_url,
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"duration_seconds": round(total_dur, 1),
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"name": r.name,
|
||||
"challenge": r.challenge,
|
||||
"passed": r.passed,
|
||||
"skipped": r.skipped,
|
||||
"error": r.error,
|
||||
"duration": round(r.duration, 1),
|
||||
"checks": [asdict(c) for c in r.checks],
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Capability verification test for pwn-dojo RL infrastructure",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--base-url", default="http://100.120.55.25:8080")
|
||||
parser.add_argument("--ssh-host", default="100.120.55.25")
|
||||
parser.add_argument("--ssh-port", type=int, default=2222)
|
||||
parser.add_argument(
|
||||
"--ssh-key", default="environments/pwncollege_env/keys/rl_test_key"
|
||||
)
|
||||
parser.add_argument("--output", "-o", help="Write JSON report")
|
||||
parser.add_argument("--skip-concurrent", action="store_true")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
||||
args = parser.parse_args()
|
||||
|
||||
key = Path(args.ssh_key)
|
||||
if not key.exists():
|
||||
key = _repo_root / args.ssh_key
|
||||
if not key.exists():
|
||||
print(f"SSH key not found: {args.ssh_key}")
|
||||
sys.exit(1)
|
||||
args.ssh_key = str(key)
|
||||
|
||||
if not args.yes:
|
||||
print(f"Will test against {args.base_url}")
|
||||
if input("Continue? [y/N] ").lower() != "y":
|
||||
sys.exit(0)
|
||||
|
||||
report = asyncio.run(run_tests(args))
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f"\nJSON report: {args.output}")
|
||||
|
||||
sys.exit(0 if report["summary"]["failed"] == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""submit_flag tool for pwn.college RL environments.
|
||||
|
||||
Registers a `submit_flag` tool in the hermes-agent tool registry under the
|
||||
"pwncollege" toolset. The handler checks flags against the dojo RL API using
|
||||
per-task context (SDK client + slot) stored in a module-level dict.
|
||||
|
||||
Usage in an environment:
|
||||
from environments.pwncollege_env.submit_flag_tool import (
|
||||
register_flag_context, clear_flag_context,
|
||||
)
|
||||
|
||||
# Before agent loop
|
||||
register_flag_context(task_id, sync_client, slot)
|
||||
|
||||
# After agent loop
|
||||
clear_flag_context(task_id)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-task context: task_id → {"client": DojoRLSyncClient, "slot": int}
|
||||
_task_flag_context: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def register_flag_context(task_id: str, sync_client: Any, slot: int) -> None:
|
||||
"""Register dojo client + slot for a rollout so submit_flag can verify flags."""
|
||||
_task_flag_context[task_id] = {"client": sync_client, "slot": slot}
|
||||
|
||||
|
||||
def clear_flag_context(task_id: str) -> None:
|
||||
"""Remove flag context after rollout completes."""
|
||||
_task_flag_context.pop(task_id, None)
|
||||
|
||||
|
||||
def _submit_flag_handler(args: dict, **kw) -> str:
|
||||
"""Handle submit_flag tool calls by checking the flag against the dojo API."""
|
||||
task_id = kw.get("task_id", "default")
|
||||
flag = args.get("flag", "")
|
||||
|
||||
if not flag:
|
||||
return json.dumps({"success": False, "message": "No flag provided."})
|
||||
|
||||
ctx = _task_flag_context.get(task_id)
|
||||
if not ctx:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "No active challenge instance for this task.",
|
||||
})
|
||||
|
||||
try:
|
||||
correct = ctx["client"].check_flag(ctx["slot"], flag)
|
||||
except Exception as e:
|
||||
logger.error("Flag check failed for task %s: %s", task_id, e, exc_info=True)
|
||||
return json.dumps({"success": False, "message": f"Flag check error: {type(e).__name__}"})
|
||||
|
||||
if correct:
|
||||
return json.dumps({"success": True, "message": "Flag accepted! Challenge solved."})
|
||||
return json.dumps({"success": False, "message": "Incorrect flag."})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register in hermes-agent tool registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUBMIT_FLAG_SCHEMA = {
|
||||
"name": "submit_flag",
|
||||
"description": (
|
||||
"Submit a flag for verification. Use this when you find a flag "
|
||||
"(format: pwn.college{...}) to check if it is correct."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flag": {
|
||||
"type": "string",
|
||||
"description": "The flag string (format: pwn.college{...}).",
|
||||
}
|
||||
},
|
||||
"required": ["flag"],
|
||||
},
|
||||
}
|
||||
|
||||
from tools.registry import registry
|
||||
from toolsets import create_custom_toolset
|
||||
|
||||
registry.register(
|
||||
name="submit_flag",
|
||||
toolset="pwncollege",
|
||||
schema=SUBMIT_FLAG_SCHEMA,
|
||||
handler=_submit_flag_handler,
|
||||
emoji="🚩",
|
||||
)
|
||||
|
||||
create_custom_toolset(
|
||||
name="pwncollege",
|
||||
description="PwnCollege CTF tools",
|
||||
tools=["submit_flag"],
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""Built-in gateway hooks that are always registered."""
|
||||
@@ -1,86 +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 os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("hooks.boot-md")
|
||||
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
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()
|
||||
+43
-117
@@ -57,8 +57,6 @@ class Platform(Enum):
|
||||
DINGTALK = "dingtalk"
|
||||
API_SERVER = "api_server"
|
||||
WEBHOOK = "webhook"
|
||||
FEISHU = "feishu"
|
||||
WECOM = "wecom"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -276,12 +274,6 @@ class GatewayConfig:
|
||||
# Webhook uses enabled flag only (secrets are per-route)
|
||||
elif platform == Platform.WEBHOOK:
|
||||
connected.append(platform)
|
||||
# Feishu uses extra dict for app credentials
|
||||
elif platform == Platform.FEISHU and config.extra.get("app_id"):
|
||||
connected.append(platform)
|
||||
# WeCom uses extra dict for bot credentials
|
||||
elif platform == Platform.WECOM and config.extra.get("bot_id"):
|
||||
connected.append(platform)
|
||||
return connected
|
||||
|
||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||
@@ -515,10 +507,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
)
|
||||
if "reply_prefix" in platform_cfg:
|
||||
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
|
||||
if "require_mention" in platform_cfg:
|
||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||
if "mention_patterns" in platform_cfg:
|
||||
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
||||
if not bridged:
|
||||
continue
|
||||
plat_data = platforms_data.setdefault(plat.value, {})
|
||||
@@ -543,20 +531,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
|
||||
# Telegram settings → env vars (env vars take precedence)
|
||||
telegram_cfg = yaml_cfg.get("telegram", {})
|
||||
if isinstance(telegram_cfg, dict):
|
||||
if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
|
||||
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
||||
import json as _json
|
||||
os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
|
||||
frc = telegram_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process config.yaml — falling back to .env / gateway.json values. "
|
||||
@@ -673,13 +647,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
config.platforms[Platform.SLACK] = PlatformConfig()
|
||||
config.platforms[Platform.SLACK].enabled = True
|
||||
config.platforms[Platform.SLACK].token = slack_token
|
||||
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
||||
if slack_home and Platform.SLACK in config.platforms:
|
||||
config.platforms[Platform.SLACK].home_channel = HomeChannel(
|
||||
platform=Platform.SLACK,
|
||||
chat_id=slack_home,
|
||||
name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""),
|
||||
)
|
||||
# Home channel
|
||||
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
||||
if slack_home:
|
||||
config.platforms[Platform.SLACK].home_channel = HomeChannel(
|
||||
platform=Platform.SLACK,
|
||||
chat_id=slack_home,
|
||||
name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""),
|
||||
)
|
||||
|
||||
# Signal
|
||||
signal_url = os.getenv("SIGNAL_HTTP_URL")
|
||||
@@ -693,13 +668,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
"account": signal_account,
|
||||
"ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"),
|
||||
})
|
||||
signal_home = os.getenv("SIGNAL_HOME_CHANNEL")
|
||||
if signal_home and Platform.SIGNAL in config.platforms:
|
||||
config.platforms[Platform.SIGNAL].home_channel = HomeChannel(
|
||||
platform=Platform.SIGNAL,
|
||||
chat_id=signal_home,
|
||||
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
signal_home = os.getenv("SIGNAL_HOME_CHANNEL")
|
||||
if signal_home:
|
||||
config.platforms[Platform.SIGNAL].home_channel = HomeChannel(
|
||||
platform=Platform.SIGNAL,
|
||||
chat_id=signal_home,
|
||||
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Mattermost
|
||||
mattermost_token = os.getenv("MATTERMOST_TOKEN")
|
||||
@@ -712,13 +687,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
config.platforms[Platform.MATTERMOST].enabled = True
|
||||
config.platforms[Platform.MATTERMOST].token = mattermost_token
|
||||
config.platforms[Platform.MATTERMOST].extra["url"] = mattermost_url
|
||||
mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL")
|
||||
if mattermost_home and Platform.MATTERMOST in config.platforms:
|
||||
config.platforms[Platform.MATTERMOST].home_channel = HomeChannel(
|
||||
platform=Platform.MATTERMOST,
|
||||
chat_id=mattermost_home,
|
||||
name=os.getenv("MATTERMOST_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL")
|
||||
if mattermost_home:
|
||||
config.platforms[Platform.MATTERMOST].home_channel = HomeChannel(
|
||||
platform=Platform.MATTERMOST,
|
||||
chat_id=mattermost_home,
|
||||
name=os.getenv("MATTERMOST_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Matrix
|
||||
matrix_token = os.getenv("MATRIX_ACCESS_TOKEN")
|
||||
@@ -740,13 +715,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
config.platforms[Platform.MATRIX].extra["password"] = matrix_password
|
||||
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
||||
config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee
|
||||
matrix_home = os.getenv("MATRIX_HOME_ROOM")
|
||||
if matrix_home and Platform.MATRIX in config.platforms:
|
||||
config.platforms[Platform.MATRIX].home_channel = HomeChannel(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id=matrix_home,
|
||||
name=os.getenv("MATRIX_HOME_ROOM_NAME", "Home"),
|
||||
)
|
||||
matrix_home = os.getenv("MATRIX_HOME_ROOM")
|
||||
if matrix_home:
|
||||
config.platforms[Platform.MATRIX].home_channel = HomeChannel(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id=matrix_home,
|
||||
name=os.getenv("MATRIX_HOME_ROOM_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Home Assistant
|
||||
hass_token = os.getenv("HASS_TOKEN")
|
||||
@@ -773,13 +748,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
"imap_host": email_imap,
|
||||
"smtp_host": email_smtp,
|
||||
})
|
||||
email_home = os.getenv("EMAIL_HOME_ADDRESS")
|
||||
if email_home and Platform.EMAIL in config.platforms:
|
||||
config.platforms[Platform.EMAIL].home_channel = HomeChannel(
|
||||
platform=Platform.EMAIL,
|
||||
chat_id=email_home,
|
||||
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
|
||||
)
|
||||
email_home = os.getenv("EMAIL_HOME_ADDRESS")
|
||||
if email_home:
|
||||
config.platforms[Platform.EMAIL].home_channel = HomeChannel(
|
||||
platform=Platform.EMAIL,
|
||||
chat_id=email_home,
|
||||
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
|
||||
)
|
||||
|
||||
# SMS (Twilio)
|
||||
twilio_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||
@@ -788,13 +763,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
config.platforms[Platform.SMS] = PlatformConfig()
|
||||
config.platforms[Platform.SMS].enabled = True
|
||||
config.platforms[Platform.SMS].api_key = os.getenv("TWILIO_AUTH_TOKEN", "")
|
||||
sms_home = os.getenv("SMS_HOME_CHANNEL")
|
||||
if sms_home and Platform.SMS in config.platforms:
|
||||
config.platforms[Platform.SMS].home_channel = HomeChannel(
|
||||
platform=Platform.SMS,
|
||||
chat_id=sms_home,
|
||||
name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
sms_home = os.getenv("SMS_HOME_CHANNEL")
|
||||
if sms_home:
|
||||
config.platforms[Platform.SMS].home_channel = HomeChannel(
|
||||
platform=Platform.SMS,
|
||||
chat_id=sms_home,
|
||||
name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# API Server
|
||||
api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes")
|
||||
@@ -836,55 +811,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
if webhook_secret:
|
||||
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
|
||||
|
||||
# Feishu / Lark
|
||||
feishu_app_id = os.getenv("FEISHU_APP_ID")
|
||||
feishu_app_secret = os.getenv("FEISHU_APP_SECRET")
|
||||
if feishu_app_id and feishu_app_secret:
|
||||
if Platform.FEISHU not in config.platforms:
|
||||
config.platforms[Platform.FEISHU] = PlatformConfig()
|
||||
config.platforms[Platform.FEISHU].enabled = True
|
||||
config.platforms[Platform.FEISHU].extra.update({
|
||||
"app_id": feishu_app_id,
|
||||
"app_secret": feishu_app_secret,
|
||||
"domain": os.getenv("FEISHU_DOMAIN", "feishu"),
|
||||
"connection_mode": os.getenv("FEISHU_CONNECTION_MODE", "websocket"),
|
||||
})
|
||||
feishu_encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY", "")
|
||||
if feishu_encrypt_key:
|
||||
config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key
|
||||
feishu_verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN", "")
|
||||
if feishu_verification_token:
|
||||
config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token
|
||||
feishu_home = os.getenv("FEISHU_HOME_CHANNEL")
|
||||
if feishu_home:
|
||||
config.platforms[Platform.FEISHU].home_channel = HomeChannel(
|
||||
platform=Platform.FEISHU,
|
||||
chat_id=feishu_home,
|
||||
name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# WeCom (Enterprise WeChat)
|
||||
wecom_bot_id = os.getenv("WECOM_BOT_ID")
|
||||
wecom_secret = os.getenv("WECOM_SECRET")
|
||||
if wecom_bot_id and wecom_secret:
|
||||
if Platform.WECOM not in config.platforms:
|
||||
config.platforms[Platform.WECOM] = PlatformConfig()
|
||||
config.platforms[Platform.WECOM].enabled = True
|
||||
config.platforms[Platform.WECOM].extra.update({
|
||||
"bot_id": wecom_bot_id,
|
||||
"secret": wecom_secret,
|
||||
})
|
||||
wecom_ws_url = os.getenv("WECOM_WEBSOCKET_URL", "")
|
||||
if wecom_ws_url:
|
||||
config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url
|
||||
wecom_home = os.getenv("WECOM_HOME_CHANNEL")
|
||||
if wecom_home:
|
||||
config.platforms[Platform.WECOM].home_channel = HomeChannel(
|
||||
platform=Platform.WECOM,
|
||||
chat_id=wecom_home,
|
||||
name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Session settings
|
||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||
if idle_minutes:
|
||||
|
||||
@@ -51,33 +51,14 @@ class HookRegistry:
|
||||
"""Return metadata about all loaded hooks."""
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
def discover_and_load(self) -> None:
|
||||
"""
|
||||
Scan the hooks directory for hook directories and load their handlers.
|
||||
|
||||
Also registers built-in hooks that are always active.
|
||||
|
||||
Each hook directory must contain:
|
||||
- HOOK.yaml with at least 'name' and 'events' keys
|
||||
- handler.py with a top-level 'handle' function (sync or async)
|
||||
"""
|
||||
self._register_builtin_hooks()
|
||||
|
||||
if not HOOKS_DIR.exists():
|
||||
return
|
||||
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_dir
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
|
||||
@@ -41,7 +41,7 @@ LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
|
||||
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
|
||||
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
|
||||
|
||||
PAIRING_DIR = get_hermes_dir("platforms/pairing", "pairing")
|
||||
PAIRING_DIR = get_hermes_home() / "pairing"
|
||||
|
||||
|
||||
def _secure_write(path: Path, data: str) -> None:
|
||||
|
||||
+67
-137
@@ -166,7 +166,7 @@ class ResponseStore:
|
||||
|
||||
_CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key",
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||
}
|
||||
|
||||
|
||||
@@ -223,23 +223,6 @@ if AIOHTTP_AVAILABLE:
|
||||
else:
|
||||
body_limit_middleware = None # type: ignore[assignment]
|
||||
|
||||
_SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
}
|
||||
|
||||
|
||||
if AIOHTTP_AVAILABLE:
|
||||
@web.middleware
|
||||
async def security_headers_middleware(request, handler):
|
||||
"""Add security headers to all responses (including errors)."""
|
||||
response = await handler(request)
|
||||
for k, v in _SECURITY_HEADERS.items():
|
||||
response.headers.setdefault(k, v)
|
||||
return response
|
||||
else:
|
||||
security_headers_middleware = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class _IdempotencyCache:
|
||||
"""In-memory idempotency cache with TTL and basic LRU semantics."""
|
||||
@@ -324,7 +307,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if "*" in self._cors_origins:
|
||||
headers = dict(_CORS_HEADERS)
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Max-Age"] = "600"
|
||||
return headers
|
||||
|
||||
if origin not in self._cors_origins:
|
||||
@@ -333,7 +315,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
headers = dict(_CORS_HEADERS)
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Vary"] = "Origin"
|
||||
headers["Access-Control-Max-Age"] = "600"
|
||||
return headers
|
||||
|
||||
def _origin_allowed(self, origin: str) -> bool:
|
||||
@@ -514,21 +495,17 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if delta is not None:
|
||||
_stream_q.put(delta)
|
||||
|
||||
# Start agent in background. agent_ref is a mutable container
|
||||
# so the SSE writer can interrupt the agent on client disconnect.
|
||||
agent_ref = [None]
|
||||
# Start agent in background
|
||||
agent_task = asyncio.ensure_future(self._run_agent(
|
||||
user_message=user_message,
|
||||
conversation_history=history,
|
||||
ephemeral_system_prompt=system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=_on_delta,
|
||||
agent_ref=agent_ref,
|
||||
))
|
||||
|
||||
return await self._write_sse_chat_completion(
|
||||
request, completion_id, model_name, created, _stream_q,
|
||||
agent_task, agent_ref,
|
||||
request, completion_id, model_name, created, _stream_q, agent_task
|
||||
)
|
||||
|
||||
# Non-streaming: run the agent (with optional Idempotency-Key)
|
||||
@@ -591,107 +568,80 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _write_sse_chat_completion(
|
||||
self, request: "web.Request", completion_id: str, model: str,
|
||||
created: int, stream_q, agent_task, agent_ref=None,
|
||||
created: int, stream_q, agent_task,
|
||||
) -> "web.StreamResponse":
|
||||
"""Write real streaming SSE from agent's stream_delta_callback queue.
|
||||
|
||||
If the client disconnects mid-stream (network drop, browser tab close),
|
||||
the agent is interrupted via ``agent.interrupt()`` so it stops making
|
||||
LLM API calls, and the asyncio task wrapper is cancelled.
|
||||
"""
|
||||
"""Write real streaming SSE from agent's stream_delta_callback queue."""
|
||||
import queue as _q
|
||||
|
||||
sse_headers = {"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}
|
||||
# CORS middleware can't inject headers into StreamResponse after
|
||||
# prepare() flushes them, so resolve CORS headers up front.
|
||||
origin = request.headers.get("Origin", "")
|
||||
cors = self._cors_headers_for_origin(origin) if origin else None
|
||||
if cors:
|
||||
sse_headers.update(cors)
|
||||
response = web.StreamResponse(status=200, headers=sse_headers)
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
try:
|
||||
# Role chunk
|
||||
role_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
|
||||
# Role chunk
|
||||
role_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
|
||||
|
||||
# Stream content chunks as they arrive from the agent
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
try:
|
||||
delta = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
|
||||
except _q.Empty:
|
||||
if agent_task.done():
|
||||
# Drain any remaining items
|
||||
while True:
|
||||
try:
|
||||
delta = stream_q.get_nowait()
|
||||
if delta is None:
|
||||
break
|
||||
content_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
|
||||
except _q.Empty:
|
||||
break
|
||||
break
|
||||
continue
|
||||
|
||||
if delta is None: # End of stream sentinel
|
||||
break
|
||||
|
||||
content_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
|
||||
|
||||
# Get usage from completed agent
|
||||
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
# Stream content chunks as they arrive from the agent
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
try:
|
||||
result, agent_usage = await agent_task
|
||||
usage = agent_usage or usage
|
||||
except Exception:
|
||||
pass
|
||||
delta = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
|
||||
except _q.Empty:
|
||||
if agent_task.done():
|
||||
# Drain any remaining items
|
||||
while True:
|
||||
try:
|
||||
delta = stream_q.get_nowait()
|
||||
if delta is None:
|
||||
break
|
||||
content_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
|
||||
except _q.Empty:
|
||||
break
|
||||
break
|
||||
continue
|
||||
|
||||
# Finish chunk
|
||||
finish_chunk = {
|
||||
if delta is None: # End of stream sentinel
|
||||
break
|
||||
|
||||
content_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {
|
||||
"prompt_tokens": usage.get("input_tokens", 0),
|
||||
"completion_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(finish_chunk)}\n\n".encode())
|
||||
await response.write(b"data: [DONE]\n\n")
|
||||
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
|
||||
# Client disconnected mid-stream. Interrupt the agent so it
|
||||
# stops making LLM API calls at the next loop iteration, then
|
||||
# cancel the asyncio task wrapper.
|
||||
agent = agent_ref[0] if agent_ref else None
|
||||
if agent is not None:
|
||||
try:
|
||||
agent.interrupt("SSE client disconnected")
|
||||
except Exception:
|
||||
pass
|
||||
if not agent_task.done():
|
||||
agent_task.cancel()
|
||||
try:
|
||||
await agent_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
|
||||
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
|
||||
|
||||
# Get usage from completed agent
|
||||
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
try:
|
||||
result, agent_usage = await agent_task
|
||||
usage = agent_usage or usage
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Finish chunk
|
||||
finish_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {
|
||||
"prompt_tokens": usage.get("input_tokens", 0),
|
||||
"completion_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
}
|
||||
await response.write(f"data: {json.dumps(finish_chunk)}\n\n".encode())
|
||||
await response.write(b"data: [DONE]\n\n")
|
||||
|
||||
return response
|
||||
|
||||
@@ -1194,18 +1144,12 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
ephemeral_system_prompt: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
stream_delta_callback=None,
|
||||
agent_ref: Optional[list] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Create an agent and run a conversation in a thread executor.
|
||||
|
||||
Returns ``(result_dict, usage_dict)`` where *usage_dict* contains
|
||||
``input_tokens``, ``output_tokens`` and ``total_tokens``.
|
||||
|
||||
If *agent_ref* is a one-element list, the AIAgent instance is stored
|
||||
at ``agent_ref[0]`` before ``run_conversation`` begins. This allows
|
||||
callers (e.g. the SSE writer) to call ``agent.interrupt()`` from
|
||||
another thread to stop in-progress LLM calls.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
@@ -1215,8 +1159,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
session_id=session_id,
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
@@ -1241,11 +1183,10 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
try:
|
||||
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
||||
mws = [mw for mw in (cors_middleware, body_limit_middleware) if mw is not None]
|
||||
self._app = web.Application(middlewares=mws)
|
||||
self._app["api_server_adapter"] = self
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/models", self._handle_models)
|
||||
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
||||
self._app.router.add_post("/v1/responses", self._handle_responses)
|
||||
@@ -1261,17 +1202,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
|
||||
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
|
||||
|
||||
# Port conflict detection — fail fast if port is already in use
|
||||
import socket as _socket
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
|
||||
_s.settimeout(1)
|
||||
_s.connect(('127.0.0.1', self._port))
|
||||
logger.error('[%s] Port %d already in use. Set a different port in config.yaml: platforms.api_server.port', self.name, self._port)
|
||||
return False
|
||||
except (ConnectionRefusedError, OSError):
|
||||
pass # port is free
|
||||
|
||||
self._runner = web.AppRunner(self._app)
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(self._runner, self._host, self._port)
|
||||
|
||||
+23
-90
@@ -27,7 +27,6 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import get_hermes_dir
|
||||
|
||||
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
@@ -45,8 +44,8 @@ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
# (e.g. Telegram file URLs expire after ~1 hour).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default location: {HERMES_HOME}/cache/images/ (legacy: image_cache/)
|
||||
IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache")
|
||||
# Default location: {HERMES_HOME}/image_cache/
|
||||
IMAGE_CACHE_DIR = get_hermes_home() / "image_cache"
|
||||
|
||||
|
||||
def get_image_cache_dir() -> Path:
|
||||
@@ -148,7 +147,7 @@ def cleanup_image_cache(max_age_hours: int = 24) -> int:
|
||||
# here so the STT tool (OpenAI Whisper) can transcribe them from local files.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUDIO_CACHE_DIR = get_hermes_dir("cache/audio", "audio_cache")
|
||||
AUDIO_CACHE_DIR = get_hermes_home() / "audio_cache"
|
||||
|
||||
|
||||
def get_audio_cache_dir() -> Path:
|
||||
@@ -175,51 +174,29 @@ def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str:
|
||||
return str(filepath)
|
||||
|
||||
|
||||
async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) -> str:
|
||||
async def cache_audio_from_url(url: str, ext: str = ".ogg") -> str:
|
||||
"""
|
||||
Download an audio file from a URL and save it to the local cache.
|
||||
|
||||
Retries on transient failures (timeouts, 429, 5xx) with exponential
|
||||
backoff so a single slow CDN response doesn't lose the media.
|
||||
|
||||
Args:
|
||||
url: The HTTP/HTTPS URL to download from.
|
||||
ext: File extension including the dot (e.g. ".ogg", ".mp3").
|
||||
retries: Number of retry attempts on transient failures.
|
||||
|
||||
Returns:
|
||||
Absolute path to the cached audio file as a string.
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging as _logging
|
||||
_log = _logging.getLogger(__name__)
|
||||
|
||||
last_exc = None
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
|
||||
"Accept": "audio/*,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cache_audio_from_bytes(response.content, ext)
|
||||
except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
|
||||
last_exc = exc
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
|
||||
raise
|
||||
if attempt < retries:
|
||||
wait = 1.5 * (attempt + 1)
|
||||
_log.debug("Audio cache retry %d/%d for %s (%.1fs): %s",
|
||||
attempt + 1, retries, url[:80], wait, exc)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
raise last_exc
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
|
||||
"Accept": "audio/*,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cache_audio_from_bytes(response.content, ext)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -229,7 +206,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
|
||||
# here so the agent can reference them by local file path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
|
||||
DOCUMENT_CACHE_DIR = get_hermes_home() / "document_cache"
|
||||
|
||||
SUPPORTED_DOCUMENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
@@ -356,10 +333,7 @@ class MessageEvent:
|
||||
return None
|
||||
# Split on space and get first word, strip the /
|
||||
parts = self.text.split(maxsplit=1)
|
||||
raw = parts[0][1:].lower() if parts else None
|
||||
if raw and "@" in raw:
|
||||
raw = raw.split("@", 1)[0]
|
||||
return raw
|
||||
return parts[0][1:].lower() if parts else None
|
||||
|
||||
def get_command_args(self) -> str:
|
||||
"""Get the arguments after a command."""
|
||||
@@ -898,26 +872,6 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Processing lifecycle hooks ──────────────────────────────────────────
|
||||
# Subclasses override these to react to message processing events
|
||||
# (e.g. Discord adds 👀/✅/❌ reactions).
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Hook called when background processing begins."""
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
"""Hook called when background processing completes."""
|
||||
|
||||
async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Run a lifecycle hook without letting failures break message flow."""
|
||||
hook = getattr(self, hook_name, None)
|
||||
if not callable(hook):
|
||||
return
|
||||
try:
|
||||
await hook(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e)
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable_error(error: Optional[str]) -> bool:
|
||||
"""Return True if the error string looks like a transient network failure."""
|
||||
@@ -1025,7 +979,7 @@ class BasePlatformAdapter(ABC):
|
||||
# simultaneous messages. Queue them without interrupting the active run,
|
||||
# then process them immediately after the current task finishes.
|
||||
if event.message_type == MessageType.PHOTO:
|
||||
logger.debug("[%s] Queuing photo follow-up for session %s without interrupt", self.name, session_key)
|
||||
print(f"[{self.name}] 🖼️ Queuing photo follow-up for session {session_key} without interrupt")
|
||||
existing = self._pending_messages.get(session_key)
|
||||
if existing and existing.message_type == MessageType.PHOTO:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
@@ -1040,7 +994,7 @@ class BasePlatformAdapter(ABC):
|
||||
return # Don't interrupt now - will run after current task completes
|
||||
|
||||
# Default behavior for non-photo follow-ups: interrupt the running agent
|
||||
logger.debug("[%s] New message while session %s is active — triggering interrupt", self.name, session_key)
|
||||
print(f"[{self.name}] ⚡ New message while session {session_key} is active - triggering interrupt")
|
||||
self._pending_messages[session_key] = event
|
||||
# Signal the interrupt (the processing task checks this)
|
||||
self._active_sessions[session_key].set()
|
||||
@@ -1080,18 +1034,6 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
|
||||
"""Background task that actually processes the message."""
|
||||
# Track delivery outcomes for the processing-complete hook
|
||||
delivery_attempted = False
|
||||
delivery_succeeded = False
|
||||
|
||||
def _record_delivery(result):
|
||||
nonlocal delivery_attempted, delivery_succeeded
|
||||
if result is None:
|
||||
return
|
||||
delivery_attempted = True
|
||||
if getattr(result, "success", False):
|
||||
delivery_succeeded = True
|
||||
|
||||
# Create interrupt event for this session
|
||||
interrupt_event = asyncio.Event()
|
||||
self._active_sessions[session_key] = interrupt_event
|
||||
@@ -1101,8 +1043,6 @@ class BasePlatformAdapter(ABC):
|
||||
typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata))
|
||||
|
||||
try:
|
||||
await self._run_processing_hook("on_processing_start", event)
|
||||
|
||||
# Call the handler (this can take a while with tool calls)
|
||||
response = await self._message_handler(event)
|
||||
|
||||
@@ -1172,7 +1112,6 @@ class BasePlatformAdapter(ABC):
|
||||
reply_to=event.message_id,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
_record_delivery(result)
|
||||
|
||||
# Human-like pacing delay between text and media
|
||||
human_delay = self._get_human_delay()
|
||||
@@ -1241,9 +1180,9 @@ class BasePlatformAdapter(ABC):
|
||||
)
|
||||
|
||||
if not media_result.success:
|
||||
logger.warning("[%s] Failed to send media (%s): %s", self.name, ext, media_result.error)
|
||||
print(f"[{self.name}] Failed to send media ({ext}): {media_result.error}")
|
||||
except Exception as media_err:
|
||||
logger.warning("[%s] Error sending media: %s", self.name, media_err)
|
||||
print(f"[{self.name}] Error sending media: {media_err}")
|
||||
|
||||
# Send auto-detected local files as native attachments
|
||||
for file_path in local_files:
|
||||
@@ -1272,14 +1211,10 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
# Determine overall success for the processing hook
|
||||
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
|
||||
await self._run_processing_hook("on_processing_complete", event, processing_ok)
|
||||
|
||||
# Check if there's a pending message that was queued during our processing
|
||||
if session_key in self._pending_messages:
|
||||
pending_event = self._pending_messages.pop(session_key)
|
||||
logger.debug("[%s] Processing queued message from interrupt", self.name)
|
||||
print(f"[{self.name}] 📨 Processing queued message from interrupt")
|
||||
# Clean up current session before processing pending
|
||||
if session_key in self._active_sessions:
|
||||
del self._active_sessions[session_key]
|
||||
@@ -1292,12 +1227,10 @@ class BasePlatformAdapter(ABC):
|
||||
await self._process_message_background(pending_event, session_key)
|
||||
return # Already cleaned up
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._run_processing_hook("on_processing_complete", event, False)
|
||||
raise
|
||||
except Exception as e:
|
||||
await self._run_processing_hook("on_processing_complete", event, False)
|
||||
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
|
||||
print(f"[{self.name}] Error handling message: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# Send the error to the user so they aren't left with radio silence
|
||||
try:
|
||||
error_type = type(e).__name__
|
||||
|
||||
@@ -486,17 +486,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Acquire scoped lock to prevent duplicate bot token usage
|
||||
from gateway.status import acquire_scoped_lock
|
||||
self._token_lock_identity = self.config.token
|
||||
acquired, existing = acquire_scoped_lock('discord-bot-token', self._token_lock_identity, metadata={'platform': 'discord'})
|
||||
if not acquired:
|
||||
owner_pid = existing.get('pid') if isinstance(existing, dict) else None
|
||||
message = f'Discord bot token already in use' + (f' (PID {owner_pid})' if owner_pid else '') + '. Stop the other gateway first.'
|
||||
logger.error('[%s] %s', self.name, message)
|
||||
self._set_fatal_error('discord_token_lock', message, retryable=False)
|
||||
return False
|
||||
|
||||
# Set up intents -- members intent needed for username-to-ID resolution
|
||||
intents = Intents.default()
|
||||
intents.message_content = True
|
||||
@@ -561,22 +550,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return
|
||||
# "all" falls through to handle_message
|
||||
|
||||
# If the message @mentions other users but NOT the bot, the
|
||||
# sender is talking to someone else — stay silent. Only
|
||||
# applies in server channels; in DMs the user is always
|
||||
# talking to the bot (mentions are just references).
|
||||
# Controlled by DISCORD_IGNORE_NO_MENTION (default: true).
|
||||
_ignore_no_mention = os.getenv(
|
||||
"DISCORD_IGNORE_NO_MENTION", "true"
|
||||
).lower() in ("true", "1", "yes")
|
||||
if _ignore_no_mention and message.mentions and not isinstance(message.channel, discord.DMChannel):
|
||||
_bot_mentioned = (
|
||||
self._client.user is not None
|
||||
and self._client.user in message.mentions
|
||||
)
|
||||
if not _bot_mentioned:
|
||||
return # Talking to someone else, don't interrupt
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
@self._client.event
|
||||
@@ -649,52 +622,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._running = False
|
||||
self._client = None
|
||||
self._ready_event.clear()
|
||||
|
||||
# Release the token lock
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
if getattr(self, '_token_lock_identity', None):
|
||||
release_scoped_lock('discord-bot-token', self._token_lock_identity)
|
||||
self._token_lock_identity = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
async def _add_reaction(self, message: Any, emoji: str) -> bool:
|
||||
"""Add an emoji reaction to a Discord message."""
|
||||
if not message or not hasattr(message, "add_reaction"):
|
||||
return False
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("[%s] add_reaction failed (%s): %s", self.name, emoji, e)
|
||||
return False
|
||||
|
||||
async def _remove_reaction(self, message: Any, emoji: str) -> bool:
|
||||
"""Remove the bot's own emoji reaction from a Discord message."""
|
||||
if not message or not hasattr(message, "remove_reaction") or not self._client or not self._client.user:
|
||||
return False
|
||||
try:
|
||||
await message.remove_reaction(emoji, self._client.user)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e)
|
||||
return False
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Add an in-progress reaction for normal Discord message events."""
|
||||
message = event.raw_message
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._add_reaction(message, "👀")
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
"""Swap the in-progress reaction for a final success/failure reaction."""
|
||||
message = event.raw_message
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._remove_reaction(message, "👀")
|
||||
await self._add_reaction(message, "✅" if success else "❌")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -1485,23 +1413,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
command_text: str,
|
||||
followup_msg: str | None = None,
|
||||
) -> None:
|
||||
"""Common handler for simple slash commands that dispatch a command string.
|
||||
|
||||
Defers the interaction (shows "thinking..."), dispatches the command,
|
||||
then cleans up the deferred response. If *followup_msg* is provided
|
||||
the "thinking..." indicator is replaced with that text; otherwise it
|
||||
is deleted so the channel isn't cluttered.
|
||||
"""
|
||||
"""Common handler for simple slash commands that dispatch a command string."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
event = self._build_slash_event(interaction, command_text)
|
||||
await self.handle_message(event)
|
||||
try:
|
||||
if followup_msg:
|
||||
await interaction.edit_original_response(content=followup_msg)
|
||||
else:
|
||||
await interaction.delete_original_response()
|
||||
except Exception as e:
|
||||
logger.debug("Discord interaction cleanup failed: %s", e)
|
||||
if followup_msg:
|
||||
try:
|
||||
await interaction.followup.send(followup_msg, ephemeral=True)
|
||||
except Exception as e:
|
||||
logger.debug("Discord followup failed: %s", e)
|
||||
|
||||
def _register_slash_commands(self) -> None:
|
||||
"""Register Discord slash commands on the command tree."""
|
||||
@@ -1526,7 +1446,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
@tree.command(name="reasoning", description="Show or change reasoning effort")
|
||||
@discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
|
||||
async def slash_reasoning(interaction: discord.Interaction, effort: str = ""):
|
||||
await self._run_simple_slash(interaction, f"/reasoning {effort}".strip())
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
event = self._build_slash_event(interaction, f"/reasoning {effort}".strip())
|
||||
await self.handle_message(event)
|
||||
|
||||
@tree.command(name="personality", description="Set a personality")
|
||||
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
|
||||
@@ -1599,7 +1521,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
discord.app_commands.Choice(name="status — show current mode", value="status"),
|
||||
])
|
||||
async def slash_voice(interaction: discord.Interaction, mode: str = ""):
|
||||
await self._run_simple_slash(interaction, f"/voice {mode}".strip())
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
event = self._build_slash_event(interaction, f"/voice {mode}".strip())
|
||||
await self.handle_message(event)
|
||||
|
||||
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
||||
async def slash_update(interaction: discord.Interaction):
|
||||
|
||||
+48
-121
@@ -43,20 +43,6 @@ from gateway.platforms.base import (
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Automated sender patterns — emails from these are silently ignored
|
||||
_NOREPLY_PATTERNS = (
|
||||
"noreply", "no-reply", "no_reply", "donotreply", "do-not-reply",
|
||||
"mailer-daemon", "postmaster", "bounce", "notifications@",
|
||||
"automated@", "auto-confirm", "auto-reply", "automailer",
|
||||
)
|
||||
|
||||
# RFC headers that indicate bulk/automated mail
|
||||
_AUTOMATED_HEADERS = {
|
||||
"Auto-Submitted": lambda v: v.lower() != "no",
|
||||
"Precedence": lambda v: v.lower() in ("bulk", "list", "junk"),
|
||||
"X-Auto-Response-Suppress": lambda v: bool(v),
|
||||
"List-Unsubscribe": lambda v: bool(v),
|
||||
}
|
||||
|
||||
# Gmail-safe max length per email body
|
||||
MAX_MESSAGE_LENGTH = 50_000
|
||||
@@ -64,17 +50,7 @@ MAX_MESSAGE_LENGTH = 50_000
|
||||
# Supported image extensions for inline detection
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
def _is_automated_sender(address: str, headers: dict) -> bool:
|
||||
"""Return True if this email is from an automated/noreply source."""
|
||||
addr = address.lower()
|
||||
if any(pattern in addr for pattern in _NOREPLY_PATTERNS):
|
||||
return True
|
||||
for header, check in _AUTOMATED_HEADERS.items():
|
||||
value = headers.get(header, "")
|
||||
if value and check(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_email_requirements() -> bool:
|
||||
"""Check if email platform dependencies are available."""
|
||||
addr = os.getenv("EMAIL_ADDRESS")
|
||||
@@ -237,7 +213,6 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
# Track message IDs we've already processed to avoid duplicates
|
||||
self._seen_uids: set = set()
|
||||
self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Map chat_id (sender email) -> last subject + message-id for threading
|
||||
@@ -245,26 +220,6 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
logger.info("[Email] Adapter initialized for %s", self._address)
|
||||
|
||||
def _trim_seen_uids(self) -> None:
|
||||
"""Keep only the most recent UIDs to prevent unbounded memory growth.
|
||||
|
||||
IMAP UIDs are monotonically increasing integers. When the set grows
|
||||
beyond the cap, we keep only the highest half — old UIDs are safe to
|
||||
drop because new messages always have higher UIDs and IMAP's UNSEEN
|
||||
flag prevents re-delivery regardless.
|
||||
"""
|
||||
if len(self._seen_uids) <= self._seen_uids_max:
|
||||
return
|
||||
try:
|
||||
# UIDs are bytes like b'1234' — sort numerically and keep top half
|
||||
sorted_uids = sorted(self._seen_uids, key=lambda u: int(u))
|
||||
keep = self._seen_uids_max // 2
|
||||
self._seen_uids = set(sorted_uids[-keep:])
|
||||
logger.debug("[Email] Trimmed seen UIDs to %d entries", len(self._seen_uids))
|
||||
except (ValueError, TypeError):
|
||||
# Fallback: just clear old entries if sort fails
|
||||
self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:])
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the IMAP server and start polling for new messages."""
|
||||
try:
|
||||
@@ -277,8 +232,6 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
if status == "OK" and data and data[0]:
|
||||
for uid in data[0].split():
|
||||
self._seen_uids.add(uid)
|
||||
# Keep only the most recent UIDs to prevent unbounded growth
|
||||
self._trim_seen_uids()
|
||||
imap.logout()
|
||||
logger.info("[Email] IMAP connection test passed. %d existing messages skipped.", len(self._seen_uids))
|
||||
except Exception as e:
|
||||
@@ -337,63 +290,52 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
results = []
|
||||
try:
|
||||
imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
|
||||
try:
|
||||
imap.login(self._address, self._password)
|
||||
imap.select("INBOX")
|
||||
imap.login(self._address, self._password)
|
||||
imap.select("INBOX")
|
||||
|
||||
status, data = imap.uid("search", None, "UNSEEN")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return results
|
||||
status, data = imap.uid("search", None, "UNSEEN")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
imap.logout()
|
||||
return results
|
||||
|
||||
for uid in data[0].split():
|
||||
if uid in self._seen_uids:
|
||||
continue
|
||||
self._seen_uids.add(uid)
|
||||
# Trim periodically to prevent unbounded memory growth
|
||||
if len(self._seen_uids) > self._seen_uids_max:
|
||||
self._trim_seen_uids()
|
||||
for uid in data[0].split():
|
||||
if uid in self._seen_uids:
|
||||
continue
|
||||
self._seen_uids.add(uid)
|
||||
|
||||
status, msg_data = imap.uid("fetch", uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
status, msg_data = imap.uid("fetch", uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
|
||||
raw_email = msg_data[0][1]
|
||||
msg = email_lib.message_from_bytes(raw_email)
|
||||
raw_email = msg_data[0][1]
|
||||
msg = email_lib.message_from_bytes(raw_email)
|
||||
|
||||
sender_raw = msg.get("From", "")
|
||||
sender_addr = _extract_email_address(sender_raw)
|
||||
sender_name = _decode_header_value(sender_raw)
|
||||
# Remove email from name if present
|
||||
if "<" in sender_name:
|
||||
sender_name = sender_name.split("<")[0].strip().strip('"')
|
||||
sender_raw = msg.get("From", "")
|
||||
sender_addr = _extract_email_address(sender_raw)
|
||||
sender_name = _decode_header_value(sender_raw)
|
||||
# Remove email from name if present
|
||||
if "<" in sender_name:
|
||||
sender_name = sender_name.split("<")[0].strip().strip('"')
|
||||
|
||||
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
message_id = msg.get("Message-ID", "")
|
||||
in_reply_to = msg.get("In-Reply-To", "")
|
||||
# Skip automated/noreply senders before any processing
|
||||
msg_headers = dict(msg.items())
|
||||
if _is_automated_sender(sender_addr, msg_headers):
|
||||
logger.debug("[Email] Skipping automated sender: %s", sender_addr)
|
||||
continue
|
||||
body = _extract_text_body(msg)
|
||||
attachments = _extract_attachments(msg, skip_attachments=self._skip_attachments)
|
||||
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
message_id = msg.get("Message-ID", "")
|
||||
in_reply_to = msg.get("In-Reply-To", "")
|
||||
body = _extract_text_body(msg)
|
||||
attachments = _extract_attachments(msg, skip_attachments=self._skip_attachments)
|
||||
|
||||
results.append({
|
||||
"uid": uid,
|
||||
"sender_addr": sender_addr,
|
||||
"sender_name": sender_name,
|
||||
"subject": subject,
|
||||
"message_id": message_id,
|
||||
"in_reply_to": in_reply_to,
|
||||
"body": body,
|
||||
"attachments": attachments,
|
||||
"date": msg.get("Date", ""),
|
||||
})
|
||||
finally:
|
||||
try:
|
||||
imap.logout()
|
||||
except Exception:
|
||||
pass
|
||||
results.append({
|
||||
"uid": uid,
|
||||
"sender_addr": sender_addr,
|
||||
"sender_name": sender_name,
|
||||
"subject": subject,
|
||||
"message_id": message_id,
|
||||
"in_reply_to": in_reply_to,
|
||||
"body": body,
|
||||
"attachments": attachments,
|
||||
"date": msg.get("Date", ""),
|
||||
})
|
||||
|
||||
imap.logout()
|
||||
except Exception as e:
|
||||
logger.error("[Email] IMAP fetch error: %s", e)
|
||||
return results
|
||||
@@ -406,11 +348,6 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
if sender_addr == self._address.lower():
|
||||
return
|
||||
|
||||
# Never reply to automated senders
|
||||
if _is_automated_sender(sender_addr, {}):
|
||||
logger.debug("[Email] Dropping automated sender at dispatch: %s", sender_addr)
|
||||
return
|
||||
|
||||
subject = msg_data["subject"]
|
||||
body = msg_data["body"].strip()
|
||||
attachments = msg_data["attachments"]
|
||||
@@ -506,15 +443,10 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
smtp.close()
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
smtp.quit()
|
||||
|
||||
logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject)
|
||||
return msg_id
|
||||
@@ -598,15 +530,10 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
msg.attach(part)
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
smtp.close()
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
smtp.quit()
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+27
-178
@@ -17,8 +17,6 @@ Environment variables:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -42,9 +40,7 @@ logger = logging.getLogger(__name__)
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
# Store directory for E2EE keys and sync state.
|
||||
# Uses get_hermes_home() so each profile gets its own Matrix store.
|
||||
from hermes_constants import get_hermes_dir as _get_hermes_dir
|
||||
_STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
|
||||
_STORE_DIR = Path.home() / ".hermes" / "matrix" / "store"
|
||||
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
@@ -165,49 +161,22 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Authenticate.
|
||||
if self._access_token:
|
||||
client.access_token = self._access_token
|
||||
|
||||
# With access-token auth, always resolve whoami so we validate the
|
||||
# token and learn the device_id. The device_id matters for E2EE:
|
||||
# without it, matrix-nio can send plain messages but may fail to
|
||||
# decrypt inbound encrypted events or encrypt outbound room sends.
|
||||
resp = await client.whoami()
|
||||
if isinstance(resp, nio.WhoamiResponse):
|
||||
resolved_user_id = getattr(resp, "user_id", "") or self._user_id
|
||||
resolved_device_id = getattr(resp, "device_id", "")
|
||||
if resolved_user_id:
|
||||
self._user_id = resolved_user_id
|
||||
|
||||
# restore_login() is the matrix-nio path that binds the access
|
||||
# token to a specific device and loads the crypto store.
|
||||
if resolved_device_id and hasattr(client, "restore_login"):
|
||||
client.restore_login(
|
||||
self._user_id or resolved_user_id,
|
||||
resolved_device_id,
|
||||
self._access_token,
|
||||
)
|
||||
# Resolve user_id if not set.
|
||||
if not self._user_id:
|
||||
resp = await client.whoami()
|
||||
if isinstance(resp, nio.WhoamiResponse):
|
||||
self._user_id = resp.user_id
|
||||
client.user_id = resp.user_id
|
||||
logger.info("Matrix: authenticated as %s", self._user_id)
|
||||
else:
|
||||
if self._user_id:
|
||||
client.user_id = self._user_id
|
||||
if resolved_device_id:
|
||||
client.device_id = resolved_device_id
|
||||
client.access_token = self._access_token
|
||||
if self._encryption:
|
||||
logger.warning(
|
||||
"Matrix: access-token login did not restore E2EE state; "
|
||||
"encrypted rooms may fail until a device_id is available"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Matrix: using access token for %s%s",
|
||||
self._user_id or "(unknown user)",
|
||||
f" (device {resolved_device_id})" if resolved_device_id else "",
|
||||
)
|
||||
logger.error(
|
||||
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER"
|
||||
)
|
||||
await client.close()
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER"
|
||||
)
|
||||
await client.close()
|
||||
return False
|
||||
client.user_id = self._user_id
|
||||
logger.info("Matrix: using access token for %s", self._user_id)
|
||||
elif self._password and self._user_id:
|
||||
resp = await client.login(
|
||||
self._password,
|
||||
@@ -225,18 +194,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
# If E2EE is enabled, load the crypto store.
|
||||
if self._encryption and getattr(client, "olm", None):
|
||||
if self._encryption and hasattr(client, "olm"):
|
||||
try:
|
||||
if client.should_upload_keys:
|
||||
await client.keys_upload()
|
||||
logger.info("Matrix: E2EE crypto initialized")
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: crypto init issue: %s", exc)
|
||||
elif self._encryption:
|
||||
logger.warning(
|
||||
"Matrix: E2EE requested but crypto store is not loaded; "
|
||||
"encrypted rooms may fail"
|
||||
)
|
||||
|
||||
# Register event callbacks.
|
||||
client.add_event_callback(self._on_room_message, nio.RoomMessageText)
|
||||
@@ -266,7 +230,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
# Build DM room cache from m.direct account data.
|
||||
await self._refresh_dm_cache()
|
||||
await self._run_e2ee_maintenance()
|
||||
else:
|
||||
logger.warning("Matrix: initial sync returned %s", type(resp).__name__)
|
||||
|
||||
@@ -338,48 +301,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
relates_to["m.in_reply_to"] = {"event_id": reply_to}
|
||||
msg_content["m.relates_to"] = relates_to
|
||||
|
||||
async def _room_send_once(*, ignore_unverified_devices: bool = False):
|
||||
return await asyncio.wait_for(
|
||||
self._client.room_send(
|
||||
chat_id,
|
||||
"m.room.message",
|
||||
msg_content,
|
||||
ignore_unverified_devices=ignore_unverified_devices,
|
||||
),
|
||||
timeout=45,
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await _room_send_once(ignore_unverified_devices=False)
|
||||
except Exception as exc:
|
||||
retryable = isinstance(exc, asyncio.TimeoutError)
|
||||
olm_unverified = getattr(nio, "OlmUnverifiedDeviceError", None)
|
||||
send_retry = getattr(nio, "SendRetryError", None)
|
||||
if isinstance(olm_unverified, type) and isinstance(exc, olm_unverified):
|
||||
retryable = True
|
||||
if isinstance(send_retry, type) and isinstance(exc, send_retry):
|
||||
retryable = True
|
||||
|
||||
if not retryable:
|
||||
logger.error("Matrix: failed to send to %s: %s", chat_id, exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
logger.warning(
|
||||
"Matrix: initial encrypted send to %s failed (%s); "
|
||||
"retrying after E2EE maintenance with ignored unverified devices",
|
||||
chat_id,
|
||||
exc,
|
||||
)
|
||||
await self._run_e2ee_maintenance()
|
||||
try:
|
||||
resp = await _room_send_once(ignore_unverified_devices=True)
|
||||
except Exception as retry_exc:
|
||||
logger.error("Matrix: failed to send to %s after retry: %s", chat_id, retry_exc)
|
||||
return SendResult(success=False, error=str(retry_exc))
|
||||
|
||||
resp = await self._client.room_send(
|
||||
chat_id,
|
||||
"m.room.message",
|
||||
msg_content,
|
||||
)
|
||||
if isinstance(resp, nio.RoomSendResponse):
|
||||
last_event_id = resp.event_id
|
||||
logger.info("Matrix: sent event %s to %s", last_event_id, chat_id)
|
||||
else:
|
||||
err = getattr(resp, "message", str(resp))
|
||||
logger.error("Matrix: failed to send to %s: %s", chat_id, err)
|
||||
@@ -514,11 +442,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Upload an audio file as a voice message (MSC3245 native voice)."""
|
||||
return await self._send_local_file(
|
||||
chat_id, audio_path, "m.audio", caption, reply_to,
|
||||
metadata=metadata, is_voice=True
|
||||
)
|
||||
"""Upload an audio file as a voice message."""
|
||||
return await self._send_local_file(chat_id, audio_path, "m.audio", caption, reply_to, metadata=metadata)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
@@ -551,16 +476,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
is_voice: bool = False,
|
||||
) -> SendResult:
|
||||
"""Upload bytes to Matrix and send as a media message."""
|
||||
import nio
|
||||
|
||||
# Upload to homeserver.
|
||||
# nio expects a DataProvider (callable) or file-like object, not raw bytes.
|
||||
# nio.upload() returns a tuple (UploadResponse|UploadError, Optional[Dict])
|
||||
resp, maybe_encryption_info = await self._client.upload(
|
||||
io.BytesIO(data),
|
||||
resp = await self._client.upload(
|
||||
data,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
)
|
||||
@@ -582,10 +504,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
},
|
||||
}
|
||||
|
||||
# Add MSC3245 voice flag for native voice messages.
|
||||
if is_voice:
|
||||
msg_content["org.matrix.msc3245.voice"] = {}
|
||||
|
||||
if reply_to:
|
||||
msg_content["m.relates_to"] = {
|
||||
"m.in_reply_to": {"event_id": reply_to}
|
||||
@@ -613,7 +531,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
is_voice: bool = False,
|
||||
) -> SendResult:
|
||||
"""Read a local file and upload it."""
|
||||
p = Path(file_path)
|
||||
@@ -626,7 +543,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
|
||||
data = p.read_bytes()
|
||||
|
||||
return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice)
|
||||
return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync loop
|
||||
@@ -648,9 +565,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
getattr(resp, "message", resp),
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
await self._run_e2ee_maintenance()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -659,38 +573,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.warning("Matrix: sync error: %s — retrying in 5s", exc)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _run_e2ee_maintenance(self) -> None:
|
||||
"""Run matrix-nio E2EE housekeeping between syncs.
|
||||
|
||||
Hermes uses a custom sync loop instead of matrix-nio's sync_forever(),
|
||||
so we need to explicitly drive the key management work that sync_forever()
|
||||
normally handles for encrypted rooms.
|
||||
"""
|
||||
client = self._client
|
||||
if not client or not self._encryption or not getattr(client, "olm", None):
|
||||
return
|
||||
|
||||
tasks = [asyncio.create_task(client.send_to_device_messages())]
|
||||
|
||||
if client.should_upload_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_upload()))
|
||||
|
||||
if client.should_query_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_query()))
|
||||
|
||||
if client.should_claim_keys:
|
||||
users = client.get_users_for_key_claiming()
|
||||
if users:
|
||||
tasks.append(asyncio.create_task(client.keys_claim(users)))
|
||||
|
||||
for task in asyncio.as_completed(tasks):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: E2EE maintenance task failed: %s", exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callbacks
|
||||
# ------------------------------------------------------------------
|
||||
@@ -821,19 +703,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
event_mimetype = (content_info.get("info") or {}).get("mimetype", "")
|
||||
media_type = "application/octet-stream"
|
||||
msg_type = MessageType.DOCUMENT
|
||||
is_voice_message = False
|
||||
|
||||
if isinstance(event, nio.RoomMessageImage):
|
||||
msg_type = MessageType.PHOTO
|
||||
media_type = event_mimetype or "image/png"
|
||||
elif isinstance(event, nio.RoomMessageAudio):
|
||||
# Check for MSC3245 voice flag: org.matrix.msc3245.voice: {}
|
||||
source_content = getattr(event, "source", {}).get("content", {})
|
||||
if source_content.get("org.matrix.msc3245.voice") is not None:
|
||||
is_voice_message = True
|
||||
msg_type = MessageType.VOICE
|
||||
else:
|
||||
msg_type = MessageType.AUDIO
|
||||
msg_type = MessageType.AUDIO
|
||||
media_type = event_mimetype or "audio/ogg"
|
||||
elif isinstance(event, nio.RoomMessageVideo):
|
||||
msg_type = MessageType.VIDEO
|
||||
@@ -871,31 +745,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if relates_to.get("rel_type") == "m.thread":
|
||||
thread_id = relates_to.get("event_id")
|
||||
|
||||
# For voice messages, cache audio locally for transcription tools.
|
||||
# Use the authenticated nio client to download (Matrix requires auth for media).
|
||||
media_urls = [http_url] if http_url else None
|
||||
media_types = [media_type] if http_url else None
|
||||
|
||||
if is_voice_message and url and url.startswith("mxc://"):
|
||||
try:
|
||||
import nio
|
||||
from gateway.platforms.base import cache_audio_from_bytes
|
||||
|
||||
resp = await self._client.download(mxc=url)
|
||||
if isinstance(resp, nio.MemoryDownloadResponse):
|
||||
# Extract extension from mimetype or default to .ogg
|
||||
ext = ".ogg"
|
||||
if media_type and "/" in media_type:
|
||||
subtype = media_type.split("/")[1]
|
||||
ext = f".{subtype}" if subtype else ".ogg"
|
||||
local_path = cache_audio_from_bytes(resp.body, ext)
|
||||
media_urls = [local_path]
|
||||
logger.debug("Matrix: cached voice message to %s", local_path)
|
||||
else:
|
||||
logger.warning("Matrix: failed to download voice: %s", getattr(resp, "message", resp))
|
||||
except Exception as e:
|
||||
logger.warning("Matrix: failed to cache voice message, using HTTP URL: %s", e)
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=room.room_id,
|
||||
chat_type=chat_type,
|
||||
|
||||
@@ -603,19 +603,9 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
# For DMs, user_id is sufficient. For channels, check for @mention.
|
||||
message_text = post.get("message", "")
|
||||
|
||||
# Mention-gating for non-DM channels.
|
||||
# Config (env vars):
|
||||
# MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
|
||||
# MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
|
||||
# Mention-only mode: skip channel messages that don't @mention the bot.
|
||||
# DMs (type "D") are always processed.
|
||||
if channel_type_raw != "D":
|
||||
require_mention = os.getenv(
|
||||
"MATTERMOST_REQUIRE_MENTION", "true"
|
||||
).lower() not in ("false", "0", "no")
|
||||
|
||||
free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "")
|
||||
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
|
||||
is_free_channel = channel_id in free_channels
|
||||
|
||||
mention_patterns = [
|
||||
f"@{self._bot_username}",
|
||||
f"@{self._bot_user_id}",
|
||||
@@ -624,21 +614,13 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
pattern.lower() in message_text.lower()
|
||||
for pattern in mention_patterns
|
||||
)
|
||||
|
||||
if require_mention and not is_free_channel and not has_mention:
|
||||
if not has_mention:
|
||||
logger.debug(
|
||||
"Mattermost: skipping non-DM message without @mention (channel=%s)",
|
||||
channel_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Strip @mention from the message text so the agent sees clean input.
|
||||
if has_mention:
|
||||
for pattern in mention_patterns:
|
||||
message_text = re.sub(
|
||||
re.escape(pattern), "", message_text, flags=re.IGNORECASE
|
||||
).strip()
|
||||
|
||||
# Resolve sender info.
|
||||
sender_id = post.get("user_id", "")
|
||||
sender_name = data.get("sender_name", "").lstrip("@") or sender_id
|
||||
|
||||
@@ -22,7 +22,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from urllib.parse import quote, unquote
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -184,8 +184,6 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
self._recent_sent_timestamps: set = set()
|
||||
self._max_recent_timestamps = 50
|
||||
|
||||
self._phone_lock_identity: Optional[str] = None
|
||||
|
||||
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
|
||||
self.http_url, _redact_phone(self.account),
|
||||
"enabled" if self.group_allow_from else "disabled")
|
||||
@@ -200,29 +198,6 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
logger.error("Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required")
|
||||
return False
|
||||
|
||||
# Acquire scoped lock to prevent duplicate Signal listeners for the same phone
|
||||
try:
|
||||
from gateway.status import acquire_scoped_lock
|
||||
|
||||
self._phone_lock_identity = self.account
|
||||
acquired, existing = acquire_scoped_lock(
|
||||
"signal-phone",
|
||||
self._phone_lock_identity,
|
||||
metadata={"platform": self.platform.value},
|
||||
)
|
||||
if not acquired:
|
||||
owner_pid = existing.get("pid") if isinstance(existing, dict) else None
|
||||
message = (
|
||||
"Another local Hermes gateway is already using this Signal account"
|
||||
+ (f" (PID {owner_pid})." if owner_pid else ".")
|
||||
+ " Stop the other gateway before starting a second Signal listener."
|
||||
)
|
||||
logger.error("Signal: %s", message)
|
||||
self._set_fatal_error("signal_phone_lock", message, retryable=False)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Signal: Could not acquire phone lock (non-fatal): %s", e)
|
||||
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
# Health check — verify signal-cli daemon is reachable
|
||||
@@ -270,14 +245,6 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
await self.client.aclose()
|
||||
self.client = None
|
||||
|
||||
if self._phone_lock_identity:
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
release_scoped_lock("signal-phone", self._phone_lock_identity)
|
||||
except Exception as e:
|
||||
logger.warning("Signal: Error releasing phone lock: %s", e, exc_info=True)
|
||||
self._phone_lock_identity = None
|
||||
|
||||
logger.info("Signal: disconnected")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -286,7 +253,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _sse_listener(self) -> None:
|
||||
"""Listen for SSE events from signal-cli daemon."""
|
||||
url = f"{self.http_url}/api/v1/events?account={quote(self.account, safe='')}"
|
||||
url = f"{self.http_url}/api/v1/events?account={self.account}"
|
||||
backoff = SSE_RETRY_DELAY_INITIAL
|
||||
|
||||
while self._running:
|
||||
@@ -554,7 +521,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
"""Fetch an attachment via JSON-RPC and cache it. Returns (path, ext)."""
|
||||
result = await self._rpc("getAttachment", {
|
||||
"account": self.account,
|
||||
"id": attachment_id,
|
||||
"attachmentId": attachment_id,
|
||||
})
|
||||
|
||||
if not result:
|
||||
|
||||
+31
-115
@@ -9,7 +9,6 @@ Uses slack-bolt (Python) with Socket Mode for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -74,10 +73,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self._bot_user_id: Optional[str] = None
|
||||
self._user_name_cache: Dict[str, str] = {} # user_id → display name
|
||||
self._socket_mode_task: Optional[asyncio.Task] = None
|
||||
# Multi-workspace support
|
||||
self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient
|
||||
self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id
|
||||
self._channel_team: Dict[str, str] = {} # channel_id → team_id
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
@@ -87,70 +82,23 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
|
||||
raw_token = self.config.token
|
||||
bot_token = self.config.token
|
||||
app_token = os.getenv("SLACK_APP_TOKEN")
|
||||
|
||||
if not raw_token:
|
||||
if not bot_token:
|
||||
logger.error("[Slack] SLACK_BOT_TOKEN not set")
|
||||
return False
|
||||
if not app_token:
|
||||
logger.error("[Slack] SLACK_APP_TOKEN not set")
|
||||
return False
|
||||
|
||||
# Support comma-separated bot tokens for multi-workspace
|
||||
bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()]
|
||||
|
||||
# Also load tokens from OAuth token file
|
||||
from hermes_constants import get_hermes_home
|
||||
tokens_file = get_hermes_home() / "slack_tokens.json"
|
||||
if tokens_file.exists():
|
||||
try:
|
||||
saved = json.loads(tokens_file.read_text(encoding="utf-8"))
|
||||
for team_id, entry in saved.items():
|
||||
tok = entry.get("token", "") if isinstance(entry, dict) else ""
|
||||
if tok and tok not in bot_tokens:
|
||||
bot_tokens.append(tok)
|
||||
team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id
|
||||
logger.info("[Slack] Loaded saved token for workspace %s", team_label)
|
||||
except Exception as e:
|
||||
logger.warning("[Slack] Failed to read %s: %s", tokens_file, e)
|
||||
|
||||
try:
|
||||
# Acquire scoped lock to prevent duplicate app token usage
|
||||
from gateway.status import acquire_scoped_lock
|
||||
self._token_lock_identity = app_token
|
||||
acquired, existing = acquire_scoped_lock('slack-app-token', app_token, metadata={'platform': 'slack'})
|
||||
if not acquired:
|
||||
owner_pid = existing.get('pid') if isinstance(existing, dict) else None
|
||||
message = f'Slack app token already in use' + (f' (PID {owner_pid})' if owner_pid else '') + '. Stop the other gateway first.'
|
||||
logger.error('[%s] %s', self.name, message)
|
||||
self._set_fatal_error('slack_token_lock', message, retryable=False)
|
||||
return False
|
||||
self._app = AsyncApp(token=bot_token)
|
||||
|
||||
# First token is the primary — used for AsyncApp / Socket Mode
|
||||
primary_token = bot_tokens[0]
|
||||
self._app = AsyncApp(token=primary_token)
|
||||
|
||||
# Register each bot token and map team_id → client
|
||||
for token in bot_tokens:
|
||||
client = AsyncWebClient(token=token)
|
||||
auth_response = await client.auth_test()
|
||||
team_id = auth_response.get("team_id", "")
|
||||
bot_user_id = auth_response.get("user_id", "")
|
||||
bot_name = auth_response.get("user", "unknown")
|
||||
team_name = auth_response.get("team", "unknown")
|
||||
|
||||
self._team_clients[team_id] = client
|
||||
self._team_bot_user_ids[team_id] = bot_user_id
|
||||
|
||||
# First token sets the primary bot_user_id (backward compat)
|
||||
if self._bot_user_id is None:
|
||||
self._bot_user_id = bot_user_id
|
||||
|
||||
logger.info(
|
||||
"[Slack] Authenticated as @%s in workspace %s (team: %s)",
|
||||
bot_name, team_name, team_id,
|
||||
)
|
||||
# Get our own bot user ID for mention detection
|
||||
auth_response = await self._app.client.auth_test()
|
||||
self._bot_user_id = auth_response.get("user_id")
|
||||
bot_name = auth_response.get("user", "unknown")
|
||||
|
||||
# Register message event handler
|
||||
@self._app.event("message")
|
||||
@@ -175,10 +123,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
|
||||
|
||||
self._running = True
|
||||
logger.info(
|
||||
"[Slack] Socket Mode connected (%d workspace(s))",
|
||||
len(self._team_clients),
|
||||
)
|
||||
logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name)
|
||||
return True
|
||||
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
@@ -193,25 +138,8 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True)
|
||||
self._running = False
|
||||
|
||||
# Release the token lock (use stored identity, not re-read env)
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
if getattr(self, '_token_lock_identity', None):
|
||||
release_scoped_lock('slack-app-token', self._token_lock_identity)
|
||||
self._token_lock_identity = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("[Slack] Disconnected")
|
||||
|
||||
def _get_client(self, chat_id: str) -> AsyncWebClient:
|
||||
"""Return the workspace-specific WebClient for a channel."""
|
||||
team_id = self._channel_team.get(chat_id)
|
||||
if team_id and team_id in self._team_clients:
|
||||
return self._team_clients[team_id]
|
||||
return self._app.client # fallback to primary
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -248,7 +176,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if broadcast and i == 0:
|
||||
kwargs["reply_broadcast"] = True
|
||||
|
||||
last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||
last_result = await self._app.client.chat_postMessage(**kwargs)
|
||||
|
||||
return SendResult(
|
||||
success=True,
|
||||
@@ -270,7 +198,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
await self._get_client(chat_id).chat_update(
|
||||
await self._app.client.chat_update(
|
||||
channel=chat_id,
|
||||
ts=message_id,
|
||||
text=content,
|
||||
@@ -304,7 +232,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return # Can only set status in a thread context
|
||||
|
||||
try:
|
||||
await self._get_client(chat_id).assistant_threads_setStatus(
|
||||
await self._app.client.assistant_threads_setStatus(
|
||||
channel_id=chat_id,
|
||||
thread_ts=thread_ts,
|
||||
status="is thinking...",
|
||||
@@ -346,7 +274,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=file_path,
|
||||
filename=os.path.basename(file_path),
|
||||
@@ -448,7 +376,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not self._app:
|
||||
return False
|
||||
try:
|
||||
await self._get_client(channel).reactions_add(
|
||||
await self._app.client.reactions_add(
|
||||
channel=channel, timestamp=timestamp, name=emoji
|
||||
)
|
||||
return True
|
||||
@@ -464,7 +392,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not self._app:
|
||||
return False
|
||||
try:
|
||||
await self._get_client(channel).reactions_remove(
|
||||
await self._app.client.reactions_remove(
|
||||
channel=channel, timestamp=timestamp, name=emoji
|
||||
)
|
||||
return True
|
||||
@@ -474,7 +402,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
# ----- User identity resolution -----
|
||||
|
||||
async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
|
||||
async def _resolve_user_name(self, user_id: str) -> str:
|
||||
"""Resolve a Slack user ID to a display name, with caching."""
|
||||
if not user_id:
|
||||
return ""
|
||||
@@ -485,8 +413,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return user_id
|
||||
|
||||
try:
|
||||
client = self._get_client(chat_id) if chat_id else self._app.client
|
||||
result = await client.users_info(user=user_id)
|
||||
result = await self._app.client.users_info(user=user_id)
|
||||
user = result.get("user", {})
|
||||
# Prefer display_name → real_name → user_id
|
||||
profile = user.get("profile", {})
|
||||
@@ -550,7 +477,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
content=response.content,
|
||||
filename="image.png",
|
||||
@@ -610,7 +537,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=f"Video file not found: {video_path}")
|
||||
|
||||
try:
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=video_path,
|
||||
filename=os.path.basename(video_path),
|
||||
@@ -651,7 +578,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
display_name = file_name or os.path.basename(file_path)
|
||||
|
||||
try:
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=file_path,
|
||||
filename=display_name,
|
||||
@@ -679,7 +606,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return {"name": chat_id, "type": "unknown"}
|
||||
|
||||
try:
|
||||
result = await self._get_client(chat_id).conversations_info(channel=chat_id)
|
||||
result = await self._app.client.conversations_info(channel=chat_id)
|
||||
channel = result.get("channel", {})
|
||||
is_dm = channel.get("is_im", False)
|
||||
return {
|
||||
@@ -712,11 +639,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
user_id = event.get("user", "")
|
||||
channel_id = event.get("channel", "")
|
||||
ts = event.get("ts", "")
|
||||
team_id = event.get("team", "")
|
||||
|
||||
# Track which workspace owns this channel
|
||||
if team_id and channel_id:
|
||||
self._channel_team[channel_id] = team_id
|
||||
|
||||
# Determine if this is a DM or channel message
|
||||
channel_type = event.get("channel_type", "")
|
||||
@@ -733,12 +655,11 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
|
||||
|
||||
# In channels, only respond if bot is mentioned
|
||||
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
|
||||
if not is_dm and bot_uid:
|
||||
if f"<@{bot_uid}>" not in text:
|
||||
if not is_dm and self._bot_user_id:
|
||||
if f"<@{self._bot_user_id}>" not in text:
|
||||
return
|
||||
# Strip the bot mention from the text
|
||||
text = text.replace(f"<@{bot_uid}>", "").strip()
|
||||
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
|
||||
|
||||
# Determine message type
|
||||
msg_type = MessageType.TEXT
|
||||
@@ -758,7 +679,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
|
||||
ext = ".jpg"
|
||||
# Slack private URLs require the bot token as auth header
|
||||
cached = await self._download_slack_file(url, ext, team_id=team_id)
|
||||
cached = await self._download_slack_file(url, ext)
|
||||
media_urls.append(cached)
|
||||
media_types.append(mimetype)
|
||||
msg_type = MessageType.PHOTO
|
||||
@@ -769,7 +690,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
ext = "." + mimetype.split("/")[-1].split(";")[0]
|
||||
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
|
||||
ext = ".ogg"
|
||||
cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id)
|
||||
cached = await self._download_slack_file(url, ext, audio=True)
|
||||
media_urls.append(cached)
|
||||
media_types.append(mimetype)
|
||||
msg_type = MessageType.VOICE
|
||||
@@ -800,7 +721,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
continue
|
||||
|
||||
# Download and cache
|
||||
raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id)
|
||||
raw_bytes = await self._download_slack_file_bytes(url)
|
||||
cached_path = cache_document_from_bytes(
|
||||
raw_bytes, original_filename or f"document{ext}"
|
||||
)
|
||||
@@ -829,7 +750,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
|
||||
|
||||
# Resolve user display name (cached after first lookup)
|
||||
user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
|
||||
user_name = await self._resolve_user_name(user_id)
|
||||
|
||||
# Build source
|
||||
source = self.build_source(
|
||||
@@ -866,11 +787,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
text = command.get("text", "").strip()
|
||||
user_id = command.get("user_id", "")
|
||||
channel_id = command.get("channel_id", "")
|
||||
team_id = command.get("team_id", "")
|
||||
|
||||
# Track which workspace owns this channel
|
||||
if team_id and channel_id:
|
||||
self._channel_team[channel_id] = team_id
|
||||
|
||||
# Map subcommands to gateway commands — derived from central registry.
|
||||
# Also keep "compact" as a Slack-specific alias for /compress.
|
||||
@@ -902,12 +818,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
|
||||
async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str:
|
||||
"""Download a Slack file using the bot token for auth, with retry."""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
|
||||
bot_token = self.config.token
|
||||
last_exc = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
@@ -937,12 +853,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
raise
|
||||
raise last_exc
|
||||
|
||||
async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes:
|
||||
async def _download_slack_file_bytes(self, url: str) -> bytes:
|
||||
"""Download a Slack file and return raw bytes, with retry."""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
|
||||
bot_token = self.config.token
|
||||
last_exc = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
|
||||
+24
-233
@@ -8,7 +8,6 @@ Uses python-telegram-bot library for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -123,8 +122,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
super().__init__(config, Platform.TELEGRAM)
|
||||
self._app: Optional[Application] = None
|
||||
self._bot: Optional[Bot] = None
|
||||
self._webhook_mode: bool = False
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
# Buffer rapid/album photo updates so Telegram image bursts are handled
|
||||
# as a single MessageEvent instead of self-interrupting multiple turns.
|
||||
@@ -348,8 +345,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
|
||||
"""Save a newly created thread_id back into config.yaml so it persists across restarts."""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
config_path = _Path.home() / ".hermes" / "config.yaml"
|
||||
if not config_path.exists():
|
||||
logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
|
||||
return
|
||||
@@ -459,19 +455,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Telegram via polling or webhook.
|
||||
|
||||
By default, uses long polling (outbound connection to Telegram).
|
||||
If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server
|
||||
instead. Webhook mode is useful for cloud deployments (Fly.io,
|
||||
Railway) where inbound HTTP can wake a suspended machine.
|
||||
|
||||
Env vars for webhook mode::
|
||||
|
||||
TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram)
|
||||
TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
|
||||
TELEGRAM_WEBHOOK_SECRET Secret token for update verification
|
||||
"""
|
||||
"""Connect to Telegram and start polling for updates."""
|
||||
if not TELEGRAM_AVAILABLE:
|
||||
logger.error(
|
||||
"[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot",
|
||||
@@ -565,57 +549,27 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
else:
|
||||
raise
|
||||
await self._app.start()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Decide between webhook and polling mode
|
||||
webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip()
|
||||
def _polling_error_callback(error: Exception) -> None:
|
||||
if self._polling_error_task and not self._polling_error_task.done():
|
||||
return
|
||||
if self._looks_like_polling_conflict(error):
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
|
||||
elif self._looks_like_network_error(error):
|
||||
logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
|
||||
else:
|
||||
logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
|
||||
|
||||
if webhook_url:
|
||||
# ── Webhook mode ─────────────────────────────────────
|
||||
# Telegram pushes updates to our HTTP endpoint. This
|
||||
# enables cloud platforms (Fly.io, Railway) to auto-wake
|
||||
# suspended machines on inbound HTTP traffic.
|
||||
webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443"))
|
||||
webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() or None
|
||||
from urllib.parse import urlparse
|
||||
webhook_path = urlparse(webhook_url).path or "/telegram"
|
||||
# Store reference for retry use in _handle_polling_conflict
|
||||
self._polling_error_callback_ref = _polling_error_callback
|
||||
|
||||
await self._app.updater.start_webhook(
|
||||
listen="0.0.0.0",
|
||||
port=webhook_port,
|
||||
url_path=webhook_path,
|
||||
webhook_url=webhook_url,
|
||||
secret_token=webhook_secret,
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
self._webhook_mode = True
|
||||
logger.info(
|
||||
"[%s] Webhook server listening on 0.0.0.0:%d%s",
|
||||
self.name, webhook_port, webhook_path,
|
||||
)
|
||||
else:
|
||||
# ── Polling mode (default) ───────────────────────────
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _polling_error_callback(error: Exception) -> None:
|
||||
if self._polling_error_task and not self._polling_error_task.done():
|
||||
return
|
||||
if self._looks_like_polling_conflict(error):
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
|
||||
elif self._looks_like_network_error(error):
|
||||
logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
|
||||
else:
|
||||
logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
|
||||
|
||||
# Store reference for retry use in _handle_polling_conflict
|
||||
self._polling_error_callback_ref = _polling_error_callback
|
||||
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
error_callback=_polling_error_callback,
|
||||
)
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
error_callback=_polling_error_callback,
|
||||
)
|
||||
|
||||
# Register bot commands so Telegram shows a hint menu when users type /
|
||||
# List is derived from the central COMMAND_REGISTRY — adding a new
|
||||
@@ -635,8 +589,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
self._mark_connected()
|
||||
mode = "webhook" if self._webhook_mode else "polling"
|
||||
logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode)
|
||||
logger.info("[%s] Connected and polling for Telegram updates", self.name)
|
||||
|
||||
# Set up DM topics (Bot API 9.4 — Private Chat Topics)
|
||||
# Runs after connection is established so the bot can call createForumTopic.
|
||||
@@ -664,7 +617,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Stop polling/webhook, cancel pending album flushes, and disconnect."""
|
||||
"""Stop polling, cancel pending album flushes, and disconnect."""
|
||||
pending_media_group_tasks = list(self._media_group_tasks.values())
|
||||
for task in pending_media_group_tasks:
|
||||
task.cancel()
|
||||
@@ -808,16 +761,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
effective_thread_id = None
|
||||
continue
|
||||
if "message to be replied not found" in err_lower and reply_to_id is not None:
|
||||
# Original message was deleted before we
|
||||
# could reply — clear reply target and retry
|
||||
# so the response is still delivered.
|
||||
logger.warning(
|
||||
"[%s] Reply target deleted, retrying without reply_to: %s",
|
||||
self.name, send_err,
|
||||
)
|
||||
reply_to_id = None
|
||||
continue
|
||||
# Other BadRequest errors are permanent — don't retry
|
||||
raise
|
||||
if _send_attempt < 2:
|
||||
@@ -1371,148 +1314,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
return text
|
||||
|
||||
# ── Group mention gating ──────────────────────────────────────────────
|
||||
|
||||
def _telegram_require_mention(self) -> bool:
|
||||
"""Return whether group chats should require an explicit bot trigger."""
|
||||
configured = self.config.extra.get("require_mention")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() in ("true", "1", "yes", "on")
|
||||
return bool(configured)
|
||||
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _telegram_free_response_chats(self) -> set[str]:
|
||||
raw = self.config.extra.get("free_response_chats")
|
||||
if raw is None:
|
||||
raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _compile_mention_patterns(self) -> List[re.Pattern]:
|
||||
"""Compile optional regex wake-word patterns for group triggers."""
|
||||
patterns = self.config.extra.get("mention_patterns")
|
||||
if patterns is None:
|
||||
raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
|
||||
if raw:
|
||||
try:
|
||||
loaded = json.loads(raw)
|
||||
except Exception:
|
||||
loaded = [part.strip() for part in raw.splitlines() if part.strip()]
|
||||
if not loaded:
|
||||
loaded = [part.strip() for part in raw.split(",") if part.strip()]
|
||||
patterns = loaded
|
||||
|
||||
if patterns is None:
|
||||
return []
|
||||
if isinstance(patterns, str):
|
||||
patterns = [patterns]
|
||||
if not isinstance(patterns, list):
|
||||
logger.warning(
|
||||
"[%s] telegram mention_patterns must be a list or string; got %s",
|
||||
self.name,
|
||||
type(patterns).__name__,
|
||||
)
|
||||
return []
|
||||
|
||||
compiled: List[re.Pattern] = []
|
||||
for pattern in patterns:
|
||||
if not isinstance(pattern, str) or not pattern.strip():
|
||||
continue
|
||||
try:
|
||||
compiled.append(re.compile(pattern, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
|
||||
if compiled:
|
||||
logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
|
||||
return compiled
|
||||
|
||||
def _is_group_chat(self, message: Message) -> bool:
|
||||
chat = getattr(message, "chat", None)
|
||||
if not chat:
|
||||
return False
|
||||
chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
|
||||
return chat_type in ("group", "supergroup")
|
||||
|
||||
def _is_reply_to_bot(self, message: Message) -> bool:
|
||||
if not self._bot or not getattr(message, "reply_to_message", None):
|
||||
return False
|
||||
reply_user = getattr(message.reply_to_message, "from_user", None)
|
||||
return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
|
||||
|
||||
def _message_mentions_bot(self, message: Message) -> bool:
|
||||
if not self._bot:
|
||||
return False
|
||||
|
||||
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
|
||||
bot_id = getattr(self._bot, "id", None)
|
||||
|
||||
def _iter_sources():
|
||||
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
||||
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
||||
|
||||
for source_text, entities in _iter_sources():
|
||||
if bot_username and f"@{bot_username}" in source_text.lower():
|
||||
return True
|
||||
for entity in entities:
|
||||
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
||||
if entity_type == "mention" and bot_username:
|
||||
offset = int(getattr(entity, "offset", -1))
|
||||
length = int(getattr(entity, "length", 0))
|
||||
if offset < 0 or length <= 0:
|
||||
continue
|
||||
if source_text[offset:offset + length].strip().lower() == f"@{bot_username}":
|
||||
return True
|
||||
elif entity_type == "text_mention":
|
||||
user = getattr(entity, "user", None)
|
||||
if user and getattr(user, "id", None) == bot_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _message_matches_mention_patterns(self, message: Message) -> bool:
|
||||
if not self._mention_patterns:
|
||||
return False
|
||||
for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
|
||||
if not candidate:
|
||||
continue
|
||||
for pattern in self._mention_patterns:
|
||||
if pattern.search(candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
|
||||
if not text or not self._bot or not getattr(self._bot, "username", None):
|
||||
return text
|
||||
username = re.escape(self._bot.username)
|
||||
cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
|
||||
return cleaned or text
|
||||
|
||||
def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
|
||||
"""Apply Telegram group trigger rules.
|
||||
|
||||
DMs remain unrestricted. Group/supergroup messages are accepted when:
|
||||
- the chat is explicitly allowlisted in ``free_response_chats``
|
||||
- ``require_mention`` is disabled
|
||||
- the message is a command
|
||||
- the message replies to the bot
|
||||
- the bot is @mentioned
|
||||
- the text/caption matches a configured regex wake-word pattern
|
||||
"""
|
||||
if not self._is_group_chat(message):
|
||||
return True
|
||||
if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
|
||||
return True
|
||||
if not self._telegram_require_mention():
|
||||
return True
|
||||
if is_command:
|
||||
return True
|
||||
if self._is_reply_to_bot(message):
|
||||
return True
|
||||
if self._message_mentions_bot(message):
|
||||
return True
|
||||
return self._message_matches_mention_patterns(message)
|
||||
|
||||
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming text messages.
|
||||
|
||||
@@ -1522,19 +1323,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.TEXT)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming command messages."""
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
if not self._should_process_message(update.message, is_command=True):
|
||||
return
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.COMMAND)
|
||||
await self.handle_message(event)
|
||||
@@ -1543,8 +1339,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""Handle incoming location/venue pin messages."""
|
||||
if not update.message:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
venue = getattr(msg, "venue", None)
|
||||
@@ -1688,8 +1482,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""Handle incoming media messages, downloading images to local cache."""
|
||||
if not update.message:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
|
||||
@@ -1713,7 +1505,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
# Add caption as text
|
||||
if msg.caption:
|
||||
event.text = self._clean_bot_trigger_text(msg.caption)
|
||||
event.text = msg.caption
|
||||
|
||||
# Handle stickers: describe via vision tool with caching
|
||||
if msg.sticker:
|
||||
@@ -1965,8 +1757,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
recognized without a gateway restart.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
config_path = _Path.home() / ".hermes" / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Iterable, Optional
|
||||
|
||||
@@ -44,14 +43,6 @@ _DOH_PROVIDERS: list[dict] = [
|
||||
_SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"]
|
||||
|
||||
|
||||
def _resolve_proxy_url() -> str | None:
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"):
|
||||
value = (os.environ.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
||||
"""Retry Telegram Bot API requests via fallback IPs while preserving TLS/SNI.
|
||||
|
||||
@@ -63,9 +54,6 @@ class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
||||
|
||||
def __init__(self, fallback_ips: Iterable[str], **transport_kwargs):
|
||||
self._fallback_ips = [ip for ip in dict.fromkeys(_normalize_fallback_ips(fallback_ips))]
|
||||
proxy_url = _resolve_proxy_url()
|
||||
if proxy_url and "proxy" not in transport_kwargs:
|
||||
transport_kwargs["proxy"] = proxy_url
|
||||
self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)
|
||||
self._fallbacks = {
|
||||
ip: httpx.AsyncHTTPTransport(**transport_kwargs) for ip in self._fallback_ips
|
||||
|
||||
@@ -27,7 +27,6 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
@@ -54,7 +53,6 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8644
|
||||
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
||||
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
|
||||
|
||||
|
||||
def check_webhook_requirements() -> bool:
|
||||
@@ -70,10 +68,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
self._host: str = config.extra.get("host", DEFAULT_HOST)
|
||||
self._port: int = int(config.extra.get("port", DEFAULT_PORT))
|
||||
self._global_secret: str = config.extra.get("secret", "")
|
||||
self._static_routes: Dict[str, dict] = config.extra.get("routes", {})
|
||||
self._dynamic_routes: Dict[str, dict] = {}
|
||||
self._dynamic_routes_mtime: float = 0.0
|
||||
self._routes: Dict[str, dict] = dict(self._static_routes)
|
||||
self._routes: Dict[str, dict] = config.extra.get("routes", {})
|
||||
self._runner = None
|
||||
|
||||
# Delivery info keyed by session chat_id — consumed by send()
|
||||
@@ -101,9 +96,6 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self) -> bool:
|
||||
# Load agent-created subscriptions before validating
|
||||
self._reload_dynamic_routes()
|
||||
|
||||
# Validate routes at startup — secret is required per route
|
||||
for name, route in self._routes.items():
|
||||
secret = route.get("secret", self._global_secret)
|
||||
@@ -118,17 +110,6 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
app.router.add_get("/health", self._handle_health)
|
||||
app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
|
||||
|
||||
# Port conflict detection — fail fast if port is already in use
|
||||
import socket as _socket
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
|
||||
_s.settimeout(1)
|
||||
_s.connect(('127.0.0.1', self._port))
|
||||
logger.error('[webhook] Port %d already in use. Set a different port in config.yaml: platforms.webhook.port', self._port)
|
||||
return False
|
||||
except (ConnectionRefusedError, OSError):
|
||||
pass # port is free
|
||||
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self._host, self._port)
|
||||
@@ -201,46 +182,8 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
"""GET /health — simple health check."""
|
||||
return web.json_response({"status": "ok", "platform": "webhook"})
|
||||
|
||||
def _reload_dynamic_routes(self) -> None:
|
||||
"""Reload agent-created subscriptions from disk if the file changed."""
|
||||
from pathlib import Path as _Path
|
||||
hermes_home = _Path(
|
||||
os.getenv("HERMES_HOME", str(_Path.home() / ".hermes"))
|
||||
).expanduser()
|
||||
subs_path = hermes_home / _DYNAMIC_ROUTES_FILENAME
|
||||
if not subs_path.exists():
|
||||
if self._dynamic_routes:
|
||||
self._dynamic_routes = {}
|
||||
self._routes = dict(self._static_routes)
|
||||
logger.debug("[webhook] Dynamic subscriptions file removed, cleared dynamic routes")
|
||||
return
|
||||
try:
|
||||
mtime = subs_path.stat().st_mtime
|
||||
if mtime <= self._dynamic_routes_mtime:
|
||||
return # No change
|
||||
data = json.loads(subs_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
# Merge: static routes take precedence over dynamic ones
|
||||
self._dynamic_routes = {
|
||||
k: v for k, v in data.items()
|
||||
if k not in self._static_routes
|
||||
}
|
||||
self._routes = {**self._dynamic_routes, **self._static_routes}
|
||||
self._dynamic_routes_mtime = mtime
|
||||
logger.info(
|
||||
"[webhook] Reloaded %d dynamic route(s): %s",
|
||||
len(self._dynamic_routes),
|
||||
", ".join(self._dynamic_routes.keys()) or "(none)",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[webhook] Failed to reload dynamic routes: %s", e)
|
||||
|
||||
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
||||
"""POST /webhooks/{route_name} — receive and process a webhook event."""
|
||||
# Hot-reload dynamic subscriptions on each request (mtime-gated, cheap)
|
||||
self._reload_dynamic_routes()
|
||||
|
||||
route_name = request.match_info.get("route_name", "")
|
||||
route_config = self._routes.get(route_name)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+104
-151
@@ -26,7 +26,6 @@ from pathlib import Path
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import get_hermes_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -135,15 +134,13 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
)
|
||||
self._session_path: Path = Path(config.extra.get(
|
||||
"session_path",
|
||||
get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")
|
||||
get_hermes_home() / "whatsapp" / "session"
|
||||
))
|
||||
self._reply_prefix: Optional[str] = config.extra.get("reply_prefix")
|
||||
self._message_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._bridge_log_fh = None
|
||||
self._bridge_log: Optional[Path] = None
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
self._http_session: Optional["aiohttp.ClientSession"] = None
|
||||
self._session_lock_identity: Optional[str] = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""
|
||||
@@ -162,29 +159,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
|
||||
logger.info("[%s] Bridge found at %s", self.name, bridge_path)
|
||||
|
||||
# Acquire scoped lock to prevent duplicate sessions
|
||||
try:
|
||||
from gateway.status import acquire_scoped_lock
|
||||
|
||||
self._session_lock_identity = str(self._session_path)
|
||||
acquired, existing = acquire_scoped_lock(
|
||||
"whatsapp-session",
|
||||
self._session_lock_identity,
|
||||
metadata={"platform": self.platform.value},
|
||||
)
|
||||
if not acquired:
|
||||
owner_pid = existing.get("pid") if isinstance(existing, dict) else None
|
||||
message = (
|
||||
"Another local Hermes gateway is already using this WhatsApp session"
|
||||
+ (f" (PID {owner_pid})." if owner_pid else ".")
|
||||
+ " Stop the other gateway before starting a second WhatsApp bridge."
|
||||
)
|
||||
logger.error("[%s] %s", self.name, message)
|
||||
self._set_fatal_error("whatsapp_session_lock", message, retryable=False)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
|
||||
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
bridge_dir = bridge_path.parent
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
@@ -225,7 +199,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
else:
|
||||
@@ -331,9 +304,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
print(f"[{self.name}] Bridge log: {self._bridge_log}")
|
||||
print(f"[{self.name}] If session expired, re-pair: hermes whatsapp")
|
||||
|
||||
# Create a persistent HTTP session for all bridge communication
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
|
||||
# Start message polling task
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
|
||||
@@ -342,12 +312,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
if self._session_lock_identity:
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
release_scoped_lock("whatsapp-session", self._session_lock_identity)
|
||||
except Exception:
|
||||
pass
|
||||
logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True)
|
||||
self._close_bridge_log()
|
||||
return False
|
||||
@@ -405,32 +369,10 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
else:
|
||||
# Bridge was not started by us, don't kill it
|
||||
print(f"[{self.name}] Disconnecting (external bridge left running)")
|
||||
|
||||
# Cancel the poll task explicitly
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._poll_task = None
|
||||
|
||||
# Close the persistent HTTP session
|
||||
if self._http_session and not self._http_session.closed:
|
||||
await self._http_session.close()
|
||||
self._http_session = None
|
||||
|
||||
if self._session_lock_identity:
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
release_scoped_lock("whatsapp-session", self._session_lock_identity)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Error releasing WhatsApp session lock: %s", self.name, e, exc_info=True)
|
||||
|
||||
|
||||
self._mark_disconnected()
|
||||
self._bridge_process = None
|
||||
self._close_bridge_log()
|
||||
self._session_lock_identity = None
|
||||
print(f"[{self.name}] Disconnected")
|
||||
|
||||
async def send(
|
||||
@@ -441,7 +383,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> SendResult:
|
||||
"""Send a message via the WhatsApp bridge."""
|
||||
if not self._running or not self._http_session:
|
||||
if not self._running:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
bridge_exit = await self._check_managed_bridge_exit()
|
||||
if bridge_exit:
|
||||
@@ -449,29 +391,36 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
payload = {
|
||||
"chatId": chat_id,
|
||||
"message": content,
|
||||
}
|
||||
if reply_to:
|
||||
payload["replyTo"] = reply_to
|
||||
|
||||
async with self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/send",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=data.get("messageId"),
|
||||
raw_response=data
|
||||
)
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
payload = {
|
||||
"chatId": chat_id,
|
||||
"message": content,
|
||||
}
|
||||
if reply_to:
|
||||
payload["replyTo"] = reply_to
|
||||
|
||||
async with session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/send",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=data.get("messageId"),
|
||||
raw_response=data
|
||||
)
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
|
||||
except ImportError:
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="aiohttp not installed. Run: pip install aiohttp"
|
||||
)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
@@ -482,27 +431,28 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
content: str,
|
||||
) -> SendResult:
|
||||
"""Edit a previously sent message via the WhatsApp bridge."""
|
||||
if not self._running or not self._http_session:
|
||||
if not self._running:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
bridge_exit = await self._check_managed_bridge_exit()
|
||||
if bridge_exit:
|
||||
return SendResult(success=False, error=bridge_exit)
|
||||
try:
|
||||
import aiohttp
|
||||
async with self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/edit",
|
||||
json={
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
"message": content,
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=15)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/edit",
|
||||
json={
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
"message": content,
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=15)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
@@ -515,7 +465,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
file_name: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Send any media file via bridge /send-media endpoint."""
|
||||
if not self._running or not self._http_session:
|
||||
if not self._running:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
bridge_exit = await self._check_managed_bridge_exit()
|
||||
if bridge_exit:
|
||||
@@ -536,21 +486,22 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
if file_name:
|
||||
payload["fileName"] = file_name
|
||||
|
||||
async with self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/send-media",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=data.get("messageId"),
|
||||
raw_response=data,
|
||||
)
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/send-media",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=data.get("messageId"),
|
||||
raw_response=data,
|
||||
)
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
@@ -575,7 +526,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a local image file natively via bridge."""
|
||||
return await self._send_media_to_bridge(chat_id, image_path, "image", caption)
|
||||
@@ -586,7 +536,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
video_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a video natively via bridge — plays inline in WhatsApp."""
|
||||
return await self._send_media_to_bridge(chat_id, video_path, "video", caption)
|
||||
@@ -598,7 +547,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a document/file as a downloadable attachment via bridge."""
|
||||
return await self._send_media_to_bridge(
|
||||
@@ -608,43 +556,45 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""Send typing indicator via bridge."""
|
||||
if not self._running or not self._http_session:
|
||||
if not self._running:
|
||||
return
|
||||
if await self._check_managed_bridge_exit():
|
||||
return
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
await self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/typing",
|
||||
json={"chatId": chat_id},
|
||||
timeout=aiohttp.ClientTimeout(total=5)
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/typing",
|
||||
json={"chatId": chat_id},
|
||||
timeout=aiohttp.ClientTimeout(total=5)
|
||||
)
|
||||
except Exception:
|
||||
pass # Ignore typing indicator failures
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Get information about a WhatsApp chat."""
|
||||
if not self._running or not self._http_session:
|
||||
if not self._running:
|
||||
return {"name": "Unknown", "type": "dm"}
|
||||
if await self._check_managed_bridge_exit():
|
||||
return {"name": chat_id, "type": "dm"}
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
async with self._http_session.get(
|
||||
f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}",
|
||||
timeout=aiohttp.ClientTimeout(total=10)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return {
|
||||
"name": data.get("name", chat_id),
|
||||
"type": "group" if data.get("isGroup") else "dm",
|
||||
"participants": data.get("participants", []),
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}",
|
||||
timeout=aiohttp.ClientTimeout(total=10)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return {
|
||||
"name": data.get("name", chat_id),
|
||||
"type": "group" if data.get("isGroup") else "dm",
|
||||
"participants": data.get("participants", []),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("Could not get WhatsApp chat info for %s: %s", chat_id, e)
|
||||
|
||||
@@ -652,26 +602,29 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _poll_messages(self) -> None:
|
||||
"""Poll the bridge for incoming messages."""
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
print(f"[{self.name}] aiohttp not installed, message polling disabled")
|
||||
return
|
||||
|
||||
while self._running:
|
||||
if not self._http_session:
|
||||
break
|
||||
bridge_exit = await self._check_managed_bridge_exit()
|
||||
if bridge_exit:
|
||||
print(f"[{self.name}] {bridge_exit}")
|
||||
break
|
||||
try:
|
||||
async with self._http_session.get(
|
||||
f"http://127.0.0.1:{self._bridge_port}/messages",
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
messages = await resp.json()
|
||||
for msg_data in messages:
|
||||
event = await self._build_message_event(msg_data)
|
||||
if event:
|
||||
await self.handle_message(event)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{self._bridge_port}/messages",
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
messages = await resp.json()
|
||||
for msg_data in messages:
|
||||
event = await self._build_message_event(msg_data)
|
||||
if event:
|
||||
await self.handle_message(event)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
|
||||
+42
-169
@@ -77,7 +77,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import atomic_yaml_write
|
||||
_hermes_home = get_hermes_home()
|
||||
|
||||
# Load environment variables from ~/.hermes/.env first.
|
||||
@@ -225,49 +224,6 @@ from gateway.session import (
|
||||
from gateway.delivery import DeliveryRouter
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
|
||||
|
||||
|
||||
def _normalize_whatsapp_identifier(value: str) -> str:
|
||||
"""Strip WhatsApp JID/LID syntax down to its stable numeric identifier."""
|
||||
return (
|
||||
str(value or "")
|
||||
.strip()
|
||||
.replace("+", "", 1)
|
||||
.split(":", 1)[0]
|
||||
.split("@", 1)[0]
|
||||
)
|
||||
|
||||
|
||||
def _expand_whatsapp_auth_aliases(identifier: str) -> set:
|
||||
"""Resolve WhatsApp phone/LID aliases using bridge session mapping files."""
|
||||
normalized = _normalize_whatsapp_identifier(identifier)
|
||||
if not normalized:
|
||||
return set()
|
||||
|
||||
session_dir = _hermes_home / "whatsapp" / "session"
|
||||
resolved = set()
|
||||
queue = [normalized]
|
||||
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
if not current or current in resolved:
|
||||
continue
|
||||
|
||||
resolved.add(current)
|
||||
for suffix in ("", "_reverse"):
|
||||
mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json"
|
||||
if not mapping_path.exists():
|
||||
continue
|
||||
try:
|
||||
mapped = _normalize_whatsapp_identifier(
|
||||
json.loads(mapping_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if mapped and mapped not in resolved:
|
||||
queue.append(mapped)
|
||||
|
||||
return resolved
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel placed into _running_agents immediately when a session starts
|
||||
@@ -323,16 +279,16 @@ def _resolve_gateway_model(config: dict | None = None) -> str:
|
||||
"""Read model from env/config — mirrors the resolution in _run_agent_sync.
|
||||
|
||||
Without this, temporary AIAgent instances (memory flush, /compress) fall
|
||||
back to the hardcoded default which fails when the active provider is
|
||||
openai-codex.
|
||||
back to the hardcoded default ("anthropic/claude-opus-4.6") which fails
|
||||
when the active provider is openai-codex.
|
||||
"""
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or ""
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
|
||||
cfg = config if config is not None else _load_gateway_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
model = model_cfg
|
||||
elif isinstance(model_cfg, dict):
|
||||
model = model_cfg.get("default") or model_cfg.get("model") or model
|
||||
model = model_cfg.get("default", model)
|
||||
return model
|
||||
|
||||
|
||||
@@ -476,7 +432,7 @@ class GatewayRunner:
|
||||
from honcho_integration.session import HonchoSessionManager
|
||||
|
||||
hcfg = HonchoClientConfig.from_global_config()
|
||||
if not hcfg.enabled or not (hcfg.api_key or hcfg.base_url):
|
||||
if not hcfg.enabled or not hcfg.api_key:
|
||||
return None, hcfg
|
||||
|
||||
client = get_honcho_client(hcfg)
|
||||
@@ -789,22 +745,10 @@ class GatewayRunner:
|
||||
logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.")
|
||||
await self.stop()
|
||||
elif not self.adapters and self._failed_platforms:
|
||||
# All platforms are down and queued for background reconnection.
|
||||
# If the error is retryable, exit with failure so systemd Restart=on-failure
|
||||
# can restart the process. Otherwise stay alive and keep retrying in background.
|
||||
if adapter.fatal_error_retryable:
|
||||
self._exit_reason = adapter.fatal_error_message or "All messaging platforms failed with retryable errors"
|
||||
self._exit_with_failure = True
|
||||
logger.error(
|
||||
"All messaging platforms failed with retryable errors. "
|
||||
"Shutting down gateway for service restart (systemd will retry)."
|
||||
)
|
||||
await self.stop()
|
||||
else:
|
||||
logger.warning(
|
||||
"No connected messaging platforms remain, but %d platform(s) queued for reconnection",
|
||||
len(self._failed_platforms),
|
||||
)
|
||||
logger.warning(
|
||||
"No connected messaging platforms remain, but %d platform(s) queued for reconnection",
|
||||
len(self._failed_platforms),
|
||||
)
|
||||
|
||||
def _request_clean_exit(self, reason: str) -> None:
|
||||
self._exit_cleanly = True
|
||||
@@ -962,12 +906,11 @@ class GatewayRunner:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _load_fallback_model() -> list | dict | None:
|
||||
"""Load fallback provider chain from config.yaml.
|
||||
def _load_fallback_model() -> dict | None:
|
||||
"""Load fallback model config from config.yaml.
|
||||
|
||||
Returns a list of provider dicts (``fallback_providers``), a single
|
||||
dict (legacy ``fallback_model``), or None if not configured.
|
||||
AIAgent.__init__ normalizes both formats into a chain.
|
||||
Returns a dict with 'provider' and 'model' keys, or None if
|
||||
not configured / both fields empty.
|
||||
"""
|
||||
try:
|
||||
import yaml as _y
|
||||
@@ -975,8 +918,8 @@ class GatewayRunner:
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None
|
||||
if fb:
|
||||
fb = cfg.get("fallback_model", {}) or {}
|
||||
if fb.get("provider") and fb.get("model"):
|
||||
return fb
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1004,13 +947,6 @@ class GatewayRunner:
|
||||
"""
|
||||
logger.info("Starting Hermes Gateway...")
|
||||
logger.info("Session storage: %s", self.config.sessions_dir)
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
_profile = get_active_profile_name()
|
||||
if _profile and _profile != "default":
|
||||
logger.info("Active profile: %s", _profile)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
write_runtime_status(gateway_state="starting", exit_reason=None)
|
||||
@@ -1026,8 +962,6 @@ class GatewayRunner:
|
||||
"EMAIL_ALLOWED_USERS",
|
||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
||||
"FEISHU_ALLOWED_USERS",
|
||||
"WECOM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
@@ -1036,9 +970,7 @@ class GatewayRunner:
|
||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
||||
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
|
||||
"FEISHU_ALLOW_ALL_USERS",
|
||||
"WECOM_ALLOW_ALL_USERS")
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
|
||||
)
|
||||
if not _any_allowlist and not _allow_all:
|
||||
logger.warning(
|
||||
@@ -1481,20 +1413,6 @@ class GatewayRunner:
|
||||
return None
|
||||
return DingTalkAdapter(config)
|
||||
|
||||
elif platform == Platform.FEISHU:
|
||||
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
|
||||
if not check_feishu_requirements():
|
||||
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
|
||||
return None
|
||||
return FeishuAdapter(config)
|
||||
|
||||
elif platform == Platform.WECOM:
|
||||
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
|
||||
if not check_wecom_requirements():
|
||||
logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set")
|
||||
return None
|
||||
return WeComAdapter(config)
|
||||
|
||||
elif platform == Platform.MATTERMOST:
|
||||
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
|
||||
if not check_mattermost_requirements():
|
||||
@@ -1561,8 +1479,6 @@ class GatewayRunner:
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
}
|
||||
platform_allow_all_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
@@ -1575,8 +1491,6 @@ class GatewayRunner:
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
|
||||
}
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
@@ -1604,23 +1518,10 @@ class GatewayRunner:
|
||||
if global_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
|
||||
|
||||
# WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison
|
||||
check_ids = {user_id}
|
||||
if "@" in user_id:
|
||||
check_ids.add(user_id.split("@")[0])
|
||||
|
||||
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
|
||||
if source.platform == Platform.WHATSAPP:
|
||||
normalized_allowed_ids = set()
|
||||
for allowed_id in allowed_ids:
|
||||
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
|
||||
if normalized_allowed_ids:
|
||||
allowed_ids = normalized_allowed_ids
|
||||
|
||||
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
|
||||
normalized_user_id = _normalize_whatsapp_identifier(user_id)
|
||||
if normalized_user_id:
|
||||
check_ids.add(normalized_user_id)
|
||||
|
||||
return bool(check_ids & allowed_ids)
|
||||
|
||||
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
|
||||
@@ -2180,7 +2081,7 @@ class GatewayRunner:
|
||||
if isinstance(_model_cfg, str):
|
||||
_hyg_model = _model_cfg
|
||||
elif isinstance(_model_cfg, dict):
|
||||
_hyg_model = _model_cfg.get("default") or _model_cfg.get("model") or _hyg_model
|
||||
_hyg_model = _model_cfg.get("default", _hyg_model)
|
||||
# Read explicit context_length override from model config
|
||||
# (same as run_agent.py lines 995-1005)
|
||||
_raw_ctx = _model_cfg.get("context_length")
|
||||
@@ -2303,15 +2204,6 @@ class GatewayRunner:
|
||||
),
|
||||
)
|
||||
|
||||
# _compress_context ends the old session and creates
|
||||
# a new session_id. Write compressed messages into
|
||||
# the NEW session so the old transcript stays intact
|
||||
# and searchable via session_search.
|
||||
_hyg_new_sid = _hyg_agent.session_id
|
||||
if _hyg_new_sid != session_entry.session_id:
|
||||
session_entry.session_id = _hyg_new_sid
|
||||
self.session_store._save()
|
||||
|
||||
self.session_store.rewrite_transcript(
|
||||
session_entry.session_id, _compressed
|
||||
)
|
||||
@@ -3175,7 +3067,8 @@ class GatewayRunner:
|
||||
if "agent" not in config or not isinstance(config.get("agent"), dict):
|
||||
config["agent"] = {}
|
||||
config["agent"]["system_prompt"] = ""
|
||||
atomic_yaml_write(config_path, config)
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
except Exception as e:
|
||||
return f"⚠️ Failed to save personality change: {e}"
|
||||
self._ephemeral_system_prompt = ""
|
||||
@@ -3188,7 +3081,8 @@ class GatewayRunner:
|
||||
if "agent" not in config or not isinstance(config.get("agent"), dict):
|
||||
config["agent"] = {}
|
||||
config["agent"]["system_prompt"] = new_prompt
|
||||
atomic_yaml_write(config_path, config)
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
except Exception as e:
|
||||
return f"⚠️ Failed to save personality change: {e}"
|
||||
|
||||
@@ -3278,7 +3172,8 @@ class GatewayRunner:
|
||||
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)
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(user_config, f, default_flow_style=False)
|
||||
# Also set in the current environment so it takes effect immediately
|
||||
os.environ[env_key] = str(chat_id)
|
||||
except Exception as e:
|
||||
@@ -3891,7 +3786,7 @@ class GatewayRunner:
|
||||
# Send media files
|
||||
for media_path in (media_files or []):
|
||||
try:
|
||||
await adapter.send_document(
|
||||
await adapter.send_file(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
)
|
||||
@@ -3946,7 +3841,8 @@ class GatewayRunner:
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
current[keys[-1]] = value
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to save config key %s: %s", key_path, e)
|
||||
@@ -4054,7 +3950,8 @@ class GatewayRunner:
|
||||
if "display" not in user_config or not isinstance(user_config.get("display"), dict):
|
||||
user_config["display"] = {}
|
||||
user_config["display"]["tool_progress"] = new_mode
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||
return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_"
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save tool_progress mode: %s", e)
|
||||
@@ -4101,22 +3998,13 @@ class GatewayRunner:
|
||||
loop = asyncio.get_event_loop()
|
||||
compressed, _ = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens)
|
||||
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens),
|
||||
)
|
||||
|
||||
# _compress_context already calls end_session() on the old session
|
||||
# (preserving its full transcript in SQLite) and creates a new
|
||||
# session_id for the continuation. Write the compressed messages
|
||||
# into the NEW session so the original history stays searchable.
|
||||
new_session_id = tmp_agent.session_id
|
||||
if new_session_id != session_entry.session_id:
|
||||
session_entry.session_id = new_session_id
|
||||
self.session_store._save()
|
||||
|
||||
self.session_store.rewrite_transcript(new_session_id, compressed)
|
||||
self.session_store.rewrite_transcript(session_entry.session_id, compressed)
|
||||
# Reset stored token count — transcript changed, old value is stale
|
||||
self.session_store.update_session(
|
||||
session_entry.session_key, last_prompt_tokens=0
|
||||
session_entry.session_key, last_prompt_tokens=0,
|
||||
)
|
||||
new_count = len(compressed)
|
||||
new_tokens = estimate_messages_tokens_rough(compressed)
|
||||
@@ -4272,7 +4160,7 @@ class GatewayRunner:
|
||||
]
|
||||
ctx = agent.context_compressor
|
||||
if ctx.last_prompt_tokens:
|
||||
pct = min(100, ctx.last_prompt_tokens / ctx.context_length * 100) if ctx.context_length else 0
|
||||
pct = ctx.last_prompt_tokens / ctx.context_length * 100 if ctx.context_length else 0
|
||||
lines.append(f"Context: {ctx.last_prompt_tokens:,} / {ctx.context_length:,} ({pct:.0f}%)")
|
||||
if ctx.compression_count:
|
||||
lines.append(f"Compressions: {ctx.compression_count}")
|
||||
@@ -5019,14 +4907,6 @@ class GatewayRunner:
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
|
||||
|
||||
# Apply tool preview length config (0 = no limit)
|
||||
try:
|
||||
from agent.display import set_tool_preview_max_len
|
||||
_tpl = user_config.get("display", {}).get("tool_preview_length", 0)
|
||||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
|
||||
# Falls back to env vars for backward compatibility.
|
||||
# YAML 1.1 parses bare `off` as boolean False — normalise before
|
||||
@@ -5072,11 +4952,9 @@ class GatewayRunner:
|
||||
return
|
||||
|
||||
if preview:
|
||||
# Truncate preview unless config says unlimited
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
if _pl > 0 and len(preview) > _pl:
|
||||
preview = preview[:_pl - 3] + "..."
|
||||
# Truncate preview to keep messages clean
|
||||
if len(preview) > 80:
|
||||
preview = preview[:77] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
@@ -5096,17 +4974,12 @@ class GatewayRunner:
|
||||
progress_queue.put(msg)
|
||||
|
||||
# Background task to send progress messages
|
||||
# Accumulates tool lines into a single message that gets edited.
|
||||
#
|
||||
# Threading metadata is platform-specific:
|
||||
# - Slack DM threading needs event_message_id fallback (reply thread)
|
||||
# - Telegram uses message_thread_id only for forum topics; passing a
|
||||
# normal DM/group message id as thread_id causes send failures
|
||||
# - Other platforms should use explicit source.thread_id only
|
||||
if source.platform == Platform.SLACK:
|
||||
_progress_thread_id = source.thread_id or event_message_id
|
||||
else:
|
||||
_progress_thread_id = source.thread_id
|
||||
# Accumulates tool lines into a single message that gets edited
|
||||
# For DM top-level Slack messages, source.thread_id is None but the
|
||||
# final reply will be threaded under the original message via reply_to.
|
||||
# Use event_message_id as fallback so progress messages land in the
|
||||
# same thread as the final response instead of going to the DM root.
|
||||
_progress_thread_id = source.thread_id or event_message_id
|
||||
_progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
|
||||
async def send_progress_messages():
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hermes Agent CLI launcher.
|
||||
Hermes Agent CLI Launcher
|
||||
|
||||
This wrapper should behave like the installed `hermes` command, including
|
||||
subcommands such as `gateway`, `cron`, and `doctor`.
|
||||
This is a convenience wrapper to launch the Hermes CLI.
|
||||
Usage: ./hermes [options]
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from hermes_cli.main import main
|
||||
main()
|
||||
from cli import main
|
||||
import fire
|
||||
fire.Fire(main)
|
||||
|
||||
@@ -11,5 +11,5 @@ Provides subcommands for:
|
||||
- hermes cron - Manage cron jobs
|
||||
"""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__release_date__ = "2026.3.28"
|
||||
__version__ = "0.4.0"
|
||||
__release_date__ = "2026.3.23"
|
||||
|
||||
+10
-28
@@ -160,7 +160,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
id="alibaba",
|
||||
name="Alibaba Cloud (DashScope)",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
inference_base_url="https://dashscope-intl.aliyuncs.com/apps/anthropic",
|
||||
api_key_env_vars=("DASHSCOPE_API_KEY",),
|
||||
base_url_env_var="DASHSCOPE_BASE_URL",
|
||||
),
|
||||
@@ -212,14 +212,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
api_key_env_vars=("KILOCODE_API_KEY",),
|
||||
base_url_env_var="KILOCODE_BASE_URL",
|
||||
),
|
||||
"huggingface": ProviderConfig(
|
||||
id="huggingface",
|
||||
name="Hugging Face",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://router.huggingface.co/v1",
|
||||
api_key_env_vars=("HF_TOKEN",),
|
||||
base_url_env_var="HF_BASE_URL",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -693,13 +685,8 @@ def resolve_provider(
|
||||
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
|
||||
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
|
||||
"opencode": "opencode-zen", "zen": "opencode-zen",
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||||
# Local server aliases — route through the generic custom provider
|
||||
"lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom",
|
||||
"ollama": "custom", "vllm": "custom", "llamacpp": "custom",
|
||||
"llama.cpp": "custom", "llama-cpp": "custom",
|
||||
}
|
||||
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
|
||||
|
||||
@@ -746,12 +733,7 @@ def resolve_provider(
|
||||
if has_usable_secret(os.getenv(env_var, "")):
|
||||
return pid
|
||||
|
||||
raise AuthError(
|
||||
"No inference provider configured. Run 'hermes model' to choose a "
|
||||
"provider and model, or set an API key (OPENROUTER_API_KEY, "
|
||||
"OPENAI_API_KEY, etc.) in ~/.hermes/.env.",
|
||||
code="no_provider_configured",
|
||||
)
|
||||
return "openrouter"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -2030,8 +2012,7 @@ def _login_openai_codex(args, pconfig: ProviderConfig) -> None:
|
||||
config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
|
||||
print()
|
||||
print("Login successful!")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print(f" Auth state: {_dhh()}/auth.json")
|
||||
print(" Auth state: ~/.hermes/auth.json")
|
||||
print(f" Config updated: {config_path} (model.provider=openai-codex)")
|
||||
|
||||
|
||||
@@ -2310,20 +2291,21 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
raise AuthError("No runtime API key available to fetch models",
|
||||
provider="nous", code="invalid_token")
|
||||
|
||||
# Use curated model list (same as OpenRouter defaults) instead
|
||||
# of the full /models dump which returns hundreds of models.
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
||||
model_ids = fetch_nous_models(
|
||||
inference_base_url=runtime_base_url,
|
||||
api_key=runtime_key,
|
||||
timeout_seconds=timeout_seconds,
|
||||
verify=verify,
|
||||
)
|
||||
|
||||
print()
|
||||
if model_ids:
|
||||
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
|
||||
selected_model = _prompt_model_selection(model_ids)
|
||||
if selected_model:
|
||||
_save_model_choice(selected_model)
|
||||
print(f"Default model set to: {selected_model}")
|
||||
else:
|
||||
print("No curated models available for Nous Portal.")
|
||||
print("No models were returned by the inference API.")
|
||||
except Exception as exc:
|
||||
message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
|
||||
print()
|
||||
|
||||
+2
-25
@@ -258,7 +258,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
get_toolset_for_tool: Callable to map tool name -> toolset name.
|
||||
context_length: Model's context window size in tokens.
|
||||
"""
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from model_tools import check_tool_availability
|
||||
if get_toolset_for_tool is None:
|
||||
from model_tools import get_toolset_for_tool
|
||||
|
||||
@@ -267,18 +267,8 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
|
||||
_, unavailable_toolsets = check_tool_availability(quiet=True)
|
||||
disabled_tools = set()
|
||||
# Tools whose toolset has a check_fn are lazy-initialized (e.g. honcho,
|
||||
# homeassistant) — they show as unavailable at banner time because the
|
||||
# check hasn't run yet, but they aren't misconfigured.
|
||||
lazy_tools = set()
|
||||
for item in unavailable_toolsets:
|
||||
toolset_name = item.get("name", "")
|
||||
ts_req = TOOLSET_REQUIREMENTS.get(toolset_name, {})
|
||||
tools_in_ts = item.get("tools", [])
|
||||
if ts_req.get("check_fn"):
|
||||
lazy_tools.update(tools_in_ts)
|
||||
else:
|
||||
disabled_tools.update(tools_in_ts)
|
||||
disabled_tools.update(item.get("tools", []))
|
||||
|
||||
layout_table = Table.grid(padding=(0, 2))
|
||||
layout_table.add_column("left", justify="center")
|
||||
@@ -338,8 +328,6 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
for name in sorted(tool_names):
|
||||
if name in disabled_tools:
|
||||
colored_names.append(f"[red]{name}[/]")
|
||||
elif name in lazy_tools:
|
||||
colored_names.append(f"[yellow]{name}[/]")
|
||||
else:
|
||||
colored_names.append(f"[{text}]{name}[/]")
|
||||
|
||||
@@ -359,8 +347,6 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
colored_names.append("[dim]...[/]")
|
||||
elif name in disabled_tools:
|
||||
colored_names.append(f"[red]{name}[/]")
|
||||
elif name in lazy_tools:
|
||||
colored_names.append(f"[yellow]{name}[/]")
|
||||
else:
|
||||
colored_names.append(f"[{text}]{name}[/]")
|
||||
tools_str = ", ".join(colored_names)
|
||||
@@ -417,15 +403,6 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
if mcp_connected:
|
||||
summary_parts.append(f"{mcp_connected} MCP servers")
|
||||
summary_parts.append("/help for commands")
|
||||
# Show active profile name when not 'default'
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
_profile_name = get_active_profile_name()
|
||||
if _profile_name and _profile_name != "default":
|
||||
right_lines.append(f"[bold {accent}]Profile:[/] [{text}]{_profile_name}[/]")
|
||||
except Exception:
|
||||
pass # Never break the banner over a profiles.py bug
|
||||
|
||||
right_lines.append(f"[dim {dim}]{' · '.join(summary_parts)}[/]")
|
||||
|
||||
# Update check — use prefetched result if available
|
||||
|
||||
@@ -12,7 +12,6 @@ import getpass
|
||||
|
||||
from hermes_cli.banner import cprint, _DIM, _RST
|
||||
from hermes_cli.config import save_env_value_secure
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
def clarify_callback(cli, question, choices):
|
||||
@@ -132,8 +131,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
}
|
||||
|
||||
stored = save_env_value_secure(var_name, value)
|
||||
_dhh = display_hermes_home()
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in ~/.hermes/.env as {var_name}{_RST}")
|
||||
return {
|
||||
**stored,
|
||||
"skipped": False,
|
||||
@@ -185,8 +183,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
}
|
||||
|
||||
stored = save_env_value_secure(var_name, value)
|
||||
_dhh = display_hermes_home()
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in ~/.hermes/.env as {var_name}{_RST}")
|
||||
return {
|
||||
**stored,
|
||||
"skipped": False,
|
||||
@@ -241,8 +238,7 @@ def approval_callback(cli, command: str, description: str) -> str:
|
||||
lock = cli._approval_lock
|
||||
|
||||
with lock:
|
||||
from cli import CLI_CONFIG
|
||||
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60)
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
if len(command) > 70:
|
||||
|
||||
+1
-13
@@ -88,19 +88,7 @@ def claw_command(args):
|
||||
|
||||
def _cmd_migrate(args):
|
||||
"""Run the OpenClaw → Hermes migration."""
|
||||
# Check current and legacy OpenClaw directories
|
||||
explicit_source = getattr(args, "source", None)
|
||||
if explicit_source:
|
||||
source_dir = Path(explicit_source)
|
||||
else:
|
||||
source_dir = Path.home() / ".openclaw"
|
||||
if not source_dir.is_dir():
|
||||
# Try legacy directory names
|
||||
for legacy in (".clawdbot", ".moldbot"):
|
||||
candidate = Path.home() / legacy
|
||||
if candidate.is_dir():
|
||||
source_dir = candidate
|
||||
break
|
||||
source_dir = Path(getattr(args, "source", None) or Path.home() / ".openclaw")
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
preset = getattr(args, "preset", "full")
|
||||
overwrite = getattr(args, "overwrite", False)
|
||||
|
||||
@@ -12,8 +12,6 @@ import os
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CODEX_MODELS: List[str] = [
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
@@ -21,9 +19,8 @@ DEFAULT_CODEX_MODELS: List[str] = [
|
||||
]
|
||||
|
||||
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
||||
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
]
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills"),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
|
||||
+4
-119
@@ -34,8 +34,6 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||||
"WECOM_BOT_ID", "WECOM_SECRET",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
@@ -137,16 +135,9 @@ def ensure_hermes_home():
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"model": "anthropic/claude-opus-4.6",
|
||||
"fallback_providers": [],
|
||||
"toolsets": ["hermes-cli"],
|
||||
"agent": {
|
||||
"max_turns": 90,
|
||||
# Tool-use enforcement: injects system prompt guidance that tells the
|
||||
# model to actually call tools instead of describing intended actions.
|
||||
# Values: "auto" (default — applies to gpt/codex models), true/false
|
||||
# (force on/off for all models), or a list of model-name substrings
|
||||
# to match (e.g. ["gpt", "codex", "gemini", "qwen"]).
|
||||
"tool_use_enforcement": "auto",
|
||||
},
|
||||
|
||||
"terminal": {
|
||||
@@ -223,57 +214,49 @@ DEFAULT_CONFIG = {
|
||||
"model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o"
|
||||
"base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider)
|
||||
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
|
||||
"timeout": 30, # seconds — LLM API call timeout; increase for slow local vision models
|
||||
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
|
||||
"timeout": 30, # seconds — increase for slow local vision models
|
||||
},
|
||||
"web_extract": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30, # seconds — increase for slow local models
|
||||
},
|
||||
"compression": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
|
||||
},
|
||||
"session_search": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
},
|
||||
"skills_hub": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
},
|
||||
"approval": {
|
||||
"provider": "auto",
|
||||
"model": "", # fast/cheap model recommended (e.g. gemini-flash, haiku)
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
},
|
||||
"mcp": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
},
|
||||
"flush_memories": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -288,7 +271,6 @@ DEFAULT_CONFIG = {
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
},
|
||||
|
||||
# Privacy settings
|
||||
@@ -371,13 +353,6 @@ DEFAULT_CONFIG = {
|
||||
# Never saved to sessions, logs, or trajectories.
|
||||
"prefill_messages_file": "",
|
||||
|
||||
# Skills — external skill directories for sharing skills across tools/agents.
|
||||
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
|
||||
# always goes to ~/.hermes/skills/.
|
||||
"skills": {
|
||||
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"]
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
# This section is only needed for hermes-specific overrides; everything else
|
||||
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
|
||||
@@ -408,7 +383,6 @@ DEFAULT_CONFIG = {
|
||||
# off — skip all approval prompts (equivalent to --yolo)
|
||||
"approvals": {
|
||||
"mode": "manual",
|
||||
"timeout": 60,
|
||||
},
|
||||
|
||||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||||
@@ -434,12 +408,6 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
},
|
||||
|
||||
"cron": {
|
||||
# Wrap delivered cron responses with a header (task name) and footer
|
||||
# ("The agent cannot see this message"). Set to false for clean output.
|
||||
"wrap_response": True,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 10,
|
||||
}
|
||||
@@ -579,14 +547,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
},
|
||||
"DASHSCOPE_API_KEY": {
|
||||
"description": "Alibaba Cloud DashScope API key (Qwen + multi-provider models)",
|
||||
"description": "Alibaba Cloud DashScope API key for Qwen models",
|
||||
"prompt": "DashScope API Key",
|
||||
"url": "https://modelstudio.console.alibabacloud.com/",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
},
|
||||
"DASHSCOPE_BASE_URL": {
|
||||
"description": "Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)",
|
||||
"description": "Custom DashScope base URL (default: international endpoint)",
|
||||
"prompt": "DashScope Base URL",
|
||||
"url": "",
|
||||
"password": False,
|
||||
@@ -625,31 +593,8 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HF_TOKEN": {
|
||||
"description": "Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)",
|
||||
"prompt": "Hugging Face Token",
|
||||
"url": "https://huggingface.co/settings/tokens",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
},
|
||||
"HF_BASE_URL": {
|
||||
"description": "Hugging Face Inference Providers base URL override",
|
||||
"prompt": "HF base URL (leave empty for default)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
|
||||
# ── Tool API keys ──
|
||||
"EXA_API_KEY": {
|
||||
"description": "Exa API key for AI-native web search and contents",
|
||||
"prompt": "Exa API key",
|
||||
"url": "https://exa.ai/",
|
||||
"tools": ["web_search", "web_extract"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"PARALLEL_API_KEY": {
|
||||
"description": "Parallel API key for AI-native web search and extract",
|
||||
"prompt": "Parallel API key",
|
||||
@@ -836,20 +781,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"MATTERMOST_REQUIRE_MENTION": {
|
||||
"description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.",
|
||||
"prompt": "Require @mention in channels",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"MATTERMOST_FREE_RESPONSE_CHANNELS": {
|
||||
"description": "Comma-separated Mattermost channel IDs where bot responds without @mention",
|
||||
"prompt": "Free-response channel IDs (comma-separated)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"MATRIX_HOMESERVER": {
|
||||
"description": "Matrix homeserver URL (e.g. https://matrix.example.org)",
|
||||
"prompt": "Matrix homeserver URL",
|
||||
@@ -1672,51 +1603,6 @@ def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def delete_env_value(key: str) -> bool:
|
||||
"""Remove a key from ~/.hermes/.env. Returns True if the key was found and removed."""
|
||||
env_path = get_env_path()
|
||||
if not env_path.exists():
|
||||
return False
|
||||
|
||||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||||
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
|
||||
|
||||
with open(env_path, **read_kw) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = [l for l in lines if not l.strip().startswith(f"{key}=")]
|
||||
if len(new_lines) == len(lines):
|
||||
return False
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', **write_kw) as f:
|
||||
f.writelines(new_lines)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
return True
|
||||
|
||||
|
||||
def reload_env() -> int:
|
||||
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated."""
|
||||
env_vars = load_env()
|
||||
count = 0
|
||||
for key, value in env_vars.items():
|
||||
if os.environ.get(key) != value:
|
||||
os.environ[key] = value
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def get_env_value(key: str) -> Optional[str]:
|
||||
"""Get a value from ~/.hermes/.env or environment."""
|
||||
@@ -1765,7 +1651,6 @@ def show_config():
|
||||
keys = [
|
||||
("OPENROUTER_API_KEY", "OpenRouter"),
|
||||
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
|
||||
("EXA_API_KEY", "Exa"),
|
||||
("PARALLEL_API_KEY", "Parallel"),
|
||||
("FIRECRAWL_API_KEY", "Firecrawl"),
|
||||
("TAVILY_API_KEY", "Tavily"),
|
||||
@@ -1925,7 +1810,7 @@ def set_config_value(key: str, value: str):
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY',
|
||||
'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY',
|
||||
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
|
||||
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
||||
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
||||
|
||||
+4
-30
@@ -4,7 +4,7 @@ Used by `hermes tools` and `hermes skills` for interactive checklists.
|
||||
Provides a curses multi-select with keyboard navigation, plus a
|
||||
text-based numbered fallback for terminals without curses support.
|
||||
"""
|
||||
from typing import Callable, List, Optional, Set
|
||||
from typing import List, Set
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
@@ -15,7 +15,6 @@ def curses_checklist(
|
||||
selected: Set[int],
|
||||
*,
|
||||
cancel_returns: Set[int] | None = None,
|
||||
status_fn: Optional[Callable[[Set[int]], str]] = None,
|
||||
) -> Set[int]:
|
||||
"""Curses multi-select checklist. Returns set of selected indices.
|
||||
|
||||
@@ -24,9 +23,6 @@ def curses_checklist(
|
||||
items: Display labels for each row.
|
||||
selected: Indices that start checked (pre-selected).
|
||||
cancel_returns: Returned on ESC/q. Defaults to the original *selected*.
|
||||
status_fn: Optional callback ``f(chosen_indices) -> str`` whose return
|
||||
value is rendered on the bottom row of the terminal. Use this for
|
||||
live aggregate info (e.g. estimated token counts).
|
||||
"""
|
||||
if cancel_returns is None:
|
||||
cancel_returns = set(selected)
|
||||
@@ -51,9 +47,6 @@ def curses_checklist(
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
# Reserve bottom row for status bar when status_fn provided
|
||||
footer_rows = 1 if status_fn else 0
|
||||
|
||||
# Header
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
@@ -69,7 +62,7 @@ def curses_checklist(
|
||||
pass
|
||||
|
||||
# Scrollable item list
|
||||
visible_rows = max_y - 3 - footer_rows
|
||||
visible_rows = max_y - 3
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
@@ -79,7 +72,7 @@ def curses_checklist(
|
||||
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + 3
|
||||
if y >= max_y - 1 - footer_rows:
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
check = "✓" if i in chosen else " "
|
||||
arrow = "→" if i == cursor else " "
|
||||
@@ -94,20 +87,6 @@ def curses_checklist(
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Status bar (bottom row, right-aligned)
|
||||
if status_fn:
|
||||
try:
|
||||
status_text = status_fn(chosen)
|
||||
if status_text:
|
||||
# Right-align on the bottom row
|
||||
sx = max(0, max_x - len(status_text) - 1)
|
||||
sattr = curses.A_DIM
|
||||
if curses.has_colors():
|
||||
sattr |= curses.color_pair(3)
|
||||
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
@@ -128,7 +107,7 @@ def curses_checklist(
|
||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||
|
||||
except Exception:
|
||||
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
|
||||
return _numbered_fallback(title, items, selected, cancel_returns)
|
||||
|
||||
|
||||
def _numbered_fallback(
|
||||
@@ -136,7 +115,6 @@ def _numbered_fallback(
|
||||
items: List[str],
|
||||
selected: Set[int],
|
||||
cancel_returns: Set[int],
|
||||
status_fn: Optional[Callable[[Set[int]], str]] = None,
|
||||
) -> Set[int]:
|
||||
"""Text-based toggle fallback for terminals without curses."""
|
||||
chosen = set(selected)
|
||||
@@ -147,10 +125,6 @@ def _numbered_fallback(
|
||||
for i, label in enumerate(items):
|
||||
marker = color("[✓]", Colors.GREEN) if i in chosen else "[ ]"
|
||||
print(f" {marker} {i + 1:>2}. {label}")
|
||||
if status_fn:
|
||||
status_text = status_fn(chosen)
|
||||
if status_text:
|
||||
print(color(f"\n {status_text}", Colors.DIM))
|
||||
print()
|
||||
try:
|
||||
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
|
||||
|
||||
+27
-76
@@ -10,11 +10,9 @@ import subprocess
|
||||
import shutil
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
PROJECT_ROOT = get_project_root()
|
||||
HERMES_HOME = get_hermes_home()
|
||||
_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)
|
||||
|
||||
# Load environment variables from ~/.hermes/.env so API key checks work
|
||||
from dotenv import load_dotenv
|
||||
@@ -58,7 +56,7 @@ def _honcho_is_configured_for_doctor() -> bool:
|
||||
from honcho_integration.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig.from_global_config()
|
||||
return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
|
||||
return bool(cfg.enabled and cfg.api_key)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -211,14 +209,14 @@ def run_doctor(args):
|
||||
# Check ~/.hermes/.env (primary location for user config)
|
||||
env_path = HERMES_HOME / '.env'
|
||||
if env_path.exists():
|
||||
check_ok(f"{_DHH}/.env file exists")
|
||||
check_ok("~/.hermes/.env file exists")
|
||||
|
||||
# Check for common issues
|
||||
content = env_path.read_text()
|
||||
if _has_provider_env_config(content):
|
||||
check_ok("API key or custom endpoint configured")
|
||||
else:
|
||||
check_warn(f"No API key found in {_DHH}/.env")
|
||||
check_warn("No API key found in ~/.hermes/.env")
|
||||
issues.append("Run 'hermes setup' to configure API keys")
|
||||
else:
|
||||
# Also check project root as fallback
|
||||
@@ -226,11 +224,11 @@ def run_doctor(args):
|
||||
if fallback_env.exists():
|
||||
check_ok(".env file exists (in project directory)")
|
||||
else:
|
||||
check_fail(f"{_DHH}/.env file missing")
|
||||
check_fail("~/.hermes/.env file missing")
|
||||
if should_fix:
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.touch()
|
||||
check_ok(f"Created empty {_DHH}/.env")
|
||||
check_ok("Created empty ~/.hermes/.env")
|
||||
check_info("Run 'hermes setup' to configure API keys")
|
||||
fixed_count += 1
|
||||
else:
|
||||
@@ -240,7 +238,7 @@ def run_doctor(args):
|
||||
# Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)
|
||||
config_path = HERMES_HOME / 'config.yaml'
|
||||
if config_path.exists():
|
||||
check_ok(f"{_DHH}/config.yaml exists")
|
||||
check_ok("~/.hermes/config.yaml exists")
|
||||
else:
|
||||
fallback_config = PROJECT_ROOT / 'cli-config.yaml'
|
||||
if fallback_config.exists():
|
||||
@@ -250,11 +248,11 @@ def run_doctor(args):
|
||||
if should_fix and example_config.exists():
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(example_config), str(config_path))
|
||||
check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
|
||||
check_ok("Created ~/.hermes/config.yaml from cli-config.yaml.example")
|
||||
fixed_count += 1
|
||||
elif should_fix:
|
||||
check_warn("config.yaml not found and no example to copy from")
|
||||
manual_issues.append(f"Create {_DHH}/config.yaml manually")
|
||||
manual_issues.append("Create ~/.hermes/config.yaml manually")
|
||||
else:
|
||||
check_warn("config.yaml not found", "(using defaults)")
|
||||
|
||||
@@ -296,28 +294,28 @@ def run_doctor(args):
|
||||
|
||||
hermes_home = HERMES_HOME
|
||||
if hermes_home.exists():
|
||||
check_ok(f"{_DHH} directory exists")
|
||||
check_ok("~/.hermes directory exists")
|
||||
else:
|
||||
if should_fix:
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
check_ok(f"Created {_DHH} directory")
|
||||
check_ok("Created ~/.hermes directory")
|
||||
fixed_count += 1
|
||||
else:
|
||||
check_warn(f"{_DHH} not found", "(will be created on first use)")
|
||||
check_warn("~/.hermes not found", "(will be created on first use)")
|
||||
|
||||
# Check expected subdirectories
|
||||
expected_subdirs = ["cron", "sessions", "logs", "skills", "memories"]
|
||||
for subdir_name in expected_subdirs:
|
||||
subdir_path = hermes_home / subdir_name
|
||||
if subdir_path.exists():
|
||||
check_ok(f"{_DHH}/{subdir_name}/ exists")
|
||||
check_ok(f"~/.hermes/{subdir_name}/ exists")
|
||||
else:
|
||||
if should_fix:
|
||||
subdir_path.mkdir(parents=True, exist_ok=True)
|
||||
check_ok(f"Created {_DHH}/{subdir_name}/")
|
||||
check_ok(f"Created ~/.hermes/{subdir_name}/")
|
||||
fixed_count += 1
|
||||
else:
|
||||
check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)")
|
||||
check_warn(f"~/.hermes/{subdir_name}/ not found", "(will be created on first use)")
|
||||
|
||||
# Check for SOUL.md persona file
|
||||
soul_path = hermes_home / "SOUL.md"
|
||||
@@ -326,11 +324,11 @@ def run_doctor(args):
|
||||
# Check if it's just the template comments (no real content)
|
||||
lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
|
||||
if lines:
|
||||
check_ok(f"{_DHH}/SOUL.md exists (persona configured)")
|
||||
check_ok("~/.hermes/SOUL.md exists (persona configured)")
|
||||
else:
|
||||
check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
|
||||
check_info("~/.hermes/SOUL.md exists but is empty — edit it to customize personality")
|
||||
else:
|
||||
check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
|
||||
check_warn("~/.hermes/SOUL.md not found", "(create it to give Hermes a custom personality)")
|
||||
if should_fix:
|
||||
soul_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
soul_path.write_text(
|
||||
@@ -339,13 +337,13 @@ def run_doctor(args):
|
||||
"You are Hermes, a helpful AI assistant.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
check_ok(f"Created {_DHH}/SOUL.md with basic template")
|
||||
check_ok("Created ~/.hermes/SOUL.md with basic template")
|
||||
fixed_count += 1
|
||||
|
||||
# Check memory directory
|
||||
memories_dir = hermes_home / "memories"
|
||||
if memories_dir.exists():
|
||||
check_ok(f"{_DHH}/memories/ directory exists")
|
||||
check_ok("~/.hermes/memories/ directory exists")
|
||||
memory_file = memories_dir / "MEMORY.md"
|
||||
user_file = memories_dir / "USER.md"
|
||||
if memory_file.exists():
|
||||
@@ -359,10 +357,10 @@ def run_doctor(args):
|
||||
else:
|
||||
check_info("USER.md not created yet (will be created when the agent first writes a memory)")
|
||||
else:
|
||||
check_warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
|
||||
check_warn("~/.hermes/memories/ not found", "(will be created on first use)")
|
||||
if should_fix:
|
||||
memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
check_ok(f"Created {_DHH}/memories/")
|
||||
check_ok("Created ~/.hermes/memories/")
|
||||
fixed_count += 1
|
||||
|
||||
# Check SQLite session store
|
||||
@@ -374,11 +372,11 @@ def run_doctor(args):
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM sessions")
|
||||
count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
check_ok(f"{_DHH}/state.db exists ({count} sessions)")
|
||||
check_ok(f"~/.hermes/state.db exists ({count} sessions)")
|
||||
except Exception as e:
|
||||
check_warn(f"{_DHH}/state.db exists but has issues: {e}")
|
||||
check_warn(f"~/.hermes/state.db exists but has issues: {e}")
|
||||
else:
|
||||
check_info(f"{_DHH}/state.db not created yet (will be created on first session)")
|
||||
check_info("~/.hermes/state.db not created yet (will be created on first session)")
|
||||
|
||||
_check_gateway_service_linger(issues)
|
||||
|
||||
@@ -693,7 +691,7 @@ def run_doctor(args):
|
||||
if github_token:
|
||||
check_ok("GitHub token configured (authenticated API access)")
|
||||
else:
|
||||
check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
|
||||
check_warn("No GITHUB_TOKEN", "(60 req/hr rate limit — set in ~/.hermes/.env for better rates)")
|
||||
|
||||
# =========================================================================
|
||||
# Honcho memory
|
||||
@@ -710,8 +708,8 @@ def run_doctor(args):
|
||||
check_warn("Honcho config not found", "run: hermes honcho setup")
|
||||
elif not hcfg.enabled:
|
||||
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
|
||||
elif not (hcfg.api_key or hcfg.base_url):
|
||||
check_fail("Honcho API key or base URL not set", "run: hermes honcho setup")
|
||||
elif not hcfg.api_key:
|
||||
check_fail("Honcho API key not set", "run: hermes honcho setup")
|
||||
issues.append("No Honcho API key — run 'hermes honcho setup'")
|
||||
else:
|
||||
from honcho_integration.client import get_honcho_client, reset_honcho_client
|
||||
@@ -730,53 +728,6 @@ def run_doctor(args):
|
||||
except Exception as _e:
|
||||
check_warn("Honcho check failed", str(_e))
|
||||
|
||||
# =========================================================================
|
||||
# Profiles
|
||||
# =========================================================================
|
||||
try:
|
||||
from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
|
||||
import re as _re
|
||||
|
||||
named_profiles = [p for p in list_profiles() if not p.is_default]
|
||||
if named_profiles:
|
||||
print()
|
||||
print(color("◆ Profiles", Colors.CYAN, Colors.BOLD))
|
||||
check_ok(f"{len(named_profiles)} profile(s) found")
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
for p in named_profiles:
|
||||
parts = []
|
||||
if p.gateway_running:
|
||||
parts.append("gateway running")
|
||||
if p.model:
|
||||
parts.append(p.model[:30])
|
||||
if not (p.path / "config.yaml").exists():
|
||||
parts.append("⚠ missing config")
|
||||
if not (p.path / ".env").exists():
|
||||
parts.append("no .env")
|
||||
wrapper = wrapper_dir / p.name
|
||||
if not wrapper.exists():
|
||||
parts.append("no alias")
|
||||
status = ", ".join(parts) if parts else "configured"
|
||||
check_ok(f" {p.name}: {status}")
|
||||
|
||||
# Check for orphan wrappers
|
||||
if wrapper_dir.is_dir():
|
||||
for wrapper in wrapper_dir.iterdir():
|
||||
if not wrapper.is_file():
|
||||
continue
|
||||
try:
|
||||
content = wrapper.read_text()
|
||||
if "hermes -p" in content:
|
||||
_m = _re.search(r"hermes -p (\S+)", content)
|
||||
if _m and not profile_exists(_m.group(1)):
|
||||
check_warn(f"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists")
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.debug("Profile health check failed: %s", _e)
|
||||
|
||||
# =========================================================================
|
||||
# Summary
|
||||
# =========================================================================
|
||||
|
||||
+21
-176
@@ -15,8 +15,6 @@ from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
|
||||
# display_hermes_home is imported lazily at call sites to avoid ImportError
|
||||
# when hermes_constants is cached from a pre-update version during `hermes update`.
|
||||
from hermes_cli.setup import (
|
||||
print_header, print_info, print_success, print_warning, print_error,
|
||||
prompt, prompt_choice, prompt_yes_no,
|
||||
@@ -127,43 +125,20 @@ _SERVICE_BASE = "hermes-gateway"
|
||||
SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
|
||||
|
||||
|
||||
def _profile_suffix() -> str:
|
||||
"""Derive a service-name suffix from the current HERMES_HOME.
|
||||
|
||||
Returns ``""`` for the default ``~/.hermes``, the profile name for
|
||||
``~/.hermes/profiles/<name>``, or a short hash for any other custom
|
||||
HERMES_HOME path.
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path as _Path
|
||||
home = get_hermes_home().resolve()
|
||||
default = (_Path.home() / ".hermes").resolve()
|
||||
if home == default:
|
||||
return ""
|
||||
# Detect ~/.hermes/profiles/<name> pattern → use the profile name
|
||||
profiles_root = (default / "profiles").resolve()
|
||||
try:
|
||||
rel = home.relative_to(profiles_root)
|
||||
parts = rel.parts
|
||||
if len(parts) == 1 and re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", parts[0]):
|
||||
return parts[0]
|
||||
except ValueError:
|
||||
pass
|
||||
# Fallback: short hash for arbitrary HERMES_HOME paths
|
||||
return hashlib.sha256(str(home).encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def get_service_name() -> str:
|
||||
"""Derive a systemd service name scoped to this HERMES_HOME.
|
||||
|
||||
Default ``~/.hermes`` returns ``hermes-gateway`` (backward compatible).
|
||||
Profile ``~/.hermes/profiles/coder`` returns ``hermes-gateway-coder``.
|
||||
Any other HERMES_HOME appends a short hash for uniqueness.
|
||||
Any other HERMES_HOME appends a short hash so multiple installations
|
||||
can each have their own systemd service without conflicting.
|
||||
"""
|
||||
suffix = _profile_suffix()
|
||||
if not suffix:
|
||||
import hashlib
|
||||
from pathlib import Path as _Path # local import to avoid monkeypatch interference
|
||||
home = get_hermes_home().resolve()
|
||||
default = (_Path.home() / ".hermes").resolve()
|
||||
if home == default:
|
||||
return _SERVICE_BASE
|
||||
suffix = hashlib.sha256(str(home).encode()).hexdigest()[:8]
|
||||
return f"{_SERVICE_BASE}-{suffix}"
|
||||
|
||||
|
||||
@@ -394,14 +369,7 @@ def print_systemd_linger_guidance() -> None:
|
||||
print(" sudo loginctl enable-linger $USER")
|
||||
|
||||
def get_launchd_plist_path() -> Path:
|
||||
"""Return the launchd plist path, scoped per profile.
|
||||
|
||||
Default ``~/.hermes`` → ``ai.hermes.gateway.plist`` (backward compatible).
|
||||
Profile ``~/.hermes/profiles/coder`` → ``ai.hermes.gateway-coder.plist``.
|
||||
"""
|
||||
suffix = _profile_suffix()
|
||||
name = f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"{name}.plist"
|
||||
return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist"
|
||||
|
||||
def _detect_venv_dir() -> Path | None:
|
||||
"""Detect the active virtualenv directory.
|
||||
@@ -452,17 +420,6 @@ def get_hermes_cli_path() -> str:
|
||||
# Systemd (Linux)
|
||||
# =============================================================================
|
||||
|
||||
def _build_user_local_paths(home: Path, path_entries: list[str]) -> list[str]:
|
||||
"""Return user-local bin dirs that exist and aren't already in *path_entries*."""
|
||||
candidates = [
|
||||
str(home / ".local" / "bin"), # uv, uvx, pip-installed CLIs
|
||||
str(home / ".cargo" / "bin"), # Rust/cargo tools
|
||||
str(home / "go" / "bin"), # Go tools
|
||||
str(home / ".npm-global" / "bin"), # npm global packages
|
||||
]
|
||||
return [p for p in candidates if p not in path_entries and Path(p).exists()]
|
||||
|
||||
|
||||
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
@@ -477,16 +434,13 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
resolved_node_dir = str(Path(resolved_node).resolve().parent)
|
||||
if resolved_node_dir not in path_entries:
|
||||
path_entries.append(resolved_node_dir)
|
||||
path_entries.extend(["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"])
|
||||
sane_path = ":".join(path_entries)
|
||||
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
|
||||
common_bin_paths = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"]
|
||||
|
||||
if system:
|
||||
username, group_name, home_dir = _system_service_identity(run_as_user)
|
||||
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
|
||||
path_entries.extend(common_bin_paths)
|
||||
sane_path = ":".join(path_entries)
|
||||
return f"""[Unit]
|
||||
Description={SERVICE_DESCRIPTION}
|
||||
After=network-online.target
|
||||
@@ -518,9 +472,6 @@ StandardError=journal
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
path_entries.extend(_build_user_local_paths(Path.home(), path_entries))
|
||||
path_entries.extend(common_bin_paths)
|
||||
sane_path = ":".join(path_entries)
|
||||
return f"""[Unit]
|
||||
Description={SERVICE_DESCRIPTION}
|
||||
After=network.target
|
||||
@@ -801,46 +752,18 @@ def systemd_status(deep: bool = False, system: bool = False):
|
||||
# Launchd (macOS)
|
||||
# =============================================================================
|
||||
|
||||
def get_launchd_label() -> str:
|
||||
"""Return the launchd service label, scoped per profile."""
|
||||
suffix = _profile_suffix()
|
||||
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
label = get_launchd_label()
|
||||
# Build a sane PATH for the launchd plist. launchd provides only a
|
||||
# minimal default (/usr/bin:/bin:/usr/sbin:/sbin) which misses Homebrew,
|
||||
# nvm, cargo, etc. We prepend venv/bin and node_modules/.bin (matching
|
||||
# the systemd unit), then capture the user's full shell PATH so every
|
||||
# user-installed tool (node, ffmpeg, …) is reachable.
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin")
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
node_bin = str(PROJECT_ROOT / "node_modules" / ".bin")
|
||||
# Resolve the directory containing the node binary (e.g. Homebrew, nvm)
|
||||
# so it's explicitly in PATH even if the user's shell PATH changes later.
|
||||
priority_dirs = [venv_bin, node_bin]
|
||||
resolved_node = shutil.which("node")
|
||||
if resolved_node:
|
||||
resolved_node_dir = str(Path(resolved_node).resolve().parent)
|
||||
if resolved_node_dir not in priority_dirs:
|
||||
priority_dirs.append(resolved_node_dir)
|
||||
sane_path = ":".join(
|
||||
dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p])
|
||||
)
|
||||
|
||||
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{label}</string>
|
||||
<string>ai.hermes.gateway</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
@@ -855,16 +778,6 @@ def generate_launchd_plist() -> str:
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{working_dir}</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{sane_path}</string>
|
||||
<key>VIRTUAL_ENV</key>
|
||||
<string>{venv_dir}</string>
|
||||
<key>HERMES_HOME</key>
|
||||
<string>{hermes_home}</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
@@ -937,8 +850,7 @@ def launchd_install(force: bool = False):
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" hermes gateway status # Check status")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print(f" tail -f {_dhh()}/logs/gateway.log # View logs")
|
||||
print(" tail -f ~/.hermes/logs/gateway.log # View logs")
|
||||
|
||||
def launchd_uninstall():
|
||||
plist_path = get_launchd_plist_path()
|
||||
@@ -951,33 +863,20 @@ def launchd_uninstall():
|
||||
print("✓ Service uninstalled")
|
||||
|
||||
def launchd_start():
|
||||
plist_path = get_launchd_plist_path()
|
||||
label = get_launchd_label()
|
||||
|
||||
# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
|
||||
if not plist_path.exists():
|
||||
print("↻ launchd plist missing; regenerating service definition")
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
print("✓ Service started")
|
||||
return
|
||||
|
||||
refresh_launchd_plist_if_needed()
|
||||
plist_path = get_launchd_plist_path()
|
||||
try:
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3:
|
||||
if e.returncode != 3 or not plist_path.exists():
|
||||
raise
|
||||
print("↻ launchd job was unloaded; reloading service definition")
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
print("✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
label = get_launchd_label()
|
||||
subprocess.run(["launchctl", "stop", label], check=True)
|
||||
subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True)
|
||||
print("✓ Service stopped")
|
||||
|
||||
def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
|
||||
@@ -1032,9 +931,8 @@ def launchd_restart():
|
||||
|
||||
def launchd_status(deep: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
label = get_launchd_label()
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", label],
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
@@ -1322,59 +1220,6 @@ _PLATFORMS = [
|
||||
"help": "The AppSecret from your DingTalk application credentials."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "feishu",
|
||||
"label": "Feishu / Lark",
|
||||
"emoji": "🪽",
|
||||
"token_var": "FEISHU_APP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)",
|
||||
"2. Create an app and copy the App ID and App Secret",
|
||||
"3. Enable the Bot capability for the app",
|
||||
"4. Choose WebSocket (recommended) or Webhook connection mode",
|
||||
"5. Add the bot to a group chat or message it directly",
|
||||
"6. Restrict access with FEISHU_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "FEISHU_APP_ID", "prompt": "App ID", "password": False,
|
||||
"help": "The App ID from your Feishu/Lark application."},
|
||||
{"name": "FEISHU_APP_SECRET", "prompt": "App Secret", "password": True,
|
||||
"help": "The App Secret from your Feishu/Lark application."},
|
||||
{"name": "FEISHU_DOMAIN", "prompt": "Domain — feishu or lark (default: feishu)", "password": False,
|
||||
"help": "Use 'feishu' for Feishu China, or 'lark' for Lark international."},
|
||||
{"name": "FEISHU_CONNECTION_MODE", "prompt": "Connection mode — websocket or webhook (default: websocket)", "password": False,
|
||||
"help": "websocket is recommended unless you specifically need webhook mode."},
|
||||
{"name": "FEISHU_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which Feishu/Lark users can interact with the bot."},
|
||||
{"name": "FEISHU_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
|
||||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "wecom",
|
||||
"label": "WeCom (Enterprise WeChat)",
|
||||
"emoji": "💬",
|
||||
"token_var": "WECOM_BOT_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to WeCom Admin Console → Applications → Create AI Bot",
|
||||
"2. Copy the Bot ID and Secret from the bot's credentials page",
|
||||
"3. The bot connects via WebSocket — no public endpoint needed",
|
||||
"4. Add the bot to a group chat or message it directly in WeCom",
|
||||
"5. Restrict access with WECOM_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "WECOM_BOT_ID", "prompt": "Bot ID", "password": False,
|
||||
"help": "The Bot ID from your WeCom AI Bot."},
|
||||
{"name": "WECOM_SECRET", "prompt": "Secret", "password": True,
|
||||
"help": "The secret from your WeCom AI Bot."},
|
||||
{"name": "WECOM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which WeCom users can interact with the bot."},
|
||||
{"name": "WECOM_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
|
||||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1592,7 +1437,7 @@ def _is_service_running() -> bool:
|
||||
return False
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
+77
-814
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ from hermes_cli.config import (
|
||||
get_hermes_home, # noqa: F401 — used by test mocks
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -245,7 +244,7 @@ def cmd_mcp_add(args):
|
||||
api_key = _prompt("API key / Bearer token", password=True)
|
||||
if api_key:
|
||||
save_env_value(env_key, api_key)
|
||||
_success(f"Saved to {display_hermes_home()}/.env as {env_key}")
|
||||
_success(f"Saved to ~/.hermes/.env as {env_key}")
|
||||
|
||||
# Set header with env var interpolation
|
||||
if api_key or existing_key:
|
||||
@@ -333,7 +332,7 @@ def cmd_mcp_add(args):
|
||||
_save_mcp_server(name, server_config)
|
||||
|
||||
print()
|
||||
_success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{total} tools enabled)")
|
||||
_success(f"Saved '{name}' to ~/.hermes/config.yaml ({tool_count}/{total} tools enabled)")
|
||||
_info("Start a new session to use these tools.")
|
||||
|
||||
|
||||
@@ -608,11 +607,6 @@ def mcp_command(args):
|
||||
"""Main dispatcher for ``hermes mcp`` subcommands."""
|
||||
action = getattr(args, "mcp_action", None)
|
||||
|
||||
if action == "serve":
|
||||
from mcp_serve import run_mcp_server
|
||||
run_mcp_server(verbose=getattr(args, "verbose", False))
|
||||
return
|
||||
|
||||
handlers = {
|
||||
"add": cmd_mcp_add,
|
||||
"remove": cmd_mcp_remove,
|
||||
@@ -631,7 +625,6 @@ def mcp_command(args):
|
||||
# No subcommand — show list
|
||||
cmd_mcp_list()
|
||||
print(color(" Commands:", Colors.CYAN))
|
||||
_info("hermes mcp serve Run as MCP server")
|
||||
_info("hermes mcp add <name> --url <endpoint> Add an MCP server")
|
||||
_info("hermes mcp add <name> --command <cmd> Add a stdio server")
|
||||
_info("hermes mcp remove <name> Remove a server")
|
||||
|
||||
+5
-30
@@ -35,8 +35,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("google/gemini-3-pro-preview", ""),
|
||||
("google/gemini-3-flash-preview", ""),
|
||||
("google/gemini-3.1-pro-preview", ""),
|
||||
("google/gemini-3.1-flash-lite-preview", ""),
|
||||
("qwen/qwen3.5-plus-02-15", ""),
|
||||
("qwen/qwen3.5-35b-a3b", ""),
|
||||
("stepfun/step-3.5-flash", ""),
|
||||
@@ -64,8 +62,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"openai/gpt-5.3-codex",
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3-flash-preview",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.1-flash-lite-preview",
|
||||
"qwen/qwen3.5-plus-02-15",
|
||||
"qwen/qwen3.5-35b-a3b",
|
||||
"stepfun/step-3.5-flash",
|
||||
@@ -212,31 +208,14 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3-flash-preview",
|
||||
],
|
||||
# Alibaba DashScope Coding platform (coding-intl) — default endpoint.
|
||||
# Supports Qwen models + third-party providers (GLM, Kimi, MiniMax).
|
||||
# Users with classic DashScope keys should override DASHSCOPE_BASE_URL
|
||||
# to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat)
|
||||
# or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat).
|
||||
"alibaba": [
|
||||
"qwen3.5-plus",
|
||||
"qwen3-max",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-coder-next",
|
||||
# Third-party models available on coding-intl
|
||||
"glm-5",
|
||||
"glm-4.7",
|
||||
"kimi-k2.5",
|
||||
"MiniMax-M2.5",
|
||||
],
|
||||
# Curated HF model list — only agentic models that map to OpenRouter defaults.
|
||||
"huggingface": [
|
||||
"Qwen/Qwen3.5-397B-A17B",
|
||||
"Qwen/Qwen3.5-35B-A3B",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"moonshotai/Kimi-K2.5",
|
||||
"MiniMaxAI/MiniMax-M2.5",
|
||||
"zai-org/GLM-5",
|
||||
"XiaomiMiMo/MiMo-V2-Flash",
|
||||
"moonshotai/Kimi-K2-Thinking",
|
||||
"qwen-plus-latest",
|
||||
"qwen3.5-flash",
|
||||
"qwen-vl-max",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -257,7 +236,6 @@ _PROVIDER_LABELS = {
|
||||
"ai-gateway": "AI Gateway",
|
||||
"kilocode": "Kilo Code",
|
||||
"alibaba": "Alibaba Cloud (DashScope)",
|
||||
"huggingface": "Hugging Face",
|
||||
"custom": "Custom endpoint",
|
||||
}
|
||||
|
||||
@@ -293,9 +271,6 @@ _PROVIDER_ALIASES = {
|
||||
"aliyun": "alibaba",
|
||||
"qwen": "alibaba",
|
||||
"alibaba-cloud": "alibaba",
|
||||
"hf": "huggingface",
|
||||
"hugging-face": "huggingface",
|
||||
"huggingface-hub": "huggingface",
|
||||
}
|
||||
|
||||
|
||||
@@ -329,7 +304,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
# Canonical providers in display order
|
||||
_PROVIDER_ORDER = [
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
||||
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
||||
"opencode-zen", "opencode-go",
|
||||
"ai-gateway", "deepseek", "custom",
|
||||
]
|
||||
|
||||
+6
-64
@@ -68,17 +68,6 @@ def _env_enabled(name: str) -> bool:
|
||||
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _get_disabled_plugins() -> set:
|
||||
"""Read the disabled plugins list from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -152,34 +141,6 @@ class PluginContext:
|
||||
self._manager._plugin_tool_names.add(name)
|
||||
logger.debug("Plugin %s registered tool: %s", self.manifest.name, name)
|
||||
|
||||
# -- message injection --------------------------------------------------
|
||||
|
||||
def inject_message(self, content: str, role: str = "user") -> bool:
|
||||
"""Inject a message into the active conversation.
|
||||
|
||||
If the agent is idle (waiting for user input), this starts a new turn.
|
||||
If the agent is running, this interrupts and injects the message.
|
||||
|
||||
This enables plugins (e.g. remote control viewers, messaging bridges)
|
||||
to send messages into the conversation from external sources.
|
||||
|
||||
Returns True if the message was queued successfully.
|
||||
"""
|
||||
cli = self._manager._cli_ref
|
||||
if cli is None:
|
||||
logger.warning("inject_message: no CLI reference (not available in gateway mode)")
|
||||
return False
|
||||
|
||||
msg = content if role == "user" else f"[{role}] {content}"
|
||||
|
||||
if getattr(cli, "_agent_running", False):
|
||||
# Agent is mid-turn — interrupt with the message
|
||||
cli._interrupt_queue.put(msg)
|
||||
else:
|
||||
# Agent is idle — queue as next input
|
||||
cli._pending_input.put(msg)
|
||||
return True
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
@@ -212,7 +173,6 @@ class PluginManager:
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._discovered: bool = False
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public
|
||||
@@ -239,15 +199,8 @@ class PluginManager:
|
||||
# 3. Pip / entry-point plugins
|
||||
manifests.extend(self._scan_entry_points())
|
||||
|
||||
# Load each manifest (skip user-disabled plugins)
|
||||
disabled = _get_disabled_plugins()
|
||||
# Load each manifest
|
||||
for manifest in manifests:
|
||||
if manifest.name in disabled:
|
||||
loaded = LoadedPlugin(manifest=manifest, enabled=False)
|
||||
loaded.error = "disabled via config"
|
||||
self._plugins[manifest.name] = loaded
|
||||
logger.debug("Skipping disabled plugin '%s'", manifest.name)
|
||||
continue
|
||||
self._load_plugin(manifest)
|
||||
|
||||
if manifests:
|
||||
@@ -432,23 +385,16 @@ class PluginManager:
|
||||
# Hook invocation
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]:
|
||||
def invoke_hook(self, hook_name: str, **kwargs: Any) -> None:
|
||||
"""Call all registered callbacks for *hook_name*.
|
||||
|
||||
Each callback is wrapped in its own try/except so a misbehaving
|
||||
plugin cannot break the core agent loop.
|
||||
|
||||
Returns a list of non-``None`` return values from callbacks.
|
||||
This allows hooks like ``pre_llm_call`` to contribute context
|
||||
that the agent core can collect and inject.
|
||||
"""
|
||||
callbacks = self._hooks.get(hook_name, [])
|
||||
results: List[Any] = []
|
||||
for cb in callbacks:
|
||||
try:
|
||||
ret = cb(**kwargs)
|
||||
if ret is not None:
|
||||
results.append(ret)
|
||||
cb(**kwargs)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Hook '%s' callback %s raised: %s",
|
||||
@@ -456,7 +402,6 @@ class PluginManager:
|
||||
getattr(cb, "__name__", repr(cb)),
|
||||
exc,
|
||||
)
|
||||
return results
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Introspection
|
||||
@@ -501,12 +446,9 @@ def discover_plugins() -> None:
|
||||
get_plugin_manager().discover_and_load()
|
||||
|
||||
|
||||
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||||
"""Invoke a lifecycle hook on all loaded plugins.
|
||||
|
||||
Returns a list of non-``None`` return values from plugin callbacks.
|
||||
"""
|
||||
return get_plugin_manager().invoke_hook(hook_name, **kwargs)
|
||||
def invoke_hook(hook_name: str, **kwargs: Any) -> None:
|
||||
"""Invoke a lifecycle hook on all loaded plugins."""
|
||||
get_plugin_manager().invoke_hook(hook_name, **kwargs)
|
||||
|
||||
|
||||
def get_plugin_tool_names() -> Set[str]:
|
||||
|
||||
+2
-153
@@ -374,73 +374,6 @@ def cmd_remove(name: str) -> None:
|
||||
_display_removed(name, plugins_dir)
|
||||
|
||||
|
||||
def _get_disabled_set() -> set:
|
||||
"""Read the disabled plugins set from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _save_disabled_set(disabled: set) -> None:
|
||||
"""Write the disabled plugins list to config.yaml."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
if "plugins" not in config:
|
||||
config["plugins"] = {}
|
||||
config["plugins"]["disabled"] = sorted(disabled)
|
||||
save_config(config)
|
||||
|
||||
|
||||
def cmd_enable(name: str) -> None:
|
||||
"""Enable a previously disabled plugin."""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# Verify the plugin exists
|
||||
target = plugins_dir / name
|
||||
if not target.is_dir():
|
||||
console.print(f"[red]Plugin '{name}' is not installed.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
if name not in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
|
||||
return
|
||||
|
||||
disabled.discard(name)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. Takes effect on next session.")
|
||||
|
||||
|
||||
def cmd_disable(name: str) -> None:
|
||||
"""Disable a plugin without removing it."""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# Verify the plugin exists
|
||||
target = plugins_dir / name
|
||||
if not target.is_dir():
|
||||
console.print(f"[red]Plugin '{name}' is not installed.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
if name in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
|
||||
return
|
||||
|
||||
disabled.add(name)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(f"[yellow]⊘[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.")
|
||||
|
||||
|
||||
def cmd_list() -> None:
|
||||
"""List installed plugins."""
|
||||
from rich.console import Console
|
||||
@@ -460,11 +393,8 @@ def cmd_list() -> None:
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
return
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
table = Table(title="Installed Plugins", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Status")
|
||||
table.add_column("Version", style="dim")
|
||||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
@@ -490,86 +420,11 @@ def cmd_list() -> None:
|
||||
if (d / ".git").exists():
|
||||
source = "git"
|
||||
|
||||
is_disabled = name in disabled or d.name in disabled
|
||||
status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
|
||||
table.add_row(name, status, str(version), description, source)
|
||||
table.add_row(name, str(version), description, source)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
|
||||
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
|
||||
|
||||
|
||||
def cmd_toggle() -> None:
|
||||
"""Interactive curses checklist to enable/disable installed plugins."""
|
||||
from rich.console import Console
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
|
||||
if not dirs:
|
||||
console.print("[dim]No plugins installed.[/dim]")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
return
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
# Build items list: "name — description" for display
|
||||
names = []
|
||||
labels = []
|
||||
selected = set()
|
||||
|
||||
for i, d in enumerate(dirs):
|
||||
manifest_file = d / "plugin.yaml"
|
||||
name = d.name
|
||||
description = ""
|
||||
|
||||
if manifest_file.exists() and yaml:
|
||||
try:
|
||||
with open(manifest_file) as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
name = manifest.get("name", d.name)
|
||||
description = manifest.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
names.append(name)
|
||||
label = f"{name} — {description}" if description else name
|
||||
labels.append(label)
|
||||
|
||||
if name not in disabled and d.name not in disabled:
|
||||
selected.add(i)
|
||||
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
result = curses_checklist(
|
||||
title="Plugins — toggle enabled/disabled",
|
||||
items=labels,
|
||||
selected=selected,
|
||||
)
|
||||
|
||||
# Compute new disabled set from deselected items
|
||||
new_disabled = set()
|
||||
for i, name in enumerate(names):
|
||||
if i not in result:
|
||||
new_disabled.add(name)
|
||||
|
||||
if new_disabled != disabled:
|
||||
_save_disabled_set(new_disabled)
|
||||
enabled_count = len(names) - len(new_disabled)
|
||||
console.print(
|
||||
f"\n[green]✓[/green] {enabled_count} enabled, {len(new_disabled)} disabled. "
|
||||
f"Takes effect on next session."
|
||||
)
|
||||
else:
|
||||
console.print("\n[dim]No changes.[/dim]")
|
||||
|
||||
|
||||
def plugins_command(args) -> None:
|
||||
@@ -582,14 +437,8 @@ def plugins_command(args) -> None:
|
||||
cmd_update(args.name)
|
||||
elif action in ("remove", "rm", "uninstall"):
|
||||
cmd_remove(args.name)
|
||||
elif action == "enable":
|
||||
cmd_enable(args.name)
|
||||
elif action == "disable":
|
||||
cmd_disable(args.name)
|
||||
elif action in ("list", "ls"):
|
||||
elif action in ("list", "ls") or action is None:
|
||||
cmd_list()
|
||||
elif action is None:
|
||||
cmd_toggle()
|
||||
else:
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
@@ -1,906 +0,0 @@
|
||||
"""
|
||||
Profile management for multiple isolated Hermes instances.
|
||||
|
||||
Each profile is a fully independent HERMES_HOME directory with its own
|
||||
config.yaml, .env, memory, sessions, skills, gateway, cron, and logs.
|
||||
Profiles live under ``~/.hermes/profiles/<name>/`` by default.
|
||||
|
||||
The "default" profile is ``~/.hermes`` itself — backward compatible,
|
||||
zero migration needed.
|
||||
|
||||
Usage::
|
||||
|
||||
hermes profile create coder # fresh profile + bundled skills
|
||||
hermes profile create coder --clone # also copy config, .env, SOUL.md
|
||||
hermes profile create coder --clone-all # full copy of source profile
|
||||
coder chat # use via wrapper alias
|
||||
hermes -p coder chat # or via flag
|
||||
hermes profile use coder # set as sticky default
|
||||
hermes profile delete coder # remove profile + alias + service
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
# Directories bootstrapped inside every new profile
|
||||
_PROFILE_DIRS = [
|
||||
"memories",
|
||||
"sessions",
|
||||
"skills",
|
||||
"skins",
|
||||
"logs",
|
||||
"plans",
|
||||
"workspace",
|
||||
"cron",
|
||||
]
|
||||
|
||||
# Files copied during --clone (if they exist in the source)
|
||||
_CLONE_CONFIG_FILES = [
|
||||
"config.yaml",
|
||||
".env",
|
||||
"SOUL.md",
|
||||
]
|
||||
|
||||
# Runtime files stripped after --clone-all (shouldn't carry over)
|
||||
_CLONE_ALL_STRIP = [
|
||||
"gateway.pid",
|
||||
"gateway_state.json",
|
||||
"processes.json",
|
||||
]
|
||||
|
||||
# Names that cannot be used as profile aliases
|
||||
_RESERVED_NAMES = frozenset({
|
||||
"hermes", "default", "test", "tmp", "root", "sudo",
|
||||
})
|
||||
|
||||
# Hermes subcommands that cannot be used as profile names/aliases
|
||||
_HERMES_SUBCOMMANDS = frozenset({
|
||||
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
|
||||
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
||||
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
||||
"profile", "plugins", "honcho", "acp",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_profiles_root() -> Path:
|
||||
"""Return the directory where named profiles are stored.
|
||||
|
||||
Always ``~/.hermes/profiles/`` — anchored to the user's home,
|
||||
NOT to the current HERMES_HOME (which may itself be a profile).
|
||||
This ensures ``coder profile list`` can see all profiles.
|
||||
"""
|
||||
return Path.home() / ".hermes" / "profiles"
|
||||
|
||||
|
||||
def _get_default_hermes_home() -> Path:
|
||||
"""Return the default (pre-profile) HERMES_HOME path."""
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
def _get_active_profile_path() -> Path:
|
||||
"""Return the path to the sticky active_profile file."""
|
||||
return _get_default_hermes_home() / "active_profile"
|
||||
|
||||
|
||||
def _get_wrapper_dir() -> Path:
|
||||
"""Return the directory for wrapper scripts."""
|
||||
return Path.home() / ".local" / "bin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_profile_name(name: str) -> None:
|
||||
"""Raise ``ValueError`` if *name* is not a valid profile identifier."""
|
||||
if name == "default":
|
||||
return # special alias for ~/.hermes
|
||||
if not _PROFILE_ID_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid profile name {name!r}. Must match "
|
||||
f"[a-z0-9][a-z0-9_-]{{0,63}}"
|
||||
)
|
||||
|
||||
|
||||
def get_profile_dir(name: str) -> Path:
|
||||
"""Resolve a profile name to its HERMES_HOME directory."""
|
||||
if name == "default":
|
||||
return _get_default_hermes_home()
|
||||
return _get_profiles_root() / name
|
||||
|
||||
|
||||
def profile_exists(name: str) -> bool:
|
||||
"""Check whether a profile directory exists."""
|
||||
if name == "default":
|
||||
return True
|
||||
return get_profile_dir(name).is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alias / wrapper script management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_alias_collision(name: str) -> Optional[str]:
|
||||
"""Return a human-readable collision message, or None if the name is safe.
|
||||
|
||||
Checks: reserved names, hermes subcommands, existing binaries in PATH.
|
||||
"""
|
||||
if name in _RESERVED_NAMES:
|
||||
return f"'{name}' is a reserved name"
|
||||
if name in _HERMES_SUBCOMMANDS:
|
||||
return f"'{name}' conflicts with a hermes subcommand"
|
||||
|
||||
# Check existing commands in PATH
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", name], capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
existing_path = result.stdout.strip()
|
||||
# Allow overwriting our own wrappers
|
||||
if existing_path == str(wrapper_dir / name):
|
||||
try:
|
||||
content = (wrapper_dir / name).read_text()
|
||||
if "hermes -p" in content:
|
||||
return None # it's our wrapper, safe to overwrite
|
||||
except Exception:
|
||||
pass
|
||||
return f"'{name}' conflicts with an existing command ({existing_path})"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
return None # safe
|
||||
|
||||
|
||||
def _is_wrapper_dir_in_path() -> bool:
|
||||
"""Check if ~/.local/bin is in PATH."""
|
||||
wrapper_dir = str(_get_wrapper_dir())
|
||||
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
||||
|
||||
|
||||
def create_wrapper_script(name: str) -> Optional[Path]:
|
||||
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
||||
|
||||
Returns the path to the created wrapper, or None if creation failed.
|
||||
"""
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
try:
|
||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
||||
return None
|
||||
|
||||
wrapper_path = wrapper_dir / name
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def remove_wrapper_script(name: str) -> bool:
|
||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
||||
wrapper_path = _get_wrapper_dir() / name
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProfileInfo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProfileInfo:
|
||||
"""Summary information about a profile."""
|
||||
name: str
|
||||
path: Path
|
||||
is_default: bool
|
||||
gateway_running: bool
|
||||
model: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
has_env: bool = False
|
||||
skill_count: int = 0
|
||||
alias_path: Optional[Path] = None
|
||||
|
||||
|
||||
def _read_config_model(profile_dir: Path) -> tuple:
|
||||
"""Read model/provider from a profile's config.yaml. Returns (model, provider)."""
|
||||
config_path = profile_dir / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return None, None
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path, "r") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
return model_cfg, None
|
||||
if isinstance(model_cfg, dict):
|
||||
return model_cfg.get("model"), model_cfg.get("provider")
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _check_gateway_running(profile_dir: Path) -> bool:
|
||||
"""Check if a gateway is running for a given profile directory."""
|
||||
pid_file = profile_dir / "gateway.pid"
|
||||
if not pid_file.exists():
|
||||
return False
|
||||
try:
|
||||
raw = pid_file.read_text().strip()
|
||||
if not raw:
|
||||
return False
|
||||
data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)}
|
||||
pid = int(data["pid"])
|
||||
os.kill(pid, 0) # existence check
|
||||
return True
|
||||
except (json.JSONDecodeError, KeyError, ValueError, TypeError,
|
||||
ProcessLookupError, PermissionError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _count_skills(profile_dir: Path) -> int:
|
||||
"""Count installed skills in a profile."""
|
||||
skills_dir = profile_dir / "skills"
|
||||
if not skills_dir.is_dir():
|
||||
return 0
|
||||
count = 0
|
||||
for md in skills_dir.rglob("SKILL.md"):
|
||||
if "/.hub/" not in str(md) and "/.git/" not in str(md):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_profiles() -> List[ProfileInfo]:
|
||||
"""Return info for all profiles, including the default."""
|
||||
profiles = []
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
|
||||
# Default profile
|
||||
default_home = _get_default_hermes_home()
|
||||
if default_home.is_dir():
|
||||
model, provider = _read_config_model(default_home)
|
||||
profiles.append(ProfileInfo(
|
||||
name="default",
|
||||
path=default_home,
|
||||
is_default=True,
|
||||
gateway_running=_check_gateway_running(default_home),
|
||||
model=model,
|
||||
provider=provider,
|
||||
has_env=(default_home / ".env").exists(),
|
||||
skill_count=_count_skills(default_home),
|
||||
))
|
||||
|
||||
# Named profiles
|
||||
profiles_root = _get_profiles_root()
|
||||
if profiles_root.is_dir():
|
||||
for entry in sorted(profiles_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
if not _PROFILE_ID_RE.match(name):
|
||||
continue
|
||||
model, provider = _read_config_model(entry)
|
||||
alias_path = wrapper_dir / name
|
||||
profiles.append(ProfileInfo(
|
||||
name=name,
|
||||
path=entry,
|
||||
is_default=False,
|
||||
gateway_running=_check_gateway_running(entry),
|
||||
model=model,
|
||||
provider=provider,
|
||||
has_env=(entry / ".env").exists(),
|
||||
skill_count=_count_skills(entry),
|
||||
alias_path=alias_path if alias_path.exists() else None,
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def create_profile(
|
||||
name: str,
|
||||
clone_from: Optional[str] = None,
|
||||
clone_all: bool = False,
|
||||
clone_config: bool = False,
|
||||
no_alias: bool = False,
|
||||
) -> Path:
|
||||
"""Create a new profile directory.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Profile identifier (lowercase, alphanumeric, hyphens, underscores).
|
||||
clone_from:
|
||||
Source profile to clone from. If ``None`` and clone_config/clone_all
|
||||
is True, defaults to the currently active profile.
|
||||
clone_all:
|
||||
If True, do a full copytree of the source (all state).
|
||||
clone_config:
|
||||
If True, copy only config files (config.yaml, .env, SOUL.md).
|
||||
no_alias:
|
||||
If True, skip wrapper script creation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
The newly created profile directory.
|
||||
"""
|
||||
validate_profile_name(name)
|
||||
|
||||
if name == "default":
|
||||
raise ValueError(
|
||||
"Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)."
|
||||
)
|
||||
|
||||
profile_dir = get_profile_dir(name)
|
||||
if profile_dir.exists():
|
||||
raise FileExistsError(f"Profile '{name}' already exists at {profile_dir}")
|
||||
|
||||
# Resolve clone source
|
||||
source_dir = None
|
||||
if clone_from is not None or clone_all or clone_config:
|
||||
if clone_from is None:
|
||||
# Default: clone from active profile
|
||||
from hermes_constants import get_hermes_home
|
||||
source_dir = get_hermes_home()
|
||||
else:
|
||||
validate_profile_name(clone_from)
|
||||
source_dir = get_profile_dir(clone_from)
|
||||
if not source_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}"
|
||||
)
|
||||
|
||||
if clone_all and source_dir:
|
||||
# Full copy of source profile
|
||||
shutil.copytree(source_dir, profile_dir)
|
||||
# Strip runtime files
|
||||
for stale in _CLONE_ALL_STRIP:
|
||||
(profile_dir / stale).unlink(missing_ok=True)
|
||||
else:
|
||||
# Bootstrap directory structure
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
for subdir in _PROFILE_DIRS:
|
||||
(profile_dir / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clone config files from source
|
||||
if source_dir is not None:
|
||||
for filename in _CLONE_CONFIG_FILES:
|
||||
src = source_dir / filename
|
||||
if src.exists():
|
||||
shutil.copy2(src, profile_dir / filename)
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict]:
|
||||
"""Seed bundled skills into a profile via subprocess.
|
||||
|
||||
Uses subprocess because sync_skills() caches HERMES_HOME at module level.
|
||||
Returns the sync result dict, or None on failure.
|
||||
"""
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
"import json; from tools.skills_sync import sync_skills; "
|
||||
"r = sync_skills(quiet=True); print(json.dumps(r))"],
|
||||
env={**os.environ, "HERMES_HOME": str(profile_dir)},
|
||||
cwd=str(project_root),
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return json.loads(result.stdout.strip())
|
||||
if not quiet:
|
||||
print(f"⚠ Skill seeding returned exit code {result.returncode}")
|
||||
if result.stderr.strip():
|
||||
print(f" {result.stderr.strip()[:200]}")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
if not quiet:
|
||||
print("⚠ Skill seeding timed out (60s)")
|
||||
return None
|
||||
except Exception as e:
|
||||
if not quiet:
|
||||
print(f"⚠ Skill seeding failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def delete_profile(name: str, yes: bool = False) -> Path:
|
||||
"""Delete a profile, its wrapper script, and its gateway service.
|
||||
|
||||
Stops the gateway if running. Disables systemd/launchd service first
|
||||
to prevent auto-restart.
|
||||
|
||||
Returns the path that was removed.
|
||||
"""
|
||||
validate_profile_name(name)
|
||||
|
||||
if name == "default":
|
||||
raise ValueError(
|
||||
"Cannot delete the default profile (~/.hermes).\n"
|
||||
"To remove everything, use: hermes uninstall"
|
||||
)
|
||||
|
||||
profile_dir = get_profile_dir(name)
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{name}' does not exist.")
|
||||
|
||||
# Show what will be deleted
|
||||
model, provider = _read_config_model(profile_dir)
|
||||
gw_running = _check_gateway_running(profile_dir)
|
||||
skill_count = _count_skills(profile_dir)
|
||||
|
||||
print(f"\nProfile: {name}")
|
||||
print(f"Path: {profile_dir}")
|
||||
if model:
|
||||
print(f"Model: {model}" + (f" ({provider})" if provider else ""))
|
||||
if skill_count:
|
||||
print(f"Skills: {skill_count}")
|
||||
|
||||
items = [
|
||||
"All config, API keys, memories, sessions, skills, cron jobs",
|
||||
]
|
||||
|
||||
# Check for service
|
||||
from hermes_cli.gateway import _profile_suffix, get_service_name
|
||||
wrapper_path = _get_wrapper_dir() / name
|
||||
has_wrapper = wrapper_path.exists()
|
||||
if has_wrapper:
|
||||
items.append(f"Command alias ({wrapper_path})")
|
||||
|
||||
print(f"\nThis will permanently delete:")
|
||||
for item in items:
|
||||
print(f" • {item}")
|
||||
if gw_running:
|
||||
print(f" ⚠ Gateway is running — it will be stopped.")
|
||||
|
||||
# Confirmation
|
||||
if not yes:
|
||||
print()
|
||||
try:
|
||||
confirm = input(f"Type '{name}' to confirm: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return profile_dir
|
||||
if confirm != name:
|
||||
print("Cancelled.")
|
||||
return profile_dir
|
||||
|
||||
# 1. Disable service (prevents auto-restart)
|
||||
_cleanup_gateway_service(name, profile_dir)
|
||||
|
||||
# 2. Stop running gateway
|
||||
if gw_running:
|
||||
_stop_gateway_process(profile_dir)
|
||||
|
||||
# 3. Remove wrapper script
|
||||
if has_wrapper:
|
||||
if remove_wrapper_script(name):
|
||||
print(f"✓ Removed {wrapper_path}")
|
||||
|
||||
# 4. Remove profile directory
|
||||
try:
|
||||
shutil.rmtree(profile_dir)
|
||||
print(f"✓ Removed {profile_dir}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not remove {profile_dir}: {e}")
|
||||
|
||||
# 5. Clear active_profile if it pointed to this profile
|
||||
try:
|
||||
active = get_active_profile()
|
||||
if active == name:
|
||||
set_active_profile("default")
|
||||
print("✓ Active profile reset to default")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"\nProfile '{name}' deleted.")
|
||||
return profile_dir
|
||||
|
||||
|
||||
def _cleanup_gateway_service(name: str, profile_dir: Path) -> None:
|
||||
"""Disable and remove systemd/launchd service for a profile."""
|
||||
import platform as _platform
|
||||
|
||||
# Derive service name for this profile
|
||||
# Temporarily set HERMES_HOME so _profile_suffix resolves correctly
|
||||
old_home = os.environ.get("HERMES_HOME")
|
||||
try:
|
||||
os.environ["HERMES_HOME"] = str(profile_dir)
|
||||
from hermes_cli.gateway import get_service_name, get_launchd_plist_path
|
||||
|
||||
if _platform.system() == "Linux":
|
||||
svc_name = get_service_name()
|
||||
svc_file = Path.home() / ".config" / "systemd" / "user" / f"{svc_name}.service"
|
||||
if svc_file.exists():
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "disable", svc_name],
|
||||
capture_output=True, check=False, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", svc_name],
|
||||
capture_output=True, check=False, timeout=10,
|
||||
)
|
||||
svc_file.unlink(missing_ok=True)
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
capture_output=True, check=False, timeout=10,
|
||||
)
|
||||
print(f"✓ Service {svc_name} removed")
|
||||
|
||||
elif _platform.system() == "Darwin":
|
||||
plist_path = get_launchd_plist_path()
|
||||
if plist_path.exists():
|
||||
subprocess.run(
|
||||
["launchctl", "unload", str(plist_path)],
|
||||
capture_output=True, check=False, timeout=10,
|
||||
)
|
||||
plist_path.unlink(missing_ok=True)
|
||||
print(f"✓ Launchd service removed")
|
||||
except Exception as e:
|
||||
print(f"⚠ Service cleanup: {e}")
|
||||
finally:
|
||||
if old_home is not None:
|
||||
os.environ["HERMES_HOME"] = old_home
|
||||
elif "HERMES_HOME" in os.environ:
|
||||
del os.environ["HERMES_HOME"]
|
||||
|
||||
|
||||
def _stop_gateway_process(profile_dir: Path) -> None:
|
||||
"""Stop a running gateway process via its PID file."""
|
||||
import signal as _signal
|
||||
import time as _time
|
||||
|
||||
pid_file = profile_dir / "gateway.pid"
|
||||
if not pid_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
raw = pid_file.read_text().strip()
|
||||
data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)}
|
||||
pid = int(data["pid"])
|
||||
os.kill(pid, _signal.SIGTERM)
|
||||
# Wait up to 10s for graceful shutdown
|
||||
for _ in range(20):
|
||||
_time.sleep(0.5)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
print(f"✓ Gateway stopped (PID {pid})")
|
||||
return
|
||||
# Force kill
|
||||
try:
|
||||
os.kill(pid, _signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
print(f"✓ Gateway force-stopped (PID {pid})")
|
||||
except (ProcessLookupError, PermissionError):
|
||||
print("✓ Gateway already stopped")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not stop gateway: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active profile (sticky default)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_active_profile() -> str:
|
||||
"""Read the sticky active profile name.
|
||||
|
||||
Returns ``"default"`` if no active_profile file exists or it's empty.
|
||||
"""
|
||||
path = _get_active_profile_path()
|
||||
try:
|
||||
name = path.read_text().strip()
|
||||
if not name:
|
||||
return "default"
|
||||
return name
|
||||
except (FileNotFoundError, UnicodeDecodeError, OSError):
|
||||
return "default"
|
||||
|
||||
|
||||
def set_active_profile(name: str) -> None:
|
||||
"""Set the sticky active profile.
|
||||
|
||||
Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear.
|
||||
"""
|
||||
validate_profile_name(name)
|
||||
if name != "default" and not profile_exists(name):
|
||||
raise FileNotFoundError(
|
||||
f"Profile '{name}' does not exist. "
|
||||
f"Create it with: hermes profile create {name}"
|
||||
)
|
||||
|
||||
path = _get_active_profile_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if name == "default":
|
||||
# Remove the file to indicate default
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
# Atomic write
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(name + "\n")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def get_active_profile_name() -> str:
|
||||
"""Infer the current profile name from HERMES_HOME.
|
||||
|
||||
Returns ``"default"`` if HERMES_HOME is not set or points to ``~/.hermes``.
|
||||
Returns the profile name if HERMES_HOME points into ``~/.hermes/profiles/<name>``.
|
||||
Returns ``"custom"`` if HERMES_HOME is set to an unrecognized path.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
hermes_home = get_hermes_home()
|
||||
resolved = hermes_home.resolve()
|
||||
|
||||
default_resolved = _get_default_hermes_home().resolve()
|
||||
if resolved == default_resolved:
|
||||
return "default"
|
||||
|
||||
profiles_root = _get_profiles_root().resolve()
|
||||
try:
|
||||
rel = resolved.relative_to(profiles_root)
|
||||
parts = rel.parts
|
||||
if len(parts) == 1 and _PROFILE_ID_RE.match(parts[0]):
|
||||
return parts[0]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return "custom"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export / Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def export_profile(name: str, output_path: str) -> Path:
|
||||
"""Export a profile to a tar.gz archive.
|
||||
|
||||
Returns the output file path.
|
||||
"""
|
||||
validate_profile_name(name)
|
||||
profile_dir = get_profile_dir(name)
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{name}' does not exist.")
|
||||
|
||||
output = Path(output_path)
|
||||
# shutil.make_archive wants the base name without extension
|
||||
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
|
||||
result = shutil.make_archive(base, "gztar", str(profile_dir.parent), name)
|
||||
return Path(result)
|
||||
|
||||
|
||||
def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"""Import a profile from a tar.gz archive.
|
||||
|
||||
If *name* is not given, infers it from the archive's top-level directory.
|
||||
Returns the imported profile directory.
|
||||
"""
|
||||
import tarfile
|
||||
|
||||
archive = Path(archive_path)
|
||||
if not archive.exists():
|
||||
raise FileNotFoundError(f"Archive not found: {archive}")
|
||||
|
||||
# Peek at the archive to find the top-level directory name
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
top_dirs = {m.name.split("/")[0] for m in tf.getmembers() if "/" in m.name}
|
||||
if not top_dirs:
|
||||
top_dirs = {m.name for m in tf.getmembers() if m.isdir()}
|
||||
|
||||
inferred_name = name or (top_dirs.pop() if len(top_dirs) == 1 else None)
|
||||
if not inferred_name:
|
||||
raise ValueError(
|
||||
"Cannot determine profile name from archive. "
|
||||
"Specify it explicitly: hermes profile import <archive> --name <name>"
|
||||
)
|
||||
|
||||
validate_profile_name(inferred_name)
|
||||
profile_dir = get_profile_dir(inferred_name)
|
||||
if profile_dir.exists():
|
||||
raise FileExistsError(f"Profile '{inferred_name}' already exists at {profile_dir}")
|
||||
|
||||
profiles_root = _get_profiles_root()
|
||||
profiles_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.unpack_archive(str(archive), str(profiles_root))
|
||||
|
||||
# If the archive extracted under a different name, rename
|
||||
extracted = profiles_root / (top_dirs.pop() if top_dirs else inferred_name)
|
||||
if extracted != profile_dir and extracted.exists():
|
||||
extracted.rename(profile_dir)
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
"""Rename a profile: directory, wrapper script, service, active_profile.
|
||||
|
||||
Returns the new profile directory.
|
||||
"""
|
||||
validate_profile_name(old_name)
|
||||
validate_profile_name(new_name)
|
||||
|
||||
if old_name == "default":
|
||||
raise ValueError("Cannot rename the default profile.")
|
||||
if new_name == "default":
|
||||
raise ValueError("Cannot rename to 'default' — it is reserved.")
|
||||
|
||||
old_dir = get_profile_dir(old_name)
|
||||
new_dir = get_profile_dir(new_name)
|
||||
|
||||
if not old_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{old_name}' does not exist.")
|
||||
if new_dir.exists():
|
||||
raise FileExistsError(f"Profile '{new_name}' already exists.")
|
||||
|
||||
# 1. Stop gateway if running
|
||||
if _check_gateway_running(old_dir):
|
||||
_cleanup_gateway_service(old_name, old_dir)
|
||||
_stop_gateway_process(old_dir)
|
||||
|
||||
# 2. Rename directory
|
||||
old_dir.rename(new_dir)
|
||||
print(f"✓ Renamed {old_dir.name} → {new_dir.name}")
|
||||
|
||||
# 3. Update wrapper script
|
||||
remove_wrapper_script(old_name)
|
||||
collision = check_alias_collision(new_name)
|
||||
if not collision:
|
||||
create_wrapper_script(new_name)
|
||||
print(f"✓ Alias updated: {new_name}")
|
||||
else:
|
||||
print(f"⚠ Cannot create alias '{new_name}' — {collision}")
|
||||
|
||||
# 4. Update active_profile if it pointed to old name
|
||||
try:
|
||||
if get_active_profile() == old_name:
|
||||
set_active_profile(new_name)
|
||||
print(f"✓ Active profile updated: {new_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return new_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tab completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_bash_completion() -> str:
|
||||
"""Generate a bash completion script for hermes profile names."""
|
||||
return '''# Hermes Agent profile completion
|
||||
# Add to ~/.bashrc: eval "$(hermes completion bash)"
|
||||
|
||||
_hermes_profiles() {
|
||||
local profiles_dir="$HOME/.hermes/profiles"
|
||||
local profiles="default"
|
||||
if [ -d "$profiles_dir" ]; then
|
||||
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
|
||||
fi
|
||||
echo "$profiles"
|
||||
}
|
||||
|
||||
_hermes_completion() {
|
||||
local cur prev
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# Complete profile names after -p / --profile
|
||||
if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# Complete profile subcommands
|
||||
if [[ "${COMP_WORDS[1]}" == "profile" ]]; then
|
||||
case "$prev" in
|
||||
profile)
|
||||
COMPREPLY=($(compgen -W "list use create delete show alias rename export import" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
use|delete|show|alias|rename|export)
|
||||
COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Top-level subcommands
|
||||
if [[ "$COMP_CWORD" == 1 ]]; then
|
||||
local commands="chat model gateway setup status cron doctor config skills tools mcp sessions profile update version"
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
fi
|
||||
}
|
||||
|
||||
complete -F _hermes_completion hermes
|
||||
'''
|
||||
|
||||
|
||||
def generate_zsh_completion() -> str:
|
||||
"""Generate a zsh completion script for hermes profile names."""
|
||||
return '''#compdef hermes
|
||||
# Hermes Agent profile completion
|
||||
# Add to ~/.zshrc: eval "$(hermes completion zsh)"
|
||||
|
||||
_hermes() {
|
||||
local -a profiles
|
||||
profiles=(default)
|
||||
if [[ -d "$HOME/.hermes/profiles" ]]; then
|
||||
profiles+=("${(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}")
|
||||
fi
|
||||
|
||||
_arguments \\
|
||||
'-p[Profile name]:profile:($profiles)' \\
|
||||
'--profile[Profile name]:profile:($profiles)' \\
|
||||
'1:command:(chat model gateway setup status cron doctor config skills tools mcp sessions profile update version)' \\
|
||||
'*::arg:->args'
|
||||
|
||||
case $words[1] in
|
||||
profile)
|
||||
_arguments '1:action:(list use create delete show alias rename export import)' \\
|
||||
'2:profile:($profiles)'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_hermes "$@"
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile env resolution (called from _apply_profile_override)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_profile_env(profile_name: str) -> str:
|
||||
"""Resolve a profile name to a HERMES_HOME path string.
|
||||
|
||||
Called early in the CLI entry point, before any hermes modules
|
||||
are imported, to set the HERMES_HOME environment variable.
|
||||
"""
|
||||
validate_profile_name(profile_name)
|
||||
profile_dir = get_profile_dir(profile_name)
|
||||
|
||||
if profile_name != "default" and not profile_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"Profile '{profile_name}' does not exist. "
|
||||
f"Create it with: hermes profile create {profile_name}"
|
||||
)
|
||||
|
||||
return str(profile_dir)
|
||||
@@ -63,11 +63,8 @@ def _get_model_config() -> Dict[str, Any]:
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
cfg = dict(model_cfg)
|
||||
# Accept "model" as alias for "default" (users intuitively write model.model)
|
||||
if not cfg.get("default") and cfg.get("model"):
|
||||
cfg["default"] = cfg["model"]
|
||||
default = (cfg.get("default") or "").strip()
|
||||
base_url = (cfg.get("base_url") or "").strip()
|
||||
default = cfg.get("default", "").strip()
|
||||
base_url = cfg.get("base_url", "").strip()
|
||||
is_local = "localhost" in base_url or "127.0.0.1" in base_url
|
||||
is_fallback = not default or default == "anthropic/claude-opus-4.6"
|
||||
if is_local and is_fallback and base_url:
|
||||
@@ -206,7 +203,7 @@ def _resolve_named_custom_runtime(
|
||||
or _detect_api_mode_for_url(base_url)
|
||||
or "chat_completions",
|
||||
"base_url": base_url,
|
||||
"api_key": api_key or "no-key-required",
|
||||
"api_key": api_key,
|
||||
"source": f"custom_provider:{custom_provider.get('name', requested_provider)}",
|
||||
}
|
||||
|
||||
@@ -410,6 +407,12 @@ def resolve_runtime_provider(
|
||||
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
|
||||
elif base_url.rstrip("/").endswith("/anthropic"):
|
||||
api_mode = "anthropic_messages"
|
||||
# MiniMax providers always use Anthropic Messages API.
|
||||
# Auto-correct stale /v1 URLs (from old .env or config) to /anthropic.
|
||||
elif provider in ("minimax", "minimax-cn"):
|
||||
api_mode = "anthropic_messages"
|
||||
if base_url.rstrip("/").endswith("/v1"):
|
||||
base_url = base_url.rstrip("/")[:-3] + "/anthropic"
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": api_mode,
|
||||
|
||||
+20
-76
@@ -80,11 +80,6 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
|
||||
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
|
||||
"kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
|
||||
"huggingface": [
|
||||
"Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-V3.2", "moonshotai/Kimi-K2.5",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -289,7 +284,6 @@ from hermes_cli.config import (
|
||||
get_env_value,
|
||||
ensure_hermes_home,
|
||||
)
|
||||
# display_hermes_home imported lazily at call sites (stale-module safety during hermes update)
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
@@ -586,11 +580,11 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
else:
|
||||
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
|
||||
|
||||
# Web tools (Exa, Parallel, Firecrawl, or Tavily)
|
||||
if get_env_value("EXA_API_KEY") or get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"):
|
||||
# Web tools (Parallel, Firecrawl, or Tavily)
|
||||
if get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"):
|
||||
tool_status.append(("Web Search & Extract", True, None))
|
||||
else:
|
||||
tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
|
||||
tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
|
||||
|
||||
# Browser tools (local Chromium or Browserbase cloud)
|
||||
import shutil
|
||||
@@ -684,8 +678,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
print_warning(
|
||||
"Some tools are disabled. Run 'hermes setup tools' to configure them,"
|
||||
)
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print_warning(f"or edit {_dhh()}/.env directly to add the missing API keys.")
|
||||
print_warning("or edit ~/.hermes/.env directly to add the missing API keys.")
|
||||
print()
|
||||
|
||||
# Done banner
|
||||
@@ -708,8 +701,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
print()
|
||||
|
||||
# Show file locations prominently
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print(color(f"📁 All your files are in {_dhh()}/:", Colors.CYAN, Colors.BOLD))
|
||||
print(color("📁 All your files are in ~/.hermes/:", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}")
|
||||
print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}")
|
||||
@@ -892,7 +884,6 @@ def setup_model_provider(config: dict):
|
||||
"OpenCode Go (open models, $10/month subscription)",
|
||||
"GitHub Copilot (uses GITHUB_TOKEN or gh auth token)",
|
||||
"GitHub Copilot ACP (spawns `copilot --acp --stdio`)",
|
||||
"Hugging Face Inference Providers (20+ open models)",
|
||||
]
|
||||
if keep_label:
|
||||
provider_choices.append(keep_label)
|
||||
@@ -1002,9 +993,10 @@ def setup_model_provider(config: dict):
|
||||
min_key_ttl_seconds=5 * 60,
|
||||
timeout_seconds=15.0,
|
||||
)
|
||||
# Use curated model list instead of full /models dump
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
nous_models = _PROVIDER_MODELS.get("nous", [])
|
||||
nous_models = fetch_nous_models(
|
||||
inference_base_url=creds.get("base_url", ""),
|
||||
api_key=creds.get("api_key", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch Nous models after login: %s", e)
|
||||
|
||||
@@ -1536,26 +1528,7 @@ def setup_model_provider(config: dict):
|
||||
_set_model_provider(config, "copilot-acp", pconfig.inference_base_url)
|
||||
selected_base_url = pconfig.inference_base_url
|
||||
|
||||
elif provider_idx == 16: # Hugging Face Inference Providers
|
||||
selected_provider = "huggingface"
|
||||
print()
|
||||
print_header("Hugging Face API Token")
|
||||
pconfig = PROVIDER_REGISTRY["huggingface"]
|
||||
print_info(f"Provider: {pconfig.name}")
|
||||
print_info("Get your token at: https://huggingface.co/settings/tokens")
|
||||
print_info("Required permission: 'Make calls to Inference Providers'")
|
||||
print()
|
||||
|
||||
api_key = prompt(" HF Token", password=True)
|
||||
if api_key:
|
||||
save_env_value("HF_TOKEN", api_key)
|
||||
# Clear OpenRouter env vars to prevent routing confusion
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_set_model_provider(config, "huggingface", pconfig.inference_base_url)
|
||||
selected_base_url = pconfig.inference_base_url
|
||||
|
||||
# else: provider_idx == 17 (Keep current) — only shown when a provider already exists
|
||||
# else: provider_idx == 16 (Keep current) — only shown when a provider already exists
|
||||
# Normalize "keep current" to an explicit provider so downstream logic
|
||||
# doesn't fall back to the generic OpenRouter/static-model path.
|
||||
if selected_provider is None:
|
||||
@@ -2094,11 +2067,11 @@ def setup_terminal_backend(config: dict):
|
||||
print_info("Serverless cloud sandboxes. Each session gets its own container.")
|
||||
print_info("Requires a Modal account: https://modal.com")
|
||||
|
||||
# Check if modal SDK is installed
|
||||
# Check if swe-rex[modal] is installed
|
||||
try:
|
||||
__import__("modal")
|
||||
__import__("swe_rex")
|
||||
except ImportError:
|
||||
print_info("Installing modal SDK...")
|
||||
print_info("Installing swe-rex[modal]...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
@@ -2110,22 +2083,22 @@ def setup_terminal_backend(config: dict):
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"modal",
|
||||
"swe-rex[modal]",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "modal"],
|
||||
[sys.executable, "-m", "pip", "install", "swe-rex[modal]"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success("modal SDK installed")
|
||||
print_success("swe-rex[modal] installed")
|
||||
else:
|
||||
print_warning(
|
||||
"Install failed — run manually: pip install modal"
|
||||
"Install failed — run manually: pip install 'swe-rex[modal]'"
|
||||
)
|
||||
|
||||
# Modal token
|
||||
@@ -2709,38 +2682,10 @@ def setup_gateway(config: dict):
|
||||
if token or get_env_value("MATRIX_PASSWORD"):
|
||||
# E2EE
|
||||
print()
|
||||
want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False)
|
||||
if want_e2ee:
|
||||
if prompt_yes_no("Enable end-to-end encryption (E2EE)?", False):
|
||||
save_env_value("MATRIX_ENCRYPTION", "true")
|
||||
print_success("E2EE enabled")
|
||||
|
||||
# Auto-install matrix-nio
|
||||
matrix_pkg = "matrix-nio[e2e]" if want_e2ee else "matrix-nio"
|
||||
try:
|
||||
__import__("nio")
|
||||
except ImportError:
|
||||
print_info(f"Installing {matrix_pkg}...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
print_warning(f"Install failed — run manually: pip install '{matrix_pkg}'")
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
print_info(" Requires: pip install 'matrix-nio[e2e]'")
|
||||
|
||||
# Allowed users
|
||||
print()
|
||||
@@ -2867,8 +2812,7 @@ def setup_gateway(config: dict):
|
||||
save_env_value("WEBHOOK_ENABLED", "true")
|
||||
print()
|
||||
print_success("Webhooks enabled! Next steps:")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print_info(f" 1. Define webhook routes in {_dhh()}/config.yaml")
|
||||
print_info(" 1. Define webhook routes in ~/.hermes/config.yaml")
|
||||
print_info(" 2. Point your service (GitHub, GitLab, etc.) at:")
|
||||
print_info(" http://your-server:8644/webhooks/<route-name>")
|
||||
print()
|
||||
|
||||
@@ -24,12 +24,6 @@ PLATFORMS = {
|
||||
"whatsapp": "📱 WhatsApp",
|
||||
"signal": "📡 Signal",
|
||||
"email": "📧 Email",
|
||||
"homeassistant": "🏠 Home Assistant",
|
||||
"mattermost": "💬 Mattermost",
|
||||
"matrix": "💬 Matrix",
|
||||
"dingtalk": "💬 DingTalk",
|
||||
"feishu": "🪽 Feishu",
|
||||
"wecom": "💬 WeCom",
|
||||
}
|
||||
|
||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
+28
-51
@@ -21,7 +21,6 @@ from rich.table import Table
|
||||
|
||||
# Lazy imports to avoid circular dependencies and slow startup.
|
||||
# tools.skills_hub and tools.skills_guard are imported inside functions.
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
_console = Console()
|
||||
|
||||
@@ -305,8 +304,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
console: Optional[Console] = None, skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
console: Optional[Console] = None, skip_confirm: bool = False) -> None:
|
||||
"""Fetch, quarantine, scan, confirm, and install a skill."""
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, ensure_hub_dirs,
|
||||
@@ -389,7 +387,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
"[bold bright_cyan]This is an official optional skill maintained by Nous Research.[/]\n\n"
|
||||
"It ships with hermes-agent but is not activated by default.\n"
|
||||
"Installing will copy it to your skills directory where the agent can use it.\n\n"
|
||||
f"Files will be at: [cyan]{display_hermes_home()}/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
f"Files will be at: [cyan]~/.hermes/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
title="Official Skill",
|
||||
border_style="bright_cyan",
|
||||
))
|
||||
@@ -399,7 +397,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
"External skills can contain instructions that influence agent behavior,\n"
|
||||
"shell commands, and scripts. Even after automated scanning, you should\n"
|
||||
"review the installed files before use.\n\n"
|
||||
f"Files will be at: [cyan]{display_hermes_home()}/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
f"Files will be at: [cyan]~/.hermes/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
title="Disclaimer",
|
||||
border_style="yellow",
|
||||
))
|
||||
@@ -419,16 +417,12 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
|
||||
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
||||
|
||||
if invalidate_cache:
|
||||
# Invalidate the skills prompt cache so the new skill appears immediately
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
c.print("[dim]Skill will be available in your next session.[/]")
|
||||
c.print("[dim]Use /reset to start a new session now, or --now to activate immediately (invalidates prompt cache).[/]\n")
|
||||
# Invalidate the skills prompt cache so the new skill appears immediately
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
|
||||
@@ -616,8 +610,7 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> N
|
||||
|
||||
|
||||
def do_uninstall(name: str, console: Optional[Console] = None,
|
||||
skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
skip_confirm: bool = False) -> None:
|
||||
"""Remove a hub-installed skill with confirmation."""
|
||||
from tools.skills_hub import uninstall_skill
|
||||
|
||||
@@ -637,15 +630,11 @@ def do_uninstall(name: str, console: Optional[Console] = None,
|
||||
success, msg = uninstall_skill(name)
|
||||
if success:
|
||||
c.print(f"[bold green]{msg}[/]\n")
|
||||
if invalidate_cache:
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
c.print("[dim]Change will take effect in your next session.[/]")
|
||||
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
c.print(f"[bold red]Error:[/] {msg}\n")
|
||||
|
||||
@@ -745,7 +734,7 @@ def do_publish(skill_path: str, target: str = "github", repo: str = "",
|
||||
auth = GitHubAuth()
|
||||
if not auth.is_authenticated():
|
||||
c.print("[bold red]Error:[/] GitHub authentication required.\n"
|
||||
f"Set GITHUB_TOKEN in {display_hermes_home()}/.env or run 'gh auth login'.\n")
|
||||
"Set GITHUB_TOKEN in ~/.hermes/.env or run 'gh auth login'.\n")
|
||||
return
|
||||
|
||||
c.print(f"[bold]Publishing '{name}' to {repo}...[/]")
|
||||
@@ -888,15 +877,10 @@ def do_snapshot_export(output_path: str, console: Optional[Console] = None) -> N
|
||||
"taps": tap_list,
|
||||
}
|
||||
|
||||
payload = json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n"
|
||||
if output_path == "-":
|
||||
import sys
|
||||
sys.stdout.write(payload)
|
||||
else:
|
||||
out = Path(output_path)
|
||||
out.write_text(payload)
|
||||
c.print(f"[bold green]Snapshot exported:[/] {out}")
|
||||
c.print(f"[dim]{len(installed)} skill(s), {len(tap_list)} tap(s)[/]\n")
|
||||
out = Path(output_path)
|
||||
out.write_text(json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n")
|
||||
c.print(f"[bold green]Snapshot exported:[/] {out}")
|
||||
c.print(f"[dim]{len(installed)} skill(s), {len(tap_list)} tap(s)[/]\n")
|
||||
|
||||
|
||||
def do_snapshot_import(input_path: str, force: bool = False,
|
||||
@@ -1087,23 +1071,19 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "install":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force] [--now]\n")
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force|--yes]\n")
|
||||
return
|
||||
identifier = args[0]
|
||||
category = ""
|
||||
# Slash commands run inside prompt_toolkit where input() hangs.
|
||||
# Always skip confirmation — the user typing the command is implicit consent.
|
||||
skip_confirm = True
|
||||
# --yes / -y bypasses confirmation prompt (needed in TUI mode)
|
||||
# --force handles reinstall override
|
||||
skip_confirm = any(flag in args for flag in ("--yes", "-y"))
|
||||
force = "--force" in args
|
||||
# --now invalidates prompt cache immediately (costs more money).
|
||||
# Default: defer to next session to preserve cache.
|
||||
invalidate_cache = "--now" in args
|
||||
for i, a in enumerate(args):
|
||||
if a == "--category" and i + 1 < len(args):
|
||||
category = args[i + 1]
|
||||
do_install(identifier, category=category, force=force,
|
||||
skip_confirm=skip_confirm, invalidate_cache=invalidate_cache,
|
||||
console=c)
|
||||
skip_confirm=skip_confirm, console=c)
|
||||
|
||||
elif action == "inspect":
|
||||
if not args:
|
||||
@@ -1133,13 +1113,10 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "uninstall":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills uninstall <name> [--now]\n")
|
||||
c.print("[bold red]Usage:[/] /skills uninstall <name> [--yes]\n")
|
||||
return
|
||||
# Slash commands run inside prompt_toolkit where input() hangs.
|
||||
skip_confirm = True
|
||||
invalidate_cache = "--now" in args
|
||||
do_uninstall(args[0], console=c, skip_confirm=skip_confirm,
|
||||
invalidate_cache=invalidate_cache)
|
||||
skip_confirm = any(flag in args for flag in ("--yes", "-y"))
|
||||
do_uninstall(args[0], console=c, skip_confirm=skip_confirm)
|
||||
|
||||
elif action == "publish":
|
||||
if not args:
|
||||
|
||||
@@ -254,9 +254,6 @@ def show_status(args):
|
||||
"Slack": ("SLACK_BOT_TOKEN", None),
|
||||
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
|
||||
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
|
||||
"DingTalk": ("DINGTALK_CLIENT_ID", None),
|
||||
"Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"),
|
||||
"WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
@@ -295,9 +292,8 @@ def show_status(args):
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
elif sys.platform == 'darwin':
|
||||
from hermes_cli.gateway import get_launchd_label
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
@@ -9,8 +9,6 @@ Saves per-platform tool configuration to ~/.hermes/config.yaml under
|
||||
the `platform_toolsets` key.
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
@@ -21,8 +19,6 @@ from hermes_cli.config import (
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
@@ -112,8 +108,7 @@ def _get_effective_configurable_toolsets():
|
||||
"""
|
||||
result = list(CONFIGURABLE_TOOLSETS)
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
|
||||
discover_plugins() # idempotent — ensures plugins are loaded
|
||||
from hermes_cli.plugins import get_plugin_toolsets
|
||||
result.extend(get_plugin_toolsets())
|
||||
except Exception:
|
||||
pass
|
||||
@@ -123,8 +118,7 @@ def _get_effective_configurable_toolsets():
|
||||
def _get_plugin_toolset_keys() -> set:
|
||||
"""Return the set of toolset keys provided by plugins."""
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
|
||||
discover_plugins() # idempotent — ensures plugins are loaded
|
||||
from hermes_cli.plugins import get_plugin_toolsets
|
||||
return {ts_key for ts_key, _, _ in get_plugin_toolsets()}
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -139,12 +133,8 @@ PLATFORMS = {
|
||||
"signal": {"label": "📡 Signal", "default_toolset": "hermes-signal"},
|
||||
"homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"},
|
||||
"email": {"label": "📧 Email", "default_toolset": "hermes-email"},
|
||||
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
|
||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||
"feishu": {"label": "🪽 Feishu", "default_toolset": "hermes-feishu"},
|
||||
"wecom": {"label": "💬 WeCom", "default_toolset": "hermes-wecom"},
|
||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
|
||||
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
|
||||
}
|
||||
|
||||
|
||||
@@ -196,14 +186,6 @@ TOOL_CATEGORIES = {
|
||||
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Exa",
|
||||
"tag": "AI-native search and contents",
|
||||
"web_backend": "exa",
|
||||
"env_vars": [
|
||||
{"key": "EXA_API_KEY", "prompt": "Exa API key", "url": "https://exa.ai"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Parallel",
|
||||
"tag": "AI-native search and extract",
|
||||
@@ -332,8 +314,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
if result.returncode == 0:
|
||||
_print_success(" Node.js dependencies installed")
|
||||
else:
|
||||
from hermes_constants import display_hermes_home
|
||||
_print_warning(f" npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install")
|
||||
_print_warning(" npm install failed - run manually: cd ~/.hermes/hermes-agent && npm install")
|
||||
elif not node_modules.exists():
|
||||
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
|
||||
|
||||
@@ -659,61 +640,9 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
|
||||
return default
|
||||
|
||||
|
||||
# ─── Token Estimation ────────────────────────────────────────────────────────
|
||||
|
||||
# Module-level cache so discovery + tokenization runs at most once per process.
|
||||
_tool_token_cache: Optional[Dict[str, int]] = None
|
||||
|
||||
|
||||
def _estimate_tool_tokens() -> Dict[str, int]:
|
||||
"""Return estimated token counts per individual tool name.
|
||||
|
||||
Uses tiktoken (cl100k_base) to count tokens in the JSON-serialised
|
||||
OpenAI-format tool schema. Triggers tool discovery on first call,
|
||||
then caches the result for the rest of the process.
|
||||
|
||||
Returns an empty dict when tiktoken or the registry is unavailable.
|
||||
"""
|
||||
global _tool_token_cache
|
||||
if _tool_token_cache is not None:
|
||||
return _tool_token_cache
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
except Exception:
|
||||
logger.debug("tiktoken unavailable; skipping tool token estimation")
|
||||
_tool_token_cache = {}
|
||||
return _tool_token_cache
|
||||
|
||||
try:
|
||||
# Trigger full tool discovery (imports all tool modules).
|
||||
import model_tools # noqa: F401
|
||||
from tools.registry import registry
|
||||
except Exception:
|
||||
logger.debug("Tool registry unavailable; skipping token estimation")
|
||||
_tool_token_cache = {}
|
||||
return _tool_token_cache
|
||||
|
||||
counts: Dict[str, int] = {}
|
||||
for name in registry.get_all_tool_names():
|
||||
schema = registry.get_schema(name)
|
||||
if schema:
|
||||
# Mirror what gets sent to the API:
|
||||
# {"type": "function", "function": <schema>}
|
||||
text = _json.dumps({"type": "function", "function": schema})
|
||||
counts[name] = len(enc.encode(text))
|
||||
_tool_token_cache = counts
|
||||
return _tool_token_cache
|
||||
|
||||
|
||||
def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]:
|
||||
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
# Pre-compute per-tool token counts (cached after first call).
|
||||
tool_tokens = _estimate_tool_tokens()
|
||||
|
||||
effective = _get_effective_configurable_toolsets()
|
||||
|
||||
@@ -729,27 +658,11 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str
|
||||
if ts_key in enabled
|
||||
}
|
||||
|
||||
# Build a live status function that shows deduplicated total token cost.
|
||||
status_fn = None
|
||||
if tool_tokens:
|
||||
ts_keys = [ts_key for ts_key, _, _ in effective]
|
||||
|
||||
def status_fn(chosen: set) -> str:
|
||||
# Collect unique tool names across all selected toolsets
|
||||
all_tools: set = set()
|
||||
for idx in chosen:
|
||||
all_tools.update(resolve_toolset(ts_keys[idx]))
|
||||
total = sum(tool_tokens.get(name, 0) for name in all_tools)
|
||||
if total >= 1000:
|
||||
return f"Est. tool context: ~{total / 1000:.1f}k tokens"
|
||||
return f"Est. tool context: ~{total} tokens"
|
||||
|
||||
chosen = curses_checklist(
|
||||
f"Tools for {platform_label}",
|
||||
labels,
|
||||
pre_selected,
|
||||
cancel_returns=pre_selected,
|
||||
status_fn=status_fn,
|
||||
)
|
||||
return {effective[i][0] for i in chosen}
|
||||
|
||||
@@ -1339,8 +1252,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
platform_choices[idx] = f"Configure {pinfo['label']} ({new_count}/{total} enabled)"
|
||||
|
||||
print()
|
||||
from hermes_constants import display_hermes_home
|
||||
print(color(f" Tool configuration saved to {display_hermes_home()}/config.yaml", Colors.DIM))
|
||||
print(color(" Tool configuration saved to ~/.hermes/config.yaml", Colors.DIM))
|
||||
print(color(" Changes take effect on next 'hermes' or gateway restart.", Colors.DIM))
|
||||
print()
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
"""
|
||||
Hermes Agent — Web UI server.
|
||||
|
||||
Provides a FastAPI backend serving the Vite/React frontend and REST API
|
||||
endpoints for managing configuration, environment variables, and sessions.
|
||||
|
||||
Usage:
|
||||
python -m hermes_cli.main web # Start on http://127.0.0.1:9119
|
||||
python -m hermes_cli.main web --port 8080
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_cli.config import (
|
||||
DEFAULT_CONFIG,
|
||||
OPTIONAL_ENV_VARS,
|
||||
get_config_path,
|
||||
get_env_path,
|
||||
get_hermes_home,
|
||||
load_config,
|
||||
load_env,
|
||||
save_config,
|
||||
save_env_value,
|
||||
delete_env_value,
|
||||
check_config_version,
|
||||
redact_key,
|
||||
)
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
"Run 'hermes web' to auto-install, or: pip install hermes-agent[web]"
|
||||
)
|
||||
|
||||
WEB_DIST = Path(__file__).parent / "web_dist"
|
||||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Default model for chat",
|
||||
"category": "general",
|
||||
},
|
||||
"provider": {
|
||||
"type": "select",
|
||||
"description": "LLM provider",
|
||||
"options": ["auto", "openrouter", "nous", "anthropic", "openai", "codex", "custom"],
|
||||
"category": "general",
|
||||
},
|
||||
"system_prompt": {
|
||||
"type": "text",
|
||||
"description": "System prompt prepended to every conversation",
|
||||
"category": "general",
|
||||
},
|
||||
"toolsets": {
|
||||
"type": "list",
|
||||
"description": "Enabled toolsets",
|
||||
"category": "general",
|
||||
},
|
||||
"agent.max_turns": {
|
||||
"type": "number",
|
||||
"description": "Maximum agent turns per conversation",
|
||||
"category": "agent",
|
||||
},
|
||||
"terminal.backend": {
|
||||
"type": "select",
|
||||
"description": "Terminal execution backend",
|
||||
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
|
||||
"category": "terminal",
|
||||
},
|
||||
"terminal.timeout": {
|
||||
"type": "number",
|
||||
"description": "Command timeout (seconds)",
|
||||
"category": "terminal",
|
||||
},
|
||||
"terminal.cwd": {
|
||||
"type": "string",
|
||||
"description": "Working directory for terminal commands",
|
||||
"category": "terminal",
|
||||
},
|
||||
"browser.inactivity_timeout": {
|
||||
"type": "number",
|
||||
"description": "Browser inactivity timeout (seconds)",
|
||||
"category": "browser",
|
||||
},
|
||||
"compression.enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable context compression",
|
||||
"category": "compression",
|
||||
},
|
||||
"compression.threshold": {
|
||||
"type": "number",
|
||||
"description": "Context window usage threshold to trigger compression (0-1)",
|
||||
"category": "compression",
|
||||
},
|
||||
"display.compact": {
|
||||
"type": "boolean",
|
||||
"description": "Compact display mode",
|
||||
"category": "display",
|
||||
},
|
||||
"display.personality": {
|
||||
"type": "select",
|
||||
"description": "Agent personality",
|
||||
"options": ["kawaii", "professional", "minimal", "hacker"],
|
||||
"category": "display",
|
||||
},
|
||||
"display.show_reasoning": {
|
||||
"type": "boolean",
|
||||
"description": "Show model reasoning/thinking",
|
||||
"category": "display",
|
||||
},
|
||||
"display.bell_on_complete": {
|
||||
"type": "boolean",
|
||||
"description": "Ring terminal bell when agent finishes",
|
||||
"category": "display",
|
||||
},
|
||||
"tts.provider": {
|
||||
"type": "select",
|
||||
"description": "Text-to-speech provider",
|
||||
"options": ["edge", "elevenlabs", "openai"],
|
||||
"category": "tts",
|
||||
},
|
||||
"checkpoints.enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable filesystem checkpoints before destructive ops",
|
||||
"category": "checkpoints",
|
||||
},
|
||||
"checkpoints.max_snapshots": {
|
||||
"type": "number",
|
||||
"description": "Max checkpoint snapshots per directory",
|
||||
"category": "checkpoints",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
class EnvVarUpdate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
class EnvVarDelete(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
gateway_pid = get_running_pid()
|
||||
gateway_running = gateway_pid is not None
|
||||
|
||||
gateway_state = None
|
||||
gateway_platforms: dict = {}
|
||||
gateway_exit_reason = None
|
||||
gateway_updated_at = None
|
||||
runtime = read_runtime_status()
|
||||
if runtime:
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
gateway_platforms = runtime.get("platforms") or {}
|
||||
gateway_exit_reason = runtime.get("exit_reason")
|
||||
gateway_updated_at = runtime.get("updated_at")
|
||||
if not gateway_running:
|
||||
gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped"
|
||||
|
||||
active_sessions = 0
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
sessions = db.list_sessions_rich(limit=50)
|
||||
now = time.time()
|
||||
active_sessions = sum(
|
||||
1 for s in sessions
|
||||
if s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()),
|
||||
"config_version": current_ver,
|
||||
"latest_config_version": latest_ver,
|
||||
"gateway_running": gateway_running,
|
||||
"gateway_pid": gateway_pid,
|
||||
"gateway_state": gateway_state,
|
||||
"gateway_platforms": gateway_platforms,
|
||||
"gateway_exit_reason": gateway_exit_reason,
|
||||
"gateway_updated_at": gateway_updated_at,
|
||||
"active_sessions": active_sessions,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def get_sessions():
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
sessions = db.list_sessions_rich(limit=20)
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
return sessions
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
return load_config()
|
||||
|
||||
|
||||
@app.get("/api/config/defaults")
|
||||
async def get_defaults():
|
||||
return DEFAULT_CONFIG
|
||||
|
||||
|
||||
@app.get("/api/config/schema")
|
||||
async def get_schema():
|
||||
return CONFIG_SCHEMA
|
||||
|
||||
|
||||
@app.put("/api/config")
|
||||
async def update_config(body: ConfigUpdate):
|
||||
try:
|
||||
save_config(body.config)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/env")
|
||||
async def get_env_vars():
|
||||
env_on_disk = load_env()
|
||||
result = {}
|
||||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||
value = env_on_disk.get(var_name)
|
||||
result[var_name] = {
|
||||
"is_set": bool(value),
|
||||
"redacted_value": redact_key(value) if value else None,
|
||||
"description": info.get("description", ""),
|
||||
"url": info.get("url"),
|
||||
"category": info.get("category", ""),
|
||||
"is_password": info.get("password", False),
|
||||
"tools": info.get("tools", []),
|
||||
"advanced": info.get("advanced", False),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/api/env")
|
||||
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:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.delete("/api/env")
|
||||
async def remove_env_var(body: EnvVarDelete):
|
||||
try:
|
||||
removed = delete_env_value(body.key)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
|
||||
return {"ok": True, "key": body.key}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing."""
|
||||
if not WEB_DIST.exists():
|
||||
@application.get("/{full_path:path}")
|
||||
async def no_frontend(full_path: str):
|
||||
return JSONResponse(
|
||||
{"error": "Frontend not built. Run: cd web && npm run build"},
|
||||
status_code=404,
|
||||
)
|
||||
return
|
||||
|
||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
@application.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = WEB_DIST / full_path
|
||||
if full_path and file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(WEB_DIST / "index.html")
|
||||
|
||||
|
||||
mount_spa(app)
|
||||
|
||||
|
||||
def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True):
|
||||
"""Start the web UI server."""
|
||||
import uvicorn
|
||||
|
||||
if open_browser:
|
||||
import threading
|
||||
import webbrowser
|
||||
|
||||
def _open():
|
||||
import time as _t
|
||||
_t.sleep(1.0)
|
||||
webbrowser.open(f"http://{host}:{port}")
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
print(f" Hermes Web UI → http://{host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||
@@ -1,260 +0,0 @@
|
||||
"""hermes webhook — manage dynamic webhook subscriptions from the CLI.
|
||||
|
||||
Usage:
|
||||
hermes webhook subscribe <name> [options]
|
||||
hermes webhook list
|
||||
hermes webhook remove <name>
|
||||
hermes webhook test <name> [--payload '{"key": "value"}']
|
||||
|
||||
Subscriptions persist to ~/.hermes/webhook_subscriptions.json and are
|
||||
hot-reloaded by the webhook adapter without a gateway restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
return Path(
|
||||
os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))
|
||||
).expanduser()
|
||||
|
||||
|
||||
def _subscriptions_path() -> Path:
|
||||
return _hermes_home() / _SUBSCRIPTIONS_FILENAME
|
||||
|
||||
|
||||
def _load_subscriptions() -> Dict[str, dict]:
|
||||
path = _subscriptions_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_subscriptions(subs: Dict[str, dict]) -> None:
|
||||
path = _subscriptions_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(".tmp")
|
||||
tmp_path.write_text(
|
||||
json.dumps(subs, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(str(tmp_path), str(path))
|
||||
|
||||
|
||||
def _get_webhook_config() -> dict:
|
||||
"""Load webhook platform config. Returns {} if not configured."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
return cfg.get("platforms", {}).get("webhook", {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _is_webhook_enabled() -> bool:
|
||||
return bool(_get_webhook_config().get("enabled"))
|
||||
|
||||
|
||||
def _get_webhook_base_url() -> str:
|
||||
wh = _get_webhook_config().get("extra", {})
|
||||
host = wh.get("host", "0.0.0.0")
|
||||
port = wh.get("port", 8644)
|
||||
display_host = "localhost" if host == "0.0.0.0" else host
|
||||
return f"http://{display_host}:{port}"
|
||||
|
||||
|
||||
def _setup_hint() -> str:
|
||||
_dhh = display_hermes_home()
|
||||
return f"""
|
||||
Webhook platform is not enabled. To set it up:
|
||||
|
||||
1. Run the gateway setup wizard:
|
||||
hermes gateway setup
|
||||
|
||||
2. Or manually add to {_dhh}/config.yaml:
|
||||
platforms:
|
||||
webhook:
|
||||
enabled: true
|
||||
extra:
|
||||
host: "0.0.0.0"
|
||||
port: 8644
|
||||
secret: "your-global-hmac-secret"
|
||||
|
||||
3. Or set environment variables in {_dhh}/.env:
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_PORT=8644
|
||||
WEBHOOK_SECRET=your-global-secret
|
||||
|
||||
Then start the gateway: hermes gateway run
|
||||
"""
|
||||
|
||||
|
||||
def _require_webhook_enabled() -> bool:
|
||||
"""Check webhook is enabled. Print setup guide and return False if not."""
|
||||
if _is_webhook_enabled():
|
||||
return True
|
||||
print(_setup_hint())
|
||||
return False
|
||||
|
||||
|
||||
def webhook_command(args):
|
||||
"""Entry point for 'hermes webhook' subcommand."""
|
||||
sub = getattr(args, "webhook_action", None)
|
||||
|
||||
if not sub:
|
||||
print("Usage: hermes webhook {subscribe|list|remove|test}")
|
||||
print("Run 'hermes webhook --help' for details.")
|
||||
return
|
||||
|
||||
if not _require_webhook_enabled():
|
||||
return
|
||||
|
||||
if sub in ("subscribe", "add"):
|
||||
_cmd_subscribe(args)
|
||||
elif sub in ("list", "ls"):
|
||||
_cmd_list(args)
|
||||
elif sub in ("remove", "rm"):
|
||||
_cmd_remove(args)
|
||||
elif sub == "test":
|
||||
_cmd_test(args)
|
||||
|
||||
|
||||
def _cmd_subscribe(args):
|
||||
name = args.name.strip().lower().replace(" ", "-")
|
||||
if not re.match(r'^[a-z0-9][a-z0-9_-]*$', name):
|
||||
print(f"Error: Invalid name '{name}'. Use lowercase alphanumeric with hyphens/underscores.")
|
||||
return
|
||||
|
||||
subs = _load_subscriptions()
|
||||
is_update = name in subs
|
||||
|
||||
secret = args.secret or secrets.token_urlsafe(32)
|
||||
events = [e.strip() for e in args.events.split(",")] if args.events else []
|
||||
|
||||
route = {
|
||||
"description": args.description or f"Agent-created subscription: {name}",
|
||||
"events": events,
|
||||
"secret": secret,
|
||||
"prompt": args.prompt or "",
|
||||
"skills": [s.strip() for s in args.skills.split(",")] if args.skills else [],
|
||||
"deliver": args.deliver or "log",
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
|
||||
if args.deliver_chat_id:
|
||||
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
|
||||
|
||||
subs[name] = route
|
||||
_save_subscriptions(subs)
|
||||
|
||||
base_url = _get_webhook_base_url()
|
||||
status = "Updated" if is_update else "Created"
|
||||
|
||||
print(f"\n {status} webhook subscription: {name}")
|
||||
print(f" URL: {base_url}/webhooks/{name}")
|
||||
print(f" Secret: {secret}")
|
||||
if events:
|
||||
print(f" Events: {', '.join(events)}")
|
||||
else:
|
||||
print(" Events: (all)")
|
||||
print(f" Deliver: {route['deliver']}")
|
||||
if route.get("prompt"):
|
||||
prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "")
|
||||
print(f" Prompt: {prompt_preview}")
|
||||
print(f"\n Configure your service to POST to the URL above.")
|
||||
print(f" Use the secret for HMAC-SHA256 signature validation.")
|
||||
print(f" The gateway must be running to receive events (hermes gateway run).\n")
|
||||
|
||||
|
||||
def _cmd_list(args):
|
||||
subs = _load_subscriptions()
|
||||
if not subs:
|
||||
print(" No dynamic webhook subscriptions.")
|
||||
print(" Create one with: hermes webhook subscribe <name>")
|
||||
return
|
||||
|
||||
base_url = _get_webhook_base_url()
|
||||
print(f"\n {len(subs)} webhook subscription(s):\n")
|
||||
for name, route in subs.items():
|
||||
events = ", ".join(route.get("events", [])) or "(all)"
|
||||
deliver = route.get("deliver", "log")
|
||||
desc = route.get("description", "")
|
||||
print(f" ◆ {name}")
|
||||
if desc:
|
||||
print(f" {desc}")
|
||||
print(f" URL: {base_url}/webhooks/{name}")
|
||||
print(f" Events: {events}")
|
||||
print(f" Deliver: {deliver}")
|
||||
print()
|
||||
|
||||
|
||||
def _cmd_remove(args):
|
||||
name = args.name.strip().lower()
|
||||
subs = _load_subscriptions()
|
||||
|
||||
if name not in subs:
|
||||
print(f" No subscription named '{name}'.")
|
||||
print(" Note: Static routes from config.yaml cannot be removed here.")
|
||||
return
|
||||
|
||||
del subs[name]
|
||||
_save_subscriptions(subs)
|
||||
print(f" Removed webhook subscription: {name}")
|
||||
|
||||
|
||||
def _cmd_test(args):
|
||||
"""Send a test POST to a webhook route."""
|
||||
name = args.name.strip().lower()
|
||||
subs = _load_subscriptions()
|
||||
|
||||
if name not in subs:
|
||||
print(f" No subscription named '{name}'.")
|
||||
return
|
||||
|
||||
route = subs[name]
|
||||
secret = route.get("secret", "")
|
||||
base_url = _get_webhook_base_url()
|
||||
url = f"{base_url}/webhooks/{name}"
|
||||
|
||||
payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}'
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
sig = "sha256=" + hmac.new(
|
||||
secret.encode(), payload.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
print(f" Sending test POST to {url}")
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload.encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": sig,
|
||||
"X-GitHub-Event": "test",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = resp.read().decode()
|
||||
print(f" Response ({resp.status}): {body}")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
print(" Is the gateway running? (hermes gateway run)")
|
||||
@@ -17,47 +17,6 @@ def get_hermes_home() -> Path:
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
|
||||
"""Resolve a Hermes subdirectory with backward compatibility.
|
||||
|
||||
New installs get the consolidated layout (e.g. ``cache/images``).
|
||||
Existing installs that already have the old path (e.g. ``image_cache``)
|
||||
keep using it — no migration required.
|
||||
|
||||
Args:
|
||||
new_subpath: Preferred path relative to HERMES_HOME (e.g. ``"cache/images"``).
|
||||
old_name: Legacy path relative to HERMES_HOME (e.g. ``"image_cache"``).
|
||||
|
||||
Returns:
|
||||
Absolute ``Path`` — old location if it exists on disk, otherwise the new one.
|
||||
"""
|
||||
home = get_hermes_home()
|
||||
old_path = home / old_name
|
||||
if old_path.exists():
|
||||
return old_path
|
||||
return home / new_subpath
|
||||
|
||||
|
||||
def display_hermes_home() -> str:
|
||||
"""Return a user-friendly display string for the current HERMES_HOME.
|
||||
|
||||
Uses ``~/`` shorthand for readability::
|
||||
|
||||
default: ``~/.hermes``
|
||||
profile: ``~/.hermes/profiles/coder``
|
||||
custom: ``/opt/hermes-custom``
|
||||
|
||||
Use this in **user-facing** print/log messages instead of hardcoding
|
||||
``~/.hermes``. For code that needs a real ``Path``, use
|
||||
:func:`get_hermes_home` instead.
|
||||
"""
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
return "~/" + str(home.relative_to(Path.home()))
|
||||
except ValueError:
|
||||
return str(home)
|
||||
|
||||
|
||||
VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal")
|
||||
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ def cmd_status(args) -> None:
|
||||
print(f" {peer}: {mode}")
|
||||
print(f" Write freq: {hcfg.write_frequency}")
|
||||
|
||||
if hcfg.enabled and (hcfg.api_key or hcfg.base_url):
|
||||
if hcfg.enabled and hcfg.api_key:
|
||||
print("\n Connection... ", end="", flush=True)
|
||||
try:
|
||||
get_honcho_client(hcfg)
|
||||
@@ -278,7 +278,7 @@ def cmd_status(args) -> None:
|
||||
except Exception as e:
|
||||
print(f"FAILED ({e})\n")
|
||||
else:
|
||||
reason = "disabled" if not hcfg.enabled else "no API key or base URL"
|
||||
reason = "disabled" if not hcfg.enabled else "no API key"
|
||||
print(f"\n Not connected ({reason})\n")
|
||||
|
||||
|
||||
|
||||
@@ -417,18 +417,9 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
else:
|
||||
logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
|
||||
|
||||
# Local Honcho instances don't require an API key, but the SDK
|
||||
# expects a non-empty string. Use a placeholder for local URLs.
|
||||
_is_local = resolved_base_url and (
|
||||
"localhost" in resolved_base_url
|
||||
or "127.0.0.1" in resolved_base_url
|
||||
or "::1" in resolved_base_url
|
||||
)
|
||||
effective_api_key = config.api_key or ("local" if _is_local else None)
|
||||
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
"api_key": config.api_key,
|
||||
"environment": config.environment,
|
||||
}
|
||||
if resolved_base_url:
|
||||
|
||||
-868
@@ -1,868 +0,0 @@
|
||||
"""
|
||||
Hermes MCP Server — expose messaging conversations as MCP tools.
|
||||
|
||||
Starts a stdio MCP server that lets any MCP client (Claude Code, Cursor, Codex,
|
||||
etc.) list conversations, read message history, send messages, poll for live
|
||||
events, and manage approval requests across all connected platforms.
|
||||
|
||||
Matches OpenClaw's 9-tool MCP channel bridge surface:
|
||||
conversations_list, conversation_get, messages_read, attachments_fetch,
|
||||
events_poll, events_wait, messages_send, permissions_list_open,
|
||||
permissions_respond
|
||||
|
||||
Plus: channels_list (Hermes-specific extra)
|
||||
|
||||
Usage:
|
||||
hermes mcp serve
|
||||
hermes mcp serve --verbose
|
||||
|
||||
MCP client config (e.g. claude_desktop_config.json):
|
||||
{
|
||||
"mcpServers": {
|
||||
"hermes": {
|
||||
"command": "hermes",
|
||||
"args": ["mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("hermes.mcp_serve")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy MCP SDK import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MCP_SERVER_AVAILABLE = False
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
_MCP_SERVER_AVAILABLE = True
|
||||
except ImportError:
|
||||
FastMCP = None # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_sessions_dir() -> Path:
|
||||
"""Return the sessions directory using HERMES_HOME."""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "sessions"
|
||||
except ImportError:
|
||||
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "sessions"
|
||||
|
||||
|
||||
def _get_session_db():
|
||||
"""Get a SessionDB instance for reading message transcripts."""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
return SessionDB()
|
||||
except Exception as e:
|
||||
logger.debug("SessionDB unavailable: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_sessions_index() -> dict:
|
||||
"""Load the gateway sessions.json index directly.
|
||||
|
||||
Returns a dict of session_key -> entry_dict with platform routing info.
|
||||
This avoids importing the full SessionStore which needs GatewayConfig.
|
||||
"""
|
||||
sessions_file = _get_sessions_dir() / "sessions.json"
|
||||
if not sessions_file.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(sessions_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load sessions.json: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _load_channel_directory() -> dict:
|
||||
"""Load the cached channel directory for available targets."""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
directory_file = get_hermes_home() / "channel_directory.json"
|
||||
except ImportError:
|
||||
directory_file = Path(
|
||||
os.environ.get("HERMES_HOME", Path.home() / ".hermes")
|
||||
) / "channel_directory.json"
|
||||
|
||||
if not directory_file.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(directory_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load channel_directory.json: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_message_content(msg: dict) -> str:
|
||||
"""Extract text content from a message, handling multi-part content."""
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
text_parts = [
|
||||
p.get("text", "") for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
return "\n".join(text_parts)
|
||||
return str(content) if content else ""
|
||||
|
||||
|
||||
def _extract_attachments(msg: dict) -> List[dict]:
|
||||
"""Extract non-text attachments from a message.
|
||||
|
||||
Finds: multi-part image/file content blocks, MEDIA: tags in text,
|
||||
image URLs, and file references.
|
||||
"""
|
||||
attachments = []
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Multi-part content blocks (image_url, file, etc.)
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
ptype = part.get("type", "")
|
||||
if ptype == "image_url":
|
||||
url = part.get("image_url", {}).get("url", "") if isinstance(part.get("image_url"), dict) else ""
|
||||
if url:
|
||||
attachments.append({"type": "image", "url": url})
|
||||
elif ptype == "image":
|
||||
url = part.get("url", part.get("source", {}).get("url", ""))
|
||||
if url:
|
||||
attachments.append({"type": "image", "url": url})
|
||||
elif ptype not in ("text",):
|
||||
# Unknown non-text content type
|
||||
attachments.append({"type": ptype, "data": part})
|
||||
|
||||
# MEDIA: tags in text content
|
||||
text = _extract_message_content(msg)
|
||||
if text:
|
||||
media_pattern = re.compile(r'MEDIA:\s*(\S+)')
|
||||
for match in media_pattern.finditer(text):
|
||||
path = match.group(1)
|
||||
attachments.append({"type": "media", "path": path})
|
||||
|
||||
return attachments
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event Bridge — polls SessionDB for new messages, maintains event queue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
QUEUE_LIMIT = 1000
|
||||
POLL_INTERVAL = 0.2 # seconds between DB polls (200ms)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueEvent:
|
||||
"""An event in the bridge's in-memory queue."""
|
||||
cursor: int
|
||||
type: str # "message", "approval_requested", "approval_resolved"
|
||||
session_key: str = ""
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class EventBridge:
|
||||
"""Background poller that watches SessionDB for new messages and
|
||||
maintains an in-memory event queue with waiter support.
|
||||
|
||||
This is the Hermes equivalent of OpenClaw's WebSocket gateway bridge.
|
||||
Instead of WebSocket events, we poll the SQLite database for changes.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._queue: List[QueueEvent] = []
|
||||
self._cursor = 0
|
||||
self._lock = threading.Lock()
|
||||
self._new_event = threading.Event()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._last_poll_timestamps: Dict[str, float] = {} # session_key -> unix timestamp
|
||||
# In-memory approval tracking (populated from events)
|
||||
self._pending_approvals: Dict[str, dict] = {}
|
||||
# mtime cache — skip expensive work when files haven't changed
|
||||
self._sessions_json_mtime: float = 0.0
|
||||
self._state_db_mtime: float = 0.0
|
||||
self._cached_sessions_index: dict = {}
|
||||
|
||||
def start(self):
|
||||
"""Start the background polling thread."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
logger.debug("EventBridge started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the background polling thread."""
|
||||
self._running = False
|
||||
self._new_event.set() # Wake any waiters
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
logger.debug("EventBridge stopped")
|
||||
|
||||
def poll_events(
|
||||
self,
|
||||
after_cursor: int = 0,
|
||||
session_key: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""Return events since after_cursor, optionally filtered by session_key."""
|
||||
with self._lock:
|
||||
events = [
|
||||
e for e in self._queue
|
||||
if e.cursor > after_cursor
|
||||
and (not session_key or e.session_key == session_key)
|
||||
][:limit]
|
||||
|
||||
next_cursor = events[-1].cursor if events else after_cursor
|
||||
return {
|
||||
"events": [
|
||||
{"cursor": e.cursor, "type": e.type,
|
||||
"session_key": e.session_key, **e.data}
|
||||
for e in events
|
||||
],
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
|
||||
def wait_for_event(
|
||||
self,
|
||||
after_cursor: int = 0,
|
||||
session_key: Optional[str] = None,
|
||||
timeout_ms: int = 30000,
|
||||
) -> Optional[dict]:
|
||||
"""Block until a matching event arrives or timeout expires."""
|
||||
deadline = time.monotonic() + (timeout_ms / 1000.0)
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
with self._lock:
|
||||
for e in self._queue:
|
||||
if e.cursor > after_cursor and (
|
||||
not session_key or e.session_key == session_key
|
||||
):
|
||||
return {
|
||||
"cursor": e.cursor, "type": e.type,
|
||||
"session_key": e.session_key, **e.data,
|
||||
}
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
self._new_event.clear()
|
||||
self._new_event.wait(timeout=min(remaining, POLL_INTERVAL))
|
||||
|
||||
return None
|
||||
|
||||
def list_pending_approvals(self) -> List[dict]:
|
||||
"""List approval requests observed during this bridge session."""
|
||||
with self._lock:
|
||||
return sorted(
|
||||
self._pending_approvals.values(),
|
||||
key=lambda a: a.get("created_at", ""),
|
||||
)
|
||||
|
||||
def respond_to_approval(self, approval_id: str, decision: str) -> dict:
|
||||
"""Resolve a pending approval (best-effort without gateway IPC)."""
|
||||
with self._lock:
|
||||
approval = self._pending_approvals.pop(approval_id, None)
|
||||
|
||||
if not approval:
|
||||
return {"error": f"Approval not found: {approval_id}"}
|
||||
|
||||
self._enqueue(QueueEvent(
|
||||
cursor=0, # Will be set by _enqueue
|
||||
type="approval_resolved",
|
||||
session_key=approval.get("session_key", ""),
|
||||
data={"approval_id": approval_id, "decision": decision},
|
||||
))
|
||||
|
||||
return {"resolved": True, "approval_id": approval_id, "decision": decision}
|
||||
|
||||
def _enqueue(self, event: QueueEvent) -> None:
|
||||
"""Add an event to the queue and wake any waiters."""
|
||||
with self._lock:
|
||||
self._cursor += 1
|
||||
event.cursor = self._cursor
|
||||
self._queue.append(event)
|
||||
# Trim queue to limit
|
||||
while len(self._queue) > QUEUE_LIMIT:
|
||||
self._queue.pop(0)
|
||||
self._new_event.set()
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Background loop: poll SessionDB for new messages."""
|
||||
db = _get_session_db()
|
||||
if not db:
|
||||
logger.warning("EventBridge: SessionDB unavailable, event polling disabled")
|
||||
return
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
self._poll_once(db)
|
||||
except Exception as e:
|
||||
logger.debug("EventBridge poll error: %s", e)
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
def _poll_once(self, db):
|
||||
"""Check for new messages across all sessions.
|
||||
|
||||
Uses mtime checks on sessions.json and state.db to skip work
|
||||
when nothing has changed — makes 200ms polling essentially free.
|
||||
"""
|
||||
# Check if sessions.json has changed (mtime check is ~1μs)
|
||||
sessions_file = _get_sessions_dir() / "sessions.json"
|
||||
try:
|
||||
sj_mtime = sessions_file.stat().st_mtime if sessions_file.exists() else 0.0
|
||||
except OSError:
|
||||
sj_mtime = 0.0
|
||||
|
||||
if sj_mtime != self._sessions_json_mtime:
|
||||
self._sessions_json_mtime = sj_mtime
|
||||
self._cached_sessions_index = _load_sessions_index()
|
||||
|
||||
# Check if state.db has changed
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
db_file = get_hermes_home() / "state.db"
|
||||
except ImportError:
|
||||
db_file = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
||||
|
||||
try:
|
||||
db_mtime = db_file.stat().st_mtime if db_file.exists() else 0.0
|
||||
except OSError:
|
||||
db_mtime = 0.0
|
||||
|
||||
if db_mtime == self._state_db_mtime and sj_mtime == self._sessions_json_mtime:
|
||||
return # Nothing changed since last poll — skip entirely
|
||||
|
||||
self._state_db_mtime = db_mtime
|
||||
entries = self._cached_sessions_index
|
||||
|
||||
for session_key, entry in entries.items():
|
||||
session_id = entry.get("session_id", "")
|
||||
if not session_id:
|
||||
continue
|
||||
|
||||
last_seen = self._last_poll_timestamps.get(session_key, 0.0)
|
||||
|
||||
try:
|
||||
messages = db.get_messages(session_id)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not messages:
|
||||
continue
|
||||
|
||||
# Normalize timestamps to float for comparison
|
||||
def _ts_float(ts) -> float:
|
||||
if isinstance(ts, (int, float)):
|
||||
return float(ts)
|
||||
if isinstance(ts, str) and ts:
|
||||
try:
|
||||
return float(ts)
|
||||
except ValueError:
|
||||
# ISO string — parse to epoch
|
||||
try:
|
||||
from datetime import datetime
|
||||
return datetime.fromisoformat(ts).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
# Find messages newer than our last seen timestamp
|
||||
new_messages = []
|
||||
for msg in messages:
|
||||
ts = _ts_float(msg.get("timestamp", 0))
|
||||
role = msg.get("role", "")
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
if ts > last_seen:
|
||||
new_messages.append(msg)
|
||||
|
||||
for msg in new_messages:
|
||||
content = _extract_message_content(msg)
|
||||
if not content:
|
||||
continue
|
||||
self._enqueue(QueueEvent(
|
||||
cursor=0,
|
||||
type="message",
|
||||
session_key=session_key,
|
||||
data={
|
||||
"role": msg.get("role", ""),
|
||||
"content": content[:500],
|
||||
"timestamp": str(msg.get("timestamp", "")),
|
||||
"message_id": str(msg.get("id", "")),
|
||||
},
|
||||
))
|
||||
|
||||
# Update last seen to the most recent message timestamp
|
||||
all_ts = [_ts_float(m.get("timestamp", 0)) for m in messages]
|
||||
if all_ts:
|
||||
latest = max(all_ts)
|
||||
if latest > last_seen:
|
||||
self._last_poll_timestamps[session_key] = latest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
"""Create and return the Hermes MCP server with all tools registered."""
|
||||
if not _MCP_SERVER_AVAILABLE:
|
||||
raise ImportError(
|
||||
"MCP server requires the 'mcp' package. "
|
||||
"Install with: pip install 'hermes-agent[mcp]'"
|
||||
)
|
||||
|
||||
mcp = FastMCP(
|
||||
"hermes",
|
||||
instructions=(
|
||||
"Hermes Agent messaging bridge. Use these tools to interact with "
|
||||
"conversations across Telegram, Discord, Slack, WhatsApp, Signal, "
|
||||
"Matrix, and other connected platforms."
|
||||
),
|
||||
)
|
||||
|
||||
bridge = event_bridge or EventBridge()
|
||||
|
||||
# -- conversations_list ------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def conversations_list(
|
||||
platform: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
) -> str:
|
||||
"""List active messaging conversations across connected platforms.
|
||||
|
||||
Returns conversations with their session keys (needed for messages_read),
|
||||
platform, chat type, display name, and last activity time.
|
||||
|
||||
Args:
|
||||
platform: Filter by platform name (telegram, discord, slack, etc.)
|
||||
limit: Maximum number of conversations to return (default 50)
|
||||
search: Optional text to filter conversations by name
|
||||
"""
|
||||
entries = _load_sessions_index()
|
||||
conversations = []
|
||||
|
||||
for key, entry in entries.items():
|
||||
origin = entry.get("origin", {})
|
||||
entry_platform = entry.get("platform") or origin.get("platform", "")
|
||||
|
||||
if platform and entry_platform.lower() != platform.lower():
|
||||
continue
|
||||
|
||||
display_name = entry.get("display_name", "")
|
||||
chat_name = origin.get("chat_name", "")
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
if (search_lower not in display_name.lower()
|
||||
and search_lower not in chat_name.lower()
|
||||
and search_lower not in key.lower()):
|
||||
continue
|
||||
|
||||
conversations.append({
|
||||
"session_key": key,
|
||||
"session_id": entry.get("session_id", ""),
|
||||
"platform": entry_platform,
|
||||
"chat_type": entry.get("chat_type", origin.get("chat_type", "")),
|
||||
"display_name": display_name,
|
||||
"chat_name": chat_name,
|
||||
"user_name": origin.get("user_name", ""),
|
||||
"updated_at": entry.get("updated_at", ""),
|
||||
})
|
||||
|
||||
conversations.sort(key=lambda c: c.get("updated_at", ""), reverse=True)
|
||||
conversations = conversations[:limit]
|
||||
|
||||
return json.dumps({
|
||||
"count": len(conversations),
|
||||
"conversations": conversations,
|
||||
}, indent=2)
|
||||
|
||||
# -- conversation_get --------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def conversation_get(session_key: str) -> str:
|
||||
"""Get detailed info about one conversation by its session key.
|
||||
|
||||
Args:
|
||||
session_key: The session key from conversations_list
|
||||
"""
|
||||
entries = _load_sessions_index()
|
||||
entry = entries.get(session_key)
|
||||
|
||||
if not entry:
|
||||
return json.dumps({"error": f"Conversation not found: {session_key}"})
|
||||
|
||||
origin = entry.get("origin", {})
|
||||
return json.dumps({
|
||||
"session_key": session_key,
|
||||
"session_id": entry.get("session_id", ""),
|
||||
"platform": entry.get("platform") or origin.get("platform", ""),
|
||||
"chat_type": entry.get("chat_type", origin.get("chat_type", "")),
|
||||
"display_name": entry.get("display_name", ""),
|
||||
"user_name": origin.get("user_name", ""),
|
||||
"chat_name": origin.get("chat_name", ""),
|
||||
"chat_id": origin.get("chat_id", ""),
|
||||
"thread_id": origin.get("thread_id"),
|
||||
"updated_at": entry.get("updated_at", ""),
|
||||
"created_at": entry.get("created_at", ""),
|
||||
"input_tokens": entry.get("input_tokens", 0),
|
||||
"output_tokens": entry.get("output_tokens", 0),
|
||||
"total_tokens": entry.get("total_tokens", 0),
|
||||
}, indent=2)
|
||||
|
||||
# -- messages_read -----------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def messages_read(
|
||||
session_key: str,
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
"""Read recent messages from a conversation.
|
||||
|
||||
Returns the message history in chronological order with role, content,
|
||||
and timestamp for each message.
|
||||
|
||||
Args:
|
||||
session_key: The session key from conversations_list
|
||||
limit: Maximum number of messages to return (default 50, most recent)
|
||||
"""
|
||||
entries = _load_sessions_index()
|
||||
entry = entries.get(session_key)
|
||||
if not entry:
|
||||
return json.dumps({"error": f"Conversation not found: {session_key}"})
|
||||
|
||||
session_id = entry.get("session_id", "")
|
||||
if not session_id:
|
||||
return json.dumps({"error": "No session ID for this conversation"})
|
||||
|
||||
db = _get_session_db()
|
||||
if not db:
|
||||
return json.dumps({"error": "Session database unavailable"})
|
||||
|
||||
try:
|
||||
all_messages = db.get_messages(session_id)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Failed to read messages: {e}"})
|
||||
|
||||
filtered = []
|
||||
for msg in all_messages:
|
||||
role = msg.get("role", "")
|
||||
if role in ("user", "assistant"):
|
||||
content = _extract_message_content(msg)
|
||||
if content:
|
||||
filtered.append({
|
||||
"id": str(msg.get("id", "")),
|
||||
"role": role,
|
||||
"content": content[:2000],
|
||||
"timestamp": msg.get("timestamp", ""),
|
||||
})
|
||||
|
||||
messages = filtered[-limit:]
|
||||
|
||||
return json.dumps({
|
||||
"session_key": session_key,
|
||||
"count": len(messages),
|
||||
"total_in_session": len(filtered),
|
||||
"messages": messages,
|
||||
}, indent=2)
|
||||
|
||||
# -- attachments_fetch -------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def attachments_fetch(
|
||||
session_key: str,
|
||||
message_id: str,
|
||||
) -> str:
|
||||
"""List non-text attachments for a message in a conversation.
|
||||
|
||||
Extracts images, media files, and other non-text content blocks
|
||||
from the specified message.
|
||||
|
||||
Args:
|
||||
session_key: The session key from conversations_list
|
||||
message_id: The message ID from messages_read
|
||||
"""
|
||||
entries = _load_sessions_index()
|
||||
entry = entries.get(session_key)
|
||||
if not entry:
|
||||
return json.dumps({"error": f"Conversation not found: {session_key}"})
|
||||
|
||||
session_id = entry.get("session_id", "")
|
||||
if not session_id:
|
||||
return json.dumps({"error": "No session ID for this conversation"})
|
||||
|
||||
db = _get_session_db()
|
||||
if not db:
|
||||
return json.dumps({"error": "Session database unavailable"})
|
||||
|
||||
try:
|
||||
all_messages = db.get_messages(session_id)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Failed to read messages: {e}"})
|
||||
|
||||
# Find the target message
|
||||
target_msg = None
|
||||
for msg in all_messages:
|
||||
if str(msg.get("id", "")) == message_id:
|
||||
target_msg = msg
|
||||
break
|
||||
|
||||
if not target_msg:
|
||||
return json.dumps({"error": f"Message not found: {message_id}"})
|
||||
|
||||
attachments = _extract_attachments(target_msg)
|
||||
|
||||
return json.dumps({
|
||||
"message_id": message_id,
|
||||
"count": len(attachments),
|
||||
"attachments": attachments,
|
||||
}, indent=2)
|
||||
|
||||
# -- events_poll -------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def events_poll(
|
||||
after_cursor: int = 0,
|
||||
session_key: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""Poll for new conversation events since a cursor position.
|
||||
|
||||
Returns events that have occurred since the given cursor. Use the
|
||||
returned next_cursor value for subsequent polls.
|
||||
|
||||
Event types: message, approval_requested, approval_resolved
|
||||
|
||||
Args:
|
||||
after_cursor: Return events after this cursor (0 for all)
|
||||
session_key: Optional filter to one conversation
|
||||
limit: Maximum events to return (default 20)
|
||||
"""
|
||||
result = bridge.poll_events(
|
||||
after_cursor=after_cursor,
|
||||
session_key=session_key,
|
||||
limit=limit,
|
||||
)
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# -- events_wait -------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def events_wait(
|
||||
after_cursor: int = 0,
|
||||
session_key: Optional[str] = None,
|
||||
timeout_ms: int = 30000,
|
||||
) -> str:
|
||||
"""Wait for the next conversation event (long-poll).
|
||||
|
||||
Blocks until a matching event arrives or the timeout expires.
|
||||
Use this for near-real-time event delivery without polling.
|
||||
|
||||
Args:
|
||||
after_cursor: Wait for events after this cursor
|
||||
session_key: Optional filter to one conversation
|
||||
timeout_ms: Maximum wait time in milliseconds (default 30000)
|
||||
"""
|
||||
event = bridge.wait_for_event(
|
||||
after_cursor=after_cursor,
|
||||
session_key=session_key,
|
||||
timeout_ms=min(timeout_ms, 300000), # Cap at 5 minutes
|
||||
)
|
||||
if event:
|
||||
return json.dumps({"event": event}, indent=2)
|
||||
return json.dumps({"event": None, "reason": "timeout"}, indent=2)
|
||||
|
||||
# -- messages_send -----------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def messages_send(
|
||||
target: str,
|
||||
message: str,
|
||||
) -> str:
|
||||
"""Send a message to a platform conversation.
|
||||
|
||||
The target format is "platform:chat_id" — same format used by the
|
||||
channels_list tool. You can also use human-friendly channel names
|
||||
that will be resolved automatically.
|
||||
|
||||
Examples:
|
||||
target="telegram:6308981865"
|
||||
target="discord:#general"
|
||||
target="slack:#engineering"
|
||||
|
||||
Args:
|
||||
target: Platform target in "platform:identifier" format
|
||||
message: The message text to send
|
||||
"""
|
||||
if not target or not message:
|
||||
return json.dumps({"error": "Both target and message are required"})
|
||||
|
||||
try:
|
||||
from tools.send_message_tool import send_message_tool
|
||||
result_str = send_message_tool(
|
||||
{"action": "send", "target": target, "message": message}
|
||||
)
|
||||
return result_str
|
||||
except ImportError:
|
||||
return json.dumps({"error": "Send message tool not available"})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Send failed: {e}"})
|
||||
|
||||
# -- channels_list -----------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def channels_list(platform: Optional[str] = None) -> str:
|
||||
"""List available messaging channels and targets across platforms.
|
||||
|
||||
Returns channels that you can send messages to. The target strings
|
||||
returned here can be used directly with the messages_send tool.
|
||||
|
||||
Args:
|
||||
platform: Filter by platform name (telegram, discord, slack, etc.)
|
||||
"""
|
||||
directory = _load_channel_directory()
|
||||
if not directory:
|
||||
entries = _load_sessions_index()
|
||||
targets = []
|
||||
seen = set()
|
||||
for key, entry in entries.items():
|
||||
origin = entry.get("origin", {})
|
||||
p = entry.get("platform") or origin.get("platform", "")
|
||||
chat_id = origin.get("chat_id", "")
|
||||
if not p or not chat_id:
|
||||
continue
|
||||
if platform and p.lower() != platform.lower():
|
||||
continue
|
||||
target_str = f"{p}:{chat_id}"
|
||||
if target_str in seen:
|
||||
continue
|
||||
seen.add(target_str)
|
||||
targets.append({
|
||||
"target": target_str,
|
||||
"platform": p,
|
||||
"name": entry.get("display_name") or origin.get("chat_name", ""),
|
||||
"chat_type": entry.get("chat_type", origin.get("chat_type", "")),
|
||||
})
|
||||
return json.dumps({"count": len(targets), "channels": targets}, indent=2)
|
||||
|
||||
channels = []
|
||||
for plat, entries_list in directory.items():
|
||||
if platform and plat.lower() != platform.lower():
|
||||
continue
|
||||
if isinstance(entries_list, list):
|
||||
for ch in entries_list:
|
||||
if isinstance(ch, dict):
|
||||
chat_id = ch.get("id", ch.get("chat_id", ""))
|
||||
channels.append({
|
||||
"target": f"{plat}:{chat_id}" if chat_id else plat,
|
||||
"platform": plat,
|
||||
"name": ch.get("name", ch.get("display_name", "")),
|
||||
"chat_type": ch.get("type", ""),
|
||||
})
|
||||
|
||||
return json.dumps({"count": len(channels), "channels": channels}, indent=2)
|
||||
|
||||
# -- permissions_list_open ---------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def permissions_list_open() -> str:
|
||||
"""List pending approval requests observed during this bridge session.
|
||||
|
||||
Returns exec and plugin approval requests that the bridge has seen
|
||||
since it started. Approvals are live-session only — older approvals
|
||||
from before the bridge connected are not included.
|
||||
"""
|
||||
approvals = bridge.list_pending_approvals()
|
||||
return json.dumps({
|
||||
"count": len(approvals),
|
||||
"approvals": approvals,
|
||||
}, indent=2)
|
||||
|
||||
# -- permissions_respond -----------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def permissions_respond(
|
||||
id: str,
|
||||
decision: str,
|
||||
) -> str:
|
||||
"""Respond to a pending approval request.
|
||||
|
||||
Args:
|
||||
id: The approval ID from permissions_list_open
|
||||
decision: One of "allow-once", "allow-always", or "deny"
|
||||
"""
|
||||
if decision not in ("allow-once", "allow-always", "deny"):
|
||||
return json.dumps({
|
||||
"error": f"Invalid decision: {decision}. "
|
||||
f"Must be allow-once, allow-always, or deny"
|
||||
})
|
||||
|
||||
result = bridge.respond_to_approval(id, decision)
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_mcp_server(verbose: bool = False) -> None:
|
||||
"""Start the Hermes MCP server on stdio."""
|
||||
if not _MCP_SERVER_AVAILABLE:
|
||||
print(
|
||||
"Error: MCP server requires the 'mcp' package.\n"
|
||||
"Install with: pip install 'hermes-agent[mcp]'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if verbose:
|
||||
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
|
||||
else:
|
||||
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
|
||||
|
||||
bridge = EventBridge()
|
||||
bridge.start()
|
||||
|
||||
server = create_mcp_server(event_bridge=bridge)
|
||||
|
||||
import asyncio
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await server.run_stdio_async()
|
||||
finally:
|
||||
bridge.stop()
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
except KeyboardInterrupt:
|
||||
bridge.stop()
|
||||
@@ -22,8 +22,6 @@ Public API (signatures preserved from the original 2,400-line version):
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
@@ -366,32 +364,6 @@ def get_tool_definitions(
|
||||
_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
|
||||
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
|
||||
|
||||
# Auto-reload .env: check file mtime at most every 5 seconds so new API keys
|
||||
# take effect without manual /reload or session restart.
|
||||
_env_last_check: float = 0.0
|
||||
_env_last_mtime: float = 0.0
|
||||
_ENV_CHECK_INTERVAL = 5.0
|
||||
|
||||
|
||||
def _maybe_reload_env() -> None:
|
||||
"""Stat ~/.hermes/.env and reload into os.environ if it changed."""
|
||||
global _env_last_check, _env_last_mtime
|
||||
now = time.monotonic()
|
||||
if now - _env_last_check < _ENV_CHECK_INTERVAL:
|
||||
return
|
||||
_env_last_check = now
|
||||
try:
|
||||
env_path = os.path.join(os.path.expanduser("~"), ".hermes", ".env")
|
||||
mtime = os.path.getmtime(env_path)
|
||||
if mtime != _env_last_mtime:
|
||||
_env_last_mtime = mtime
|
||||
from hermes_cli.config import reload_env
|
||||
reload_env()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def handle_function_call(
|
||||
function_name: str,
|
||||
@@ -418,8 +390,6 @@ def handle_function_call(
|
||||
Returns:
|
||||
Function result as a JSON string.
|
||||
"""
|
||||
_maybe_reload_env()
|
||||
|
||||
# Notify the read-loop tracker when a non-read/search tool runs,
|
||||
# so the *consecutive* counter resets (reads after other work are fine).
|
||||
if function_name not in _READ_SEARCH_TOOLS:
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
fi
|
||||
mkdir -p "$TARGET_HOME"
|
||||
chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME"
|
||||
chmod 0750 "$TARGET_HOME"
|
||||
|
||||
# Ensure HERMES_HOME is owned by the target user
|
||||
if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then
|
||||
@@ -552,8 +551,8 @@
|
||||
# ── Directories ───────────────────────────────────────────────────
|
||||
{
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir} 0755 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes 0755 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
@@ -567,23 +566,21 @@
|
||||
mkdir -p ${cfg.stateDir}/home
|
||||
mkdir -p ${cfg.workingDirectory}
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory}
|
||||
chmod 0750 ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory}
|
||||
|
||||
# Merge Nix settings into existing config.yaml.
|
||||
# Preserves user-added keys (skills, streaming, etc.); Nix keys win.
|
||||
# If configFile is user-provided (not generated), overwrite instead of merge.
|
||||
${if cfg.configFile != null then ''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0644 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
'' else ''
|
||||
${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml
|
||||
chmod 0640 ${cfg.stateDir}/.hermes/config.yaml
|
||||
chmod 0644 ${cfg.stateDir}/.hermes/config.yaml
|
||||
''}
|
||||
|
||||
# Managed mode marker (so interactive shells also detect NixOS management)
|
||||
touch ${cfg.stateDir}/.hermes/.managed
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed
|
||||
chmod 0644 ${cfg.stateDir}/.hermes/.managed
|
||||
|
||||
# Seed auth file if provided
|
||||
${lib.optionalString (cfg.authFile != null) ''
|
||||
@@ -615,7 +612,7 @@ HERMES_NIX_ENV_EOF
|
||||
|
||||
# Link documents into workspace
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: ''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
|
||||
'') cfg.documents)}
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Communication and decision-making frameworks — structured response formats for proposals, trade-off analysis, and stakeholder-ready recommendations.
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
name: one-three-one-rule
|
||||
description: >
|
||||
Structured decision-making framework for technical proposals and trade-off analysis.
|
||||
When the user faces a choice between multiple approaches (architecture decisions,
|
||||
tool selection, refactoring strategies, migration paths), this skill produces a
|
||||
1-3-1 format: one clear problem statement, three distinct options with pros/cons,
|
||||
and one concrete recommendation with definition of done and implementation plan.
|
||||
Use when the user asks for a "1-3-1", says "give me options", or needs help
|
||||
choosing between competing approaches.
|
||||
version: 1.0.0
|
||||
author: Willard Moore
|
||||
license: MIT
|
||||
category: communication
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [communication, decision-making, proposals, trade-offs]
|
||||
---
|
||||
|
||||
# 1-3-1 Communication Rule
|
||||
|
||||
Structured decision-making format for when a task has multiple viable approaches and the user needs a clear recommendation. Produces a concise problem framing, three options with trade-offs, and an actionable plan for the recommended path.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user explicitly asks for a "1-3-1" response.
|
||||
- The user says "give me options" or "what are my choices" for a technical decision.
|
||||
- A task has multiple viable approaches with meaningful trade-offs (architecture, tooling, migration strategy).
|
||||
- The user needs a proposal they can forward to a team or stakeholder.
|
||||
|
||||
Do NOT use for simple questions with one obvious answer, debugging sessions, or tasks where the user has already decided on an approach.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Problem** (one sentence)
|
||||
- State the core decision or desired outcome in a single concise sentence.
|
||||
- Focus on the *what*, not the *how* — no implementation details, tool names, or specific technologies.
|
||||
- Keep it tight. If you need "and", you're describing two problems.
|
||||
|
||||
2. **Options** (exactly three)
|
||||
- Present three distinct, viable approaches labeled A, B, C.
|
||||
- Each option gets a brief description, pros, and cons.
|
||||
- Options should represent genuinely different strategies, not minor variations of the same approach.
|
||||
|
||||
3. **Recommendation** (one option)
|
||||
- State which option you recommend and why, based on the user's context and priorities.
|
||||
- Be direct — this is your professional judgment, not a hedge.
|
||||
|
||||
4. **Definition of Done**
|
||||
- List the specific success criteria for the recommended option.
|
||||
- These are concrete, verifiable outcomes — not vague aspirations.
|
||||
- If the user picks a different option, revise this section to match.
|
||||
|
||||
5. **Implementation Plan**
|
||||
- Concrete steps to execute the recommended option.
|
||||
- Include specific commands, tools, or actions where applicable.
|
||||
- If the user picks a different option, revise this section to match.
|
||||
|
||||
## Verification
|
||||
|
||||
- Response has exactly one Problem sentence.
|
||||
- Response has exactly three Options (A, B, C) with pros and cons for each.
|
||||
- Response has a single Recommendation that picks one option with reasoning.
|
||||
- Definition of Done and Implementation Plan align with the recommended option.
|
||||
- If the user selects a different option, Recommendation, DoD, and Implementation Plan update accordingly.
|
||||
|
||||
## Example
|
||||
|
||||
User: "Give me a 1-3-1 for adding retry logic to our API client."
|
||||
|
||||
Agent:
|
||||
|
||||
**Problem:** API calls to external services fail intermittently under load and need graceful retry handling.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **Option A: Exponential backoff with jitter in a shared utility.**
|
||||
Pros: Simple, well-understood pattern; single implementation shared across all callers.
|
||||
Cons: No circuit-breaking; callers still block during retries.
|
||||
|
||||
- **Option B: Retry library (tenacity) as a decorator on each endpoint call.**
|
||||
Pros: Configurable per-endpoint; built-in support for circuit breakers and rate limits.
|
||||
Cons: Adds a dependency; decorator pattern can obscure control flow.
|
||||
|
||||
- **Option C: Queue-based async retry with dead letter handling.**
|
||||
Pros: Non-blocking; failed calls are tracked and replayable; scales well.
|
||||
Cons: Significant complexity increase; requires a queue backend; overkill for low-volume services.
|
||||
|
||||
**Recommendation:** Option A. The call volume doesn't justify queue infrastructure, and a shared utility with exponential backoff + jitter covers the failure mode cleanly without new dependencies.
|
||||
|
||||
**Definition of Done:**
|
||||
- `api_retry()` utility handles transient HTTP errors (429, 502, 503, 504).
|
||||
- Retries up to 3 times with exponential backoff and jitter.
|
||||
- All external API calls route through the retry utility.
|
||||
- Logs each retry attempt with endpoint and attempt number.
|
||||
- Tests cover retry exhaustion, immediate success, and transient-then-success scenarios.
|
||||
|
||||
**Implementation Plan:**
|
||||
1. Create `utils/api_retry.py` with configurable max retries, base delay, and retryable status codes.
|
||||
2. Add jitter using `random.uniform(0, base_delay)` to prevent thundering herd.
|
||||
3. Wrap existing API calls in `api_client.py` with the retry utility.
|
||||
4. Add unit tests mocking HTTP responses for each retry scenario.
|
||||
5. Verify under load with a simple stress test against a flaky endpoint mock.
|
||||
@@ -304,29 +304,6 @@ def ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Optional[str]:
|
||||
"""Resolve an OpenClaw SecretInput value to a plain string.
|
||||
|
||||
SecretInput can be:
|
||||
- A plain string: "sk-..."
|
||||
- An env template: "${OPENROUTER_API_KEY}"
|
||||
- A SecretRef object: {"source": "env", "id": "OPENROUTER_API_KEY"}
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
# Check for env template: "${VAR_NAME}"
|
||||
m = re.match(r"^\$\{(\w+)\}$", value.strip())
|
||||
if m and env:
|
||||
return env.get(m.group(1), "").strip() or None
|
||||
return value.strip() or None
|
||||
if isinstance(value, dict):
|
||||
source = value.get("source", "")
|
||||
ref_id = value.get("id", "")
|
||||
if source == "env" and ref_id and env:
|
||||
return env.get(ref_id, "").strip() or None
|
||||
# File/exec sources can't be resolved here — return None
|
||||
return None
|
||||
|
||||
|
||||
def load_yaml_file(path: Path) -> Dict[str, Any]:
|
||||
if yaml is None or not path.exists():
|
||||
return {}
|
||||
@@ -913,20 +890,14 @@ class Migrator:
|
||||
self.record("command-allowlist", source, destination, "migrated", "Would merge patterns", added_patterns=added)
|
||||
|
||||
def load_openclaw_config(self) -> Dict[str, Any]:
|
||||
# Check current name and legacy config filenames
|
||||
for name in ("openclaw.json", "clawdbot.json", "moldbot.json"):
|
||||
config_path = self.source_root / name
|
||||
if config_path.exists():
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return {}
|
||||
|
||||
def load_openclaw_env(self) -> Dict[str, str]:
|
||||
"""Load the OpenClaw .env file for secrets that live there instead of config."""
|
||||
return parse_env_file(self.source_root / ".env")
|
||||
config_path = self.source_root / "openclaw.json"
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
def merge_env_values(self, additions: Dict[str, str], kind: str, source: Path) -> None:
|
||||
destination = self.target_root / ".env"
|
||||
@@ -1053,10 +1024,6 @@ class Migrator:
|
||||
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
|
||||
)
|
||||
|
||||
def _resolve_channel_secret(self, value: Any) -> Optional[str]:
|
||||
"""Resolve a channel config value that may be a SecretRef."""
|
||||
return resolve_secret_input(value, self.load_openclaw_env())
|
||||
|
||||
def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
additions: Dict[str, str] = {}
|
||||
@@ -1151,17 +1118,15 @@ class Migrator:
|
||||
secret_additions: Dict[str, str] = {}
|
||||
|
||||
# Extract provider API keys from models.providers
|
||||
# Note: apiKey values can be strings, env templates, or SecretRef objects
|
||||
openclaw_env = self.load_openclaw_env()
|
||||
providers = config.get("models", {}).get("providers", {})
|
||||
if isinstance(providers, dict):
|
||||
for provider_name, provider_cfg in providers.items():
|
||||
if not isinstance(provider_cfg, dict):
|
||||
continue
|
||||
raw_key = provider_cfg.get("apiKey")
|
||||
api_key = resolve_secret_input(raw_key, openclaw_env)
|
||||
if not api_key:
|
||||
api_key = provider_cfg.get("apiKey")
|
||||
if not isinstance(api_key, str) or not api_key.strip():
|
||||
continue
|
||||
api_key = api_key.strip()
|
||||
|
||||
base_url = provider_cfg.get("baseUrl", "")
|
||||
api_type = provider_cfg.get("api", "")
|
||||
@@ -1205,50 +1170,6 @@ class Migrator:
|
||||
if isinstance(oai_key, str) and oai_key.strip():
|
||||
secret_additions["VOICE_TOOLS_OPENAI_KEY"] = oai_key.strip()
|
||||
|
||||
# Also check the OpenClaw .env file — many users store keys there
|
||||
# instead of inline in openclaw.json
|
||||
openclaw_env = self.load_openclaw_env()
|
||||
env_key_mapping = {
|
||||
"OPENROUTER_API_KEY": "OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY": "OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY",
|
||||
"ELEVENLABS_API_KEY": "ELEVENLABS_API_KEY",
|
||||
"TELEGRAM_BOT_TOKEN": "TELEGRAM_BOT_TOKEN",
|
||||
"DEEPSEEK_API_KEY": "DEEPSEEK_API_KEY",
|
||||
"GEMINI_API_KEY": "GEMINI_API_KEY",
|
||||
"ZAI_API_KEY": "ZAI_API_KEY",
|
||||
"MINIMAX_API_KEY": "MINIMAX_API_KEY",
|
||||
}
|
||||
for oc_key, hermes_key in env_key_mapping.items():
|
||||
val = openclaw_env.get(oc_key, "").strip()
|
||||
if val and hermes_key not in secret_additions:
|
||||
secret_additions[hermes_key] = val
|
||||
|
||||
# Check per-agent auth-profiles.json for additional credentials
|
||||
auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json"
|
||||
if auth_profiles_path.exists():
|
||||
try:
|
||||
profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8"))
|
||||
if isinstance(profiles, dict):
|
||||
# auth-profiles.json wraps profiles in a "profiles" key
|
||||
profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles
|
||||
for profile_name, profile_data in profile_entries.items():
|
||||
if not isinstance(profile_data, dict):
|
||||
continue
|
||||
# Canonical field is "key", "apiKey" is accepted as alias
|
||||
api_key = profile_data.get("key", "") or profile_data.get("apiKey", "")
|
||||
if not isinstance(api_key, str) or not api_key.strip():
|
||||
continue
|
||||
name_lower = profile_name.lower()
|
||||
if "openrouter" in name_lower and "OPENROUTER_API_KEY" not in secret_additions:
|
||||
secret_additions["OPENROUTER_API_KEY"] = api_key.strip()
|
||||
elif "openai" in name_lower and "OPENAI_API_KEY" not in secret_additions:
|
||||
secret_additions["OPENAI_API_KEY"] = api_key.strip()
|
||||
elif "anthropic" in name_lower and "ANTHROPIC_API_KEY" not in secret_additions:
|
||||
secret_additions["ANTHROPIC_API_KEY"] = api_key.strip()
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
if secret_additions:
|
||||
self.merge_env_values(secret_additions, "provider-keys", self.source_root / "openclaw.json")
|
||||
else:
|
||||
@@ -1297,11 +1218,7 @@ class Migrator:
|
||||
|
||||
if self.execute:
|
||||
backup_path = self.maybe_backup(destination)
|
||||
existing_model = hermes_config.get("model")
|
||||
if isinstance(existing_model, dict):
|
||||
existing_model["default"] = model_str
|
||||
else:
|
||||
hermes_config["model"] = {"default": model_str}
|
||||
hermes_config["model"] = model_str
|
||||
dump_yaml_file(destination, hermes_config)
|
||||
self.record("model-config", source_path, destination, "migrated", backup=str(backup_path) if backup_path else "", model=model_str)
|
||||
else:
|
||||
@@ -1327,44 +1244,22 @@ class Migrator:
|
||||
if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge"):
|
||||
tts_data["provider"] = provider
|
||||
|
||||
# TTS provider settings live under messages.tts.providers.{provider}
|
||||
# in OpenClaw (not messages.tts.elevenlabs directly)
|
||||
providers = tts.get("providers") or {}
|
||||
|
||||
# Also check the top-level "talk" config which has provider settings too
|
||||
talk_cfg = (config or self.load_openclaw_config()).get("talk") or {}
|
||||
talk_providers = talk_cfg.get("providers") or {}
|
||||
|
||||
# Merge: messages.tts.providers takes priority, then talk.providers,
|
||||
# then legacy flat keys (messages.tts.elevenlabs, etc.)
|
||||
elevenlabs = (
|
||||
(providers.get("elevenlabs") or {})
|
||||
if isinstance(providers.get("elevenlabs"), dict) else
|
||||
(talk_providers.get("elevenlabs") or {})
|
||||
if isinstance(talk_providers.get("elevenlabs"), dict) else
|
||||
(tts.get("elevenlabs") or {})
|
||||
)
|
||||
elevenlabs = tts.get("elevenlabs", {})
|
||||
if isinstance(elevenlabs, dict):
|
||||
el_settings: Dict[str, str] = {}
|
||||
voice_id = elevenlabs.get("voiceId") or talk_cfg.get("voiceId")
|
||||
voice_id = elevenlabs.get("voiceId")
|
||||
if isinstance(voice_id, str) and voice_id.strip():
|
||||
el_settings["voice_id"] = voice_id.strip()
|
||||
model_id = elevenlabs.get("modelId") or talk_cfg.get("modelId")
|
||||
model_id = elevenlabs.get("modelId")
|
||||
if isinstance(model_id, str) and model_id.strip():
|
||||
el_settings["model_id"] = model_id.strip()
|
||||
if el_settings:
|
||||
tts_data["elevenlabs"] = el_settings
|
||||
|
||||
openai_tts = (
|
||||
(providers.get("openai") or {})
|
||||
if isinstance(providers.get("openai"), dict) else
|
||||
(talk_providers.get("openai") or {})
|
||||
if isinstance(talk_providers.get("openai"), dict) else
|
||||
(tts.get("openai") or {})
|
||||
)
|
||||
openai_tts = tts.get("openai", {})
|
||||
if isinstance(openai_tts, dict):
|
||||
oai_settings: Dict[str, str] = {}
|
||||
oai_model = openai_tts.get("model") or openai_tts.get("modelId")
|
||||
oai_model = openai_tts.get("model")
|
||||
if isinstance(oai_model, str) and oai_model.strip():
|
||||
oai_settings["model"] = oai_model.strip()
|
||||
oai_voice = openai_tts.get("voice")
|
||||
@@ -1373,11 +1268,7 @@ class Migrator:
|
||||
if oai_settings:
|
||||
tts_data["openai"] = oai_settings
|
||||
|
||||
edge_tts = (
|
||||
(providers.get("edge") or {})
|
||||
if isinstance(providers.get("edge"), dict) else
|
||||
(tts.get("edge") or {})
|
||||
)
|
||||
edge_tts = tts.get("edge", {})
|
||||
if isinstance(edge_tts, dict):
|
||||
edge_voice = edge_tts.get("voice")
|
||||
if isinstance(edge_voice, str) and edge_voice.strip():
|
||||
@@ -1407,29 +1298,15 @@ class Migrator:
|
||||
self.record("tts-config", source_path, destination, "migrated", "Would set TTS config", settings=list(tts_data.keys()))
|
||||
|
||||
def migrate_shared_skills(self) -> None:
|
||||
# Check all OpenClaw skill sources: managed, personal, project-level
|
||||
skill_sources = [
|
||||
(self.source_root / "skills", "shared-skills", "managed skills"),
|
||||
(Path.home() / ".agents" / "skills", "personal-skills", "personal cross-project skills"),
|
||||
(self.source_root / "workspace" / ".agents" / "skills", "project-skills", "project-level shared skills"),
|
||||
(self.source_root / "workspace.default" / ".agents" / "skills", "project-skills", "project-level shared skills"),
|
||||
]
|
||||
found_any = False
|
||||
for source_root, kind_label, desc in skill_sources:
|
||||
if source_root.exists():
|
||||
found_any = True
|
||||
self._import_skill_directory(source_root, kind_label, desc)
|
||||
if not found_any:
|
||||
destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME
|
||||
self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directories found")
|
||||
|
||||
def _import_skill_directory(self, source_root: Path, kind_label: str, desc: str) -> None:
|
||||
"""Import skills from a single source directory into openclaw-imports."""
|
||||
source_root = self.source_root / "skills"
|
||||
destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME
|
||||
if not source_root.exists():
|
||||
self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directory found")
|
||||
return
|
||||
|
||||
skill_dirs = [p for p in sorted(source_root.iterdir()) if p.is_dir() and (p / "SKILL.md").exists()]
|
||||
if not skill_dirs:
|
||||
self.record(kind_label, source_root, destination_root, "skipped", f"No skills with SKILL.md found in {desc}")
|
||||
self.record("shared-skills", source_root, destination_root, "skipped", "No shared skills with SKILL.md found")
|
||||
return
|
||||
|
||||
for skill_dir in skill_dirs:
|
||||
@@ -1437,7 +1314,7 @@ class Migrator:
|
||||
final_destination = destination
|
||||
if destination.exists():
|
||||
if self.skill_conflict_mode == "skip":
|
||||
self.record(kind_label, skill_dir, destination, "conflict", "Destination skill already exists")
|
||||
self.record("shared-skill", skill_dir, destination, "conflict", "Destination skill already exists")
|
||||
continue
|
||||
if self.skill_conflict_mode == "rename":
|
||||
final_destination = self.resolve_skill_destination(destination)
|
||||
@@ -1452,19 +1329,19 @@ class Migrator:
|
||||
details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""}
|
||||
if final_destination != destination:
|
||||
details["renamed_from"] = str(destination)
|
||||
self.record(kind_label, skill_dir, final_destination, "migrated", **details)
|
||||
self.record("shared-skill", skill_dir, final_destination, "migrated", **details)
|
||||
else:
|
||||
if final_destination != destination:
|
||||
self.record(
|
||||
kind_label,
|
||||
"shared-skill",
|
||||
skill_dir,
|
||||
final_destination,
|
||||
"migrated",
|
||||
f"Would copy {desc} directory under a renamed folder",
|
||||
"Would copy shared skill directory under a renamed folder",
|
||||
renamed_from=str(destination),
|
||||
)
|
||||
else:
|
||||
self.record(kind_label, skill_dir, final_destination, "migrated", f"Would copy {desc} directory")
|
||||
self.record("shared-skill", skill_dir, final_destination, "migrated", "Would copy shared skill directory")
|
||||
|
||||
desc_path = destination_root / "DESCRIPTION.md"
|
||||
if self.execute:
|
||||
@@ -1641,7 +1518,6 @@ class Migrator:
|
||||
self.source_candidate("workspace/IDENTITY.md", "workspace.default/IDENTITY.md"),
|
||||
self.source_candidate("workspace/TOOLS.md", "workspace.default/TOOLS.md"),
|
||||
self.source_candidate("workspace/HEARTBEAT.md", "workspace.default/HEARTBEAT.md"),
|
||||
self.source_candidate("workspace/BOOTSTRAP.md", "workspace.default/BOOTSTRAP.md"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate:
|
||||
@@ -1913,9 +1789,8 @@ class Migrator:
|
||||
human_delay = defaults.get("humanDelay") or {}
|
||||
if human_delay:
|
||||
hd = hermes_cfg.get("human_delay") or {}
|
||||
hd_mode = human_delay.get("mode") or ("natural" if human_delay.get("enabled") else None)
|
||||
if hd_mode and hd_mode != "off":
|
||||
hd["mode"] = hd_mode
|
||||
if human_delay.get("enabled"):
|
||||
hd["mode"] = "natural"
|
||||
if human_delay.get("minMs"):
|
||||
hd["min_ms"] = human_delay["minMs"]
|
||||
if human_delay.get("maxMs"):
|
||||
@@ -1929,11 +1804,11 @@ class Migrator:
|
||||
changes = True
|
||||
|
||||
# Map terminal/exec settings
|
||||
exec_cfg = (config.get("tools") or {}).get("exec") or {}
|
||||
exec_cfg = defaults.get("exec") or (config.get("tools") or {}).get("exec") or {}
|
||||
if exec_cfg:
|
||||
terminal_cfg = hermes_cfg.get("terminal") or {}
|
||||
if exec_cfg.get("timeoutSec") or exec_cfg.get("timeout"):
|
||||
terminal_cfg["timeout"] = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")
|
||||
if exec_cfg.get("timeout"):
|
||||
terminal_cfg["timeout"] = exec_cfg["timeout"]
|
||||
changes = True
|
||||
hermes_cfg["terminal"] = terminal_cfg
|
||||
|
||||
@@ -2008,34 +1883,24 @@ class Migrator:
|
||||
sr = hermes_cfg.get("session_reset") or {}
|
||||
changes = False
|
||||
|
||||
# OpenClaw uses session.reset (structured) and session.resetTriggers (string array)
|
||||
reset = session.get("reset") or {}
|
||||
reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or []
|
||||
reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or {}
|
||||
if reset_triggers:
|
||||
daily = reset_triggers.get("daily") or {}
|
||||
idle = reset_triggers.get("idle") or {}
|
||||
|
||||
if reset:
|
||||
# Structured reset config: has mode, atHour, idleMinutes
|
||||
mode = reset.get("mode", "")
|
||||
if mode == "daily":
|
||||
if daily.get("enabled") and idle.get("enabled"):
|
||||
sr["mode"] = "both"
|
||||
elif daily.get("enabled"):
|
||||
sr["mode"] = "daily"
|
||||
elif mode == "idle":
|
||||
elif idle.get("enabled"):
|
||||
sr["mode"] = "idle"
|
||||
else:
|
||||
sr["mode"] = mode or "none"
|
||||
if reset.get("atHour") is not None:
|
||||
sr["at_hour"] = reset["atHour"]
|
||||
if reset.get("idleMinutes"):
|
||||
sr["idle_minutes"] = reset["idleMinutes"]
|
||||
changes = True
|
||||
elif isinstance(reset_triggers, list) and reset_triggers:
|
||||
# Simple string triggers: ["daily", "idle"]
|
||||
has_daily = "daily" in reset_triggers
|
||||
has_idle = "idle" in reset_triggers
|
||||
if has_daily and has_idle:
|
||||
sr["mode"] = "both"
|
||||
elif has_daily:
|
||||
sr["mode"] = "daily"
|
||||
elif has_idle:
|
||||
sr["mode"] = "idle"
|
||||
sr["mode"] = "none"
|
||||
|
||||
if daily.get("hour") is not None:
|
||||
sr["at_hour"] = daily["hour"]
|
||||
if idle.get("minutes") or idle.get("timeoutMinutes"):
|
||||
sr["idle_minutes"] = idle.get("minutes") or idle.get("timeoutMinutes")
|
||||
changes = True
|
||||
|
||||
if changes:
|
||||
@@ -2227,12 +2092,11 @@ class Migrator:
|
||||
browser_hermes = hermes_cfg.get("browser") or {}
|
||||
changed = False
|
||||
|
||||
# Map fields that have Hermes equivalents
|
||||
if browser.get("cdpUrl"):
|
||||
browser_hermes["cdp_url"] = browser["cdpUrl"]
|
||||
if browser.get("inactivityTimeoutMs"):
|
||||
browser_hermes["inactivity_timeout"] = browser["inactivityTimeoutMs"] // 1000
|
||||
changed = True
|
||||
if browser.get("headless") is not None:
|
||||
browser_hermes["headless"] = browser["headless"]
|
||||
if browser.get("commandTimeoutMs"):
|
||||
browser_hermes["command_timeout"] = browser["commandTimeoutMs"] // 1000
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
@@ -2243,9 +2107,9 @@ class Migrator:
|
||||
self.record("browser-config", "openclaw.json browser.*", "config.yaml browser",
|
||||
"migrated")
|
||||
|
||||
# Archive remaining browser settings
|
||||
# Archive advanced browser settings
|
||||
advanced = {k: v for k, v in browser.items()
|
||||
if k not in ("cdpUrl", "headless") and v}
|
||||
if k not in ("inactivityTimeoutMs", "commandTimeoutMs") and v}
|
||||
if advanced and self.archive_dir:
|
||||
if self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2266,22 +2130,18 @@ class Migrator:
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
changed = False
|
||||
|
||||
# Map exec timeout -> terminal timeout (field is timeoutSec in OpenClaw)
|
||||
# Map exec timeout -> terminal timeout
|
||||
exec_cfg = tools.get("exec") or {}
|
||||
timeout_val = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")
|
||||
if timeout_val:
|
||||
if exec_cfg.get("timeout"):
|
||||
terminal_cfg = hermes_cfg.get("terminal") or {}
|
||||
terminal_cfg["timeout"] = timeout_val
|
||||
terminal_cfg["timeout"] = exec_cfg["timeout"]
|
||||
hermes_cfg["terminal"] = terminal_cfg
|
||||
changed = True
|
||||
|
||||
# Map web search API key (path: tools.web.search.brave.apiKey in OpenClaw)
|
||||
web_cfg = tools.get("web") or tools.get("webSearch") or {}
|
||||
search_cfg = web_cfg.get("search") or web_cfg if not web_cfg.get("search") else web_cfg["search"]
|
||||
brave_cfg = search_cfg.get("brave") or {}
|
||||
brave_key = brave_cfg.get("apiKey") or search_cfg.get("braveApiKey") or web_cfg.get("braveApiKey")
|
||||
if brave_key and isinstance(brave_key, str) and self.migrate_secrets:
|
||||
self._set_env_var("BRAVE_API_KEY", brave_key, "tools.web.search.brave.apiKey")
|
||||
# Map web search API key
|
||||
web_cfg = tools.get("webSearch") or tools.get("web") or {}
|
||||
if web_cfg.get("braveApiKey") and self.migrate_secrets:
|
||||
self._set_env_var("BRAVE_API_KEY", web_cfg["braveApiKey"], "tools.webSearch.braveApiKey")
|
||||
|
||||
if changed and self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
@@ -2309,9 +2169,8 @@ class Migrator:
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
|
||||
# Map approval mode (nested under approvals.exec.mode in OpenClaw)
|
||||
exec_approvals = approvals.get("exec") or {}
|
||||
mode = (exec_approvals.get("mode") if isinstance(exec_approvals, dict) else None) or approvals.get("mode") or approvals.get("defaultMode")
|
||||
# Map approval mode
|
||||
mode = approvals.get("mode") or approvals.get("defaultMode")
|
||||
if mode:
|
||||
mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"}
|
||||
hermes_mode = mode_map.get(mode, "manual")
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
name: canvas
|
||||
description: Canvas LMS integration — fetch enrolled courses and assignments using API token authentication.
|
||||
version: 1.0.0
|
||||
author: community
|
||||
license: MIT
|
||||
prerequisites:
|
||||
env_vars: [CANVAS_API_TOKEN, CANVAS_BASE_URL]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Canvas, LMS, Education, Courses, Assignments]
|
||||
---
|
||||
|
||||
# Canvas LMS — Course & Assignment Access
|
||||
|
||||
Read-only access to Canvas LMS for listing courses and assignments.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/canvas_api.py` — Python CLI for Canvas API calls
|
||||
|
||||
## Setup
|
||||
|
||||
1. Log in to your Canvas instance in a browser
|
||||
2. Go to **Account → Settings** (click your profile icon, then Settings)
|
||||
3. Scroll to **Approved Integrations** and click **+ New Access Token**
|
||||
4. Name the token (e.g., "Hermes Agent"), set an optional expiry, and click **Generate Token**
|
||||
5. Copy the token and add to `~/.hermes/.env`:
|
||||
|
||||
```
|
||||
CANVAS_API_TOKEN=your_token_here
|
||||
CANVAS_BASE_URL=https://yourschool.instructure.com
|
||||
```
|
||||
|
||||
The base URL is whatever appears in your browser when you're logged into Canvas (no trailing slash).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
CANVAS="python $HERMES_HOME/skills/productivity/canvas/scripts/canvas_api.py"
|
||||
|
||||
# List all active courses
|
||||
$CANVAS list_courses --enrollment-state active
|
||||
|
||||
# List all courses (any state)
|
||||
$CANVAS list_courses
|
||||
|
||||
# List assignments for a specific course
|
||||
$CANVAS list_assignments 12345
|
||||
|
||||
# List assignments ordered by due date
|
||||
$CANVAS list_assignments 12345 --order-by due_at
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
**list_courses** returns:
|
||||
```json
|
||||
[{"id": 12345, "name": "Intro to CS", "course_code": "CS101", "workflow_state": "available", "start_at": "...", "end_at": "..."}]
|
||||
```
|
||||
|
||||
**list_assignments** returns:
|
||||
```json
|
||||
[{"id": 67890, "name": "Homework 1", "due_at": "2025-02-15T23:59:00Z", "points_possible": 100, "submission_types": ["online_upload"], "html_url": "...", "description": "...", "course_id": 12345}]
|
||||
```
|
||||
|
||||
Note: Assignment descriptions are truncated to 500 characters. The `html_url` field links to the full assignment page in Canvas.
|
||||
|
||||
## API Reference (curl)
|
||||
|
||||
```bash
|
||||
# List courses
|
||||
curl -s -H "Authorization: Bearer $CANVAS_API_TOKEN" \
|
||||
"$CANVAS_BASE_URL/api/v1/courses?enrollment_state=active&per_page=10"
|
||||
|
||||
# List assignments for a course
|
||||
curl -s -H "Authorization: Bearer $CANVAS_API_TOKEN" \
|
||||
"$CANVAS_BASE_URL/api/v1/courses/COURSE_ID/assignments?per_page=10&order_by=due_at"
|
||||
```
|
||||
|
||||
Canvas uses `Link` headers for pagination. The Python script handles pagination automatically.
|
||||
|
||||
## Rules
|
||||
|
||||
- This skill is **read-only** — it only fetches data, never modifies courses or assignments
|
||||
- On first use, verify auth by running `$CANVAS list_courses` — if it fails with 401, guide the user through setup
|
||||
- Canvas rate-limits to ~700 requests per 10 minutes; check `X-Rate-Limit-Remaining` header if hitting limits
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
|---------|-----|
|
||||
| 401 Unauthorized | Token invalid or expired — regenerate in Canvas Settings |
|
||||
| 403 Forbidden | Token lacks permission for this course |
|
||||
| Empty course list | Try `--enrollment-state active` or omit the flag to see all states |
|
||||
| Wrong institution | Verify `CANVAS_BASE_URL` matches the URL in your browser |
|
||||
| Timeout errors | Check network connectivity to your Canvas instance |
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canvas LMS API CLI for Hermes Agent.
|
||||
|
||||
A thin CLI wrapper around the Canvas REST API.
|
||||
Authenticates using a personal access token from environment variables.
|
||||
|
||||
Usage:
|
||||
python canvas_api.py list_courses [--per-page N] [--enrollment-state STATE]
|
||||
python canvas_api.py list_assignments COURSE_ID [--per-page N] [--order-by FIELD]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
CANVAS_API_TOKEN = os.environ.get("CANVAS_API_TOKEN", "")
|
||||
CANVAS_BASE_URL = os.environ.get("CANVAS_BASE_URL", "").rstrip("/")
|
||||
|
||||
|
||||
def _check_config():
|
||||
"""Validate required environment variables are set."""
|
||||
missing = []
|
||||
if not CANVAS_API_TOKEN:
|
||||
missing.append("CANVAS_API_TOKEN")
|
||||
if not CANVAS_BASE_URL:
|
||||
missing.append("CANVAS_BASE_URL")
|
||||
if missing:
|
||||
print(
|
||||
f"Missing required environment variables: {', '.join(missing)}\n"
|
||||
"Set them in ~/.hermes/.env or export them in your shell.\n"
|
||||
"See the canvas skill SKILL.md for setup instructions.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _headers():
|
||||
return {"Authorization": f"Bearer {CANVAS_API_TOKEN}"}
|
||||
|
||||
|
||||
def _paginated_get(url, params=None, max_items=200):
|
||||
"""Fetch all pages up to max_items, following Canvas Link headers."""
|
||||
results = []
|
||||
while url and len(results) < max_items:
|
||||
resp = requests.get(url, headers=_headers(), params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
results.extend(resp.json())
|
||||
params = None # params are included in the Link URL for subsequent pages
|
||||
url = None
|
||||
link = resp.headers.get("Link", "")
|
||||
for part in link.split(","):
|
||||
if 'rel="next"' in part:
|
||||
url = part.split(";")[0].strip().strip("<>")
|
||||
return results[:max_items]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Commands
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def list_courses(args):
|
||||
"""List enrolled courses."""
|
||||
_check_config()
|
||||
url = f"{CANVAS_BASE_URL}/api/v1/courses"
|
||||
params = {"per_page": args.per_page}
|
||||
if args.enrollment_state:
|
||||
params["enrollment_state"] = args.enrollment_state
|
||||
try:
|
||||
courses = _paginated_get(url, params)
|
||||
except requests.HTTPError as e:
|
||||
print(f"API error: {e.response.status_code} {e.response.text}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
output = [
|
||||
{
|
||||
"id": c["id"],
|
||||
"name": c.get("name", ""),
|
||||
"course_code": c.get("course_code", ""),
|
||||
"enrollment_term_id": c.get("enrollment_term_id"),
|
||||
"start_at": c.get("start_at"),
|
||||
"end_at": c.get("end_at"),
|
||||
"workflow_state": c.get("workflow_state", ""),
|
||||
}
|
||||
for c in courses
|
||||
]
|
||||
print(json.dumps(output, indent=2))
|
||||
|
||||
|
||||
def list_assignments(args):
|
||||
"""List assignments for a course."""
|
||||
_check_config()
|
||||
url = f"{CANVAS_BASE_URL}/api/v1/courses/{args.course_id}/assignments"
|
||||
params = {"per_page": args.per_page}
|
||||
if args.order_by:
|
||||
params["order_by"] = args.order_by
|
||||
try:
|
||||
assignments = _paginated_get(url, params)
|
||||
except requests.HTTPError as e:
|
||||
print(f"API error: {e.response.status_code} {e.response.text}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
output = [
|
||||
{
|
||||
"id": a["id"],
|
||||
"name": a.get("name", ""),
|
||||
"description": (a.get("description") or "")[:500],
|
||||
"due_at": a.get("due_at"),
|
||||
"points_possible": a.get("points_possible"),
|
||||
"submission_types": a.get("submission_types", []),
|
||||
"html_url": a.get("html_url", ""),
|
||||
"course_id": a.get("course_id"),
|
||||
}
|
||||
for a in assignments
|
||||
]
|
||||
print(json.dumps(output, indent=2))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# CLI parser
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Canvas LMS API CLI for Hermes Agent"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# --- list_courses ---
|
||||
p = sub.add_parser("list_courses", help="List enrolled courses")
|
||||
p.add_argument("--per-page", type=int, default=50, help="Results per page (default 50)")
|
||||
p.add_argument(
|
||||
"--enrollment-state",
|
||||
default="",
|
||||
help="Filter by enrollment state (active, invited_or_pending, completed)",
|
||||
)
|
||||
p.set_defaults(func=list_courses)
|
||||
|
||||
# --- list_assignments ---
|
||||
p = sub.add_parser("list_assignments", help="List assignments for a course")
|
||||
p.add_argument("course_id", help="Canvas course ID")
|
||||
p.add_argument("--per-page", type=int, default=50, help="Results per page (default 50)")
|
||||
p.add_argument(
|
||||
"--order-by",
|
||||
default="",
|
||||
help="Order by field (due_at, name, position)",
|
||||
)
|
||||
p.set_defaults(func=list_assignments)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user