Compare commits

..
Author SHA1 Message Date
Teknium 1ddb03b76f feat: per-model rate limit handler with stepped cooldown (inspired by OpenClaw)
Add agent/rate_limiter.py — a thread-safe per-model rate limit handler
with a stepped cooldown ladder:
  1st hit: 30s cooldown
  2nd hit: 60s cooldown
  3rd+ hits: 5min cooldown
  Resets after 10min of no hits

Wired into run_agent.py's API error handling. When a 429 is caught and
no fallback provider is available, the rate limiter kicks in with
escalating backoff instead of immediately failing.

23 new tests.
2026-03-30 01:15:26 -07:00
45 changed files with 830 additions and 2503 deletions
-249
View File
@@ -1,249 +0,0 @@
# Hermes Agent v0.6.0 (v2026.3.30)
**Release Date:** March 30, 2026
> The multi-instance release — Profiles for running isolated agent instances, MCP server mode, Docker container, fallback provider chains, two new messaging platforms (Feishu/Lark and WeCom), Telegram webhook mode, Slack multi-workspace OAuth, 95 PRs and 16 resolved issues in 2 days.
---
## ✨ Highlights
- **Profiles — Multi-Instance Hermes** — Run multiple isolated Hermes instances from the same installation. Each profile gets its own config, memory, sessions, skills, and gateway service. Create with `hermes profile create`, switch with `hermes -p <name>`, export/import for sharing. Full token-lock isolation prevents two profiles from using the same bot credential. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681))
- **MCP Server Mode** — Expose Hermes conversations and sessions to any MCP-compatible client (Claude Desktop, Cursor, VS Code, etc.) via `hermes mcp serve`. Browse conversations, read messages, search across sessions, and manage attachments — all through the Model Context Protocol. Supports both stdio and Streamable HTTP transports. ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795))
- **Docker Container** — Official Dockerfile for running Hermes Agent in a container. Supports both CLI and gateway modes with volume-mounted config. ([#3668](https://github.com/NousResearch/hermes-agent/pull/3668), closes [#850](https://github.com/NousResearch/hermes-agent/issues/850))
- **Ordered Fallback Provider Chain** — Configure multiple inference providers with automatic failover. When your primary provider returns errors or is unreachable, Hermes automatically tries the next provider in the chain. Configure via `fallback_providers` in config.yaml. ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813), closes [#1734](https://github.com/NousResearch/hermes-agent/issues/1734))
- **Feishu/Lark Platform Support** — Full gateway adapter for Feishu (飞书) and Lark with event subscriptions, message cards, group chat, image/file attachments, and interactive card callbacks. ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817), closes [#1788](https://github.com/NousResearch/hermes-agent/issues/1788))
- **WeCom (Enterprise WeChat) Platform Support** — New gateway adapter for WeCom (企业微信) with text/image/voice messages, group chats, and callback verification. ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847))
- **Slack Multi-Workspace OAuth** — Connect a single Hermes gateway to multiple Slack workspaces via OAuth token file. Each workspace gets its own bot token, resolved dynamically per incoming event. ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903))
- **Telegram Webhook Mode & Group Controls** — Run the Telegram adapter in webhook mode as an alternative to polling — faster response times and better for production deployments behind a reverse proxy. New group mention gating controls when the bot responds: always, only when @mentioned, or via regex triggers. ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880), [#3870](https://github.com/NousResearch/hermes-agent/pull/3870))
- **Exa Search Backend** — Add Exa as an alternative web search and content extraction backend alongside Firecrawl and DuckDuckGo. Set `EXA_API_KEY` and configure as preferred backend. ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648))
- **Skills & Credentials on Remote Backends** — Mount skill directories and credential files into Modal and Docker containers, so remote terminal sessions have access to the same skills and secrets as local execution. ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890), [#3671](https://github.com/NousResearch/hermes-agent/pull/3671), closes [#3665](https://github.com/NousResearch/hermes-agent/issues/3665), [#3433](https://github.com/NousResearch/hermes-agent/issues/3433))
---
## 🏗️ Core Agent & Architecture
### Provider & Model Support
- **Ordered fallback provider chain** — automatic failover across multiple configured providers ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813))
- **Fix api_mode on provider switch** — switching providers via `hermes model` now correctly clears stale `api_mode` instead of hardcoding `chat_completions`, fixing 404s for providers with Anthropic-compatible endpoints ([#3726](https://github.com/NousResearch/hermes-agent/pull/3726), [#3857](https://github.com/NousResearch/hermes-agent/pull/3857), closes [#3685](https://github.com/NousResearch/hermes-agent/issues/3685))
- **Stop silent OpenRouter fallback** — when no provider is configured, Hermes now raises a clear error instead of silently routing to OpenRouter ([#3807](https://github.com/NousResearch/hermes-agent/pull/3807), [#3862](https://github.com/NousResearch/hermes-agent/pull/3862))
- **Gemini 3.1 preview models** — added to OpenRouter and Nous Portal catalogs ([#3803](https://github.com/NousResearch/hermes-agent/pull/3803), closes [#3753](https://github.com/NousResearch/hermes-agent/issues/3753))
- **Gemini direct API context length** — full context length resolution for direct Google AI endpoints ([#3876](https://github.com/NousResearch/hermes-agent/pull/3876))
- **gpt-5.4-mini** added to Codex fallback catalog ([#3855](https://github.com/NousResearch/hermes-agent/pull/3855))
- **Curated model lists preferred** over live API probe when the probe returns fewer models ([#3856](https://github.com/NousResearch/hermes-agent/pull/3856), [#3867](https://github.com/NousResearch/hermes-agent/pull/3867))
- **User-friendly 429 rate limit messages** with Retry-After countdown ([#3809](https://github.com/NousResearch/hermes-agent/pull/3809))
- **Auxiliary client placeholder key** for local servers without auth requirements ([#3842](https://github.com/NousResearch/hermes-agent/pull/3842))
- **INFO-level logging** for auxiliary provider resolution ([#3866](https://github.com/NousResearch/hermes-agent/pull/3866))
### Agent Loop & Conversation
- **Subagent status reporting** — reports `completed` status when summary exists instead of generic failure ([#3829](https://github.com/NousResearch/hermes-agent/pull/3829))
- **Session log file updated during compression** — prevents stale file references after context compression ([#3835](https://github.com/NousResearch/hermes-agent/pull/3835))
- **Omit empty tools param** — sends no `tools` parameter when empty instead of `None`, fixing compatibility with strict providers ([#3820](https://github.com/NousResearch/hermes-agent/pull/3820))
### Profiles & Multi-Instance
- **Profiles system** — `hermes profile create/list/switch/delete/export/import/rename`. Each profile gets isolated HERMES_HOME, gateway service, CLI wrapper. Token locks prevent credential collisions. Tab completion for profile names. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681))
- **Profile-aware display paths** — all user-facing `~/.hermes` paths replaced with `display_hermes_home()` to show the correct profile directory ([#3623](https://github.com/NousResearch/hermes-agent/pull/3623))
- **Lazy display_hermes_home imports** — prevents `ImportError` during `hermes update` when modules cache stale bytecode ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776))
- **HERMES_HOME for protected paths** — `.env` write-deny path now respects HERMES_HOME instead of hardcoded `~/.hermes` ([#3840](https://github.com/NousResearch/hermes-agent/pull/3840))
---
## 📱 Messaging Platforms (Gateway)
### New Platforms
- **Feishu/Lark** — Full adapter with event subscriptions, message cards, group chat, image/file attachments, interactive card callbacks ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817))
- **WeCom (Enterprise WeChat)** — Text/image/voice messages, group chats, callback verification ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847))
### Telegram
- **Webhook mode** — run as webhook endpoint instead of polling for production deployments ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880))
- **Group mention gating & regex triggers** — configurable bot response behavior in groups: always, @mention-only, or regex-matched ([#3870](https://github.com/NousResearch/hermes-agent/pull/3870))
- **Gracefully handle deleted reply targets** — no more crashes when the message being replied to was deleted ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858), closes [#3229](https://github.com/NousResearch/hermes-agent/issues/3229))
### Discord
- **Message processing reactions** — adds a reaction emoji while processing and removes it when done, giving visual feedback in channels ([#3871](https://github.com/NousResearch/hermes-agent/pull/3871))
- **DISCORD_IGNORE_NO_MENTION** — skip messages that @mention other users/bots but not Hermes ([#3640](https://github.com/NousResearch/hermes-agent/pull/3640))
- **Clean up deferred "thinking..."** — properly removes the "thinking..." indicator after slash commands complete ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674), closes [#3595](https://github.com/NousResearch/hermes-agent/issues/3595))
### Slack
- **Multi-workspace OAuth** — connect to multiple Slack workspaces from a single gateway via OAuth token file ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903))
### WhatsApp
- **Persistent aiohttp session** — reuse HTTP sessions across requests instead of creating new ones per message ([#3818](https://github.com/NousResearch/hermes-agent/pull/3818))
- **LID↔phone alias resolution** — correctly match Linked ID and phone number formats in allowlists ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830))
- **Skip reply prefix in bot mode** — cleaner message formatting when running as a WhatsApp bot ([#3931](https://github.com/NousResearch/hermes-agent/pull/3931))
### Matrix
- **Native voice messages via MSC3245** — send voice messages as proper Matrix voice events instead of file attachments ([#3877](https://github.com/NousResearch/hermes-agent/pull/3877))
### Mattermost
- **Configurable mention behavior** — respond to messages without requiring @mention ([#3664](https://github.com/NousResearch/hermes-agent/pull/3664))
### Signal
- **URL-encode phone numbers** and correct attachment RPC parameter — fixes delivery failures with certain phone number formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)) — @kshitijk4poor
### Email
- **Close SMTP/IMAP connections on failure** — prevents connection leaks during error scenarios ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804))
### Gateway Core
- **Atomic config writes** — use atomic file writes for config.yaml to prevent data loss during crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800))
- **Home channel env overrides** — apply environment variable overrides for home channels consistently ([#3796](https://github.com/NousResearch/hermes-agent/pull/3796), [#3808](https://github.com/NousResearch/hermes-agent/pull/3808))
- **Replace print() with logger** — BasePlatformAdapter now uses proper logging instead of print statements ([#3669](https://github.com/NousResearch/hermes-agent/pull/3669))
- **Cron delivery labels** — resolve human-friendly delivery labels via channel directory ([#3860](https://github.com/NousResearch/hermes-agent/pull/3860), closes [#1945](https://github.com/NousResearch/hermes-agent/issues/1945))
- **Cron [SILENT] tightening** — prevent agents from prefixing reports with [SILENT] to suppress delivery ([#3901](https://github.com/NousResearch/hermes-agent/pull/3901))
- **Background task media delivery** and vision download timeout fixes ([#3919](https://github.com/NousResearch/hermes-agent/pull/3919))
- **Boot-md hook** — example built-in hook to run a BOOT.md file on gateway startup ([#3733](https://github.com/NousResearch/hermes-agent/pull/3733))
---
## 🖥️ CLI & User Experience
### Interactive CLI
- **Configurable tool preview length** — show full file paths by default instead of truncating at 40 chars ([#3841](https://github.com/NousResearch/hermes-agent/pull/3841))
- **Tool token context display** — `hermes tools` checklist now shows estimated token cost per toolset ([#3805](https://github.com/NousResearch/hermes-agent/pull/3805))
- **/bg spinner TUI fix** — route background task spinner through the TUI widget to prevent status bar collision ([#3643](https://github.com/NousResearch/hermes-agent/pull/3643))
- **Prevent status bar wrapping** into duplicate rows ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883)) — @kshitijk4poor
- **Handle closed stdout ValueError** in safe print paths — fixes crashes when stdout is closed during gateway thread shutdown ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843), closes [#3534](https://github.com/NousResearch/hermes-agent/issues/3534))
- **Remove input() from /tools disable** — eliminates freeze in terminal when disabling tools ([#3918](https://github.com/NousResearch/hermes-agent/pull/3918))
- **TTY guard for interactive CLI commands** — prevent CPU spin when launched without a terminal ([#3933](https://github.com/NousResearch/hermes-agent/pull/3933))
- **Argparse entrypoint** — use argparse in the top-level launcher for cleaner error handling ([#3874](https://github.com/NousResearch/hermes-agent/pull/3874))
- **Lazy-initialized tools show yellow** in banner instead of red, reducing false alarm about "missing" tools ([#3822](https://github.com/NousResearch/hermes-agent/pull/3822))
- **Honcho tools shown in banner** when configured ([#3810](https://github.com/NousResearch/hermes-agent/pull/3810))
### Setup & Configuration
- **Auto-install matrix-nio** during `hermes setup` when Matrix is selected ([#3802](https://github.com/NousResearch/hermes-agent/pull/3802), [#3873](https://github.com/NousResearch/hermes-agent/pull/3873))
- **Session export stdout support** — export sessions to stdout with `-` for piping ([#3641](https://github.com/NousResearch/hermes-agent/pull/3641), closes [#3609](https://github.com/NousResearch/hermes-agent/issues/3609))
- **Configurable approval timeouts** — set how long dangerous command approval prompts wait before auto-denying ([#3886](https://github.com/NousResearch/hermes-agent/pull/3886), closes [#3765](https://github.com/NousResearch/hermes-agent/issues/3765))
- **Clear __pycache__ during update** — prevents stale bytecode ImportError after `hermes update` ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819))
---
## 🔧 Tool System
### MCP
- **MCP Server Mode** — `hermes mcp serve` exposes conversations, sessions, and attachments to MCP clients via stdio or Streamable HTTP ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795))
- **Dynamic tool discovery** — respond to `notifications/tools/list_changed` events to pick up new tools from MCP servers without reconnecting ([#3812](https://github.com/NousResearch/hermes-agent/pull/3812))
- **Non-deprecated HTTP transport** — switched from `sse_client` to `streamable_http_client` ([#3646](https://github.com/NousResearch/hermes-agent/pull/3646))
### Web Tools
- **Exa search backend** — alternative to Firecrawl and DuckDuckGo for web search and extraction ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648))
### Browser
- **Guard against None LLM responses** in browser snapshot and vision tools ([#3642](https://github.com/NousResearch/hermes-agent/pull/3642))
### Terminal & Remote Backends
- **Mount skill directories** into Modal and Docker containers ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890))
- **Mount credential files** into remote backends with mtime+size caching ([#3671](https://github.com/NousResearch/hermes-agent/pull/3671))
- **Preserve partial output** when commands time out instead of losing everything ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868))
- **Stop marking persisted env vars as missing** on remote backends ([#3650](https://github.com/NousResearch/hermes-agent/pull/3650))
### Audio
- **.aac format support** in transcription tool ([#3865](https://github.com/NousResearch/hermes-agent/pull/3865), closes [#1963](https://github.com/NousResearch/hermes-agent/issues/1963))
- **Audio download retry** — retry logic for `cache_audio_from_url` matching the existing image download pattern ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401)) — @binhnt92
### Vision
- **Reject non-image files** and enforce website-only policy for vision analysis ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845))
### Tool Schema
- **Ensure name field** always present in tool definitions, fixing `KeyError: 'name'` crashes ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811), closes [#3729](https://github.com/NousResearch/hermes-agent/issues/3729))
### ACP (Editor Integration)
- **Complete session management surface** for VS Code/Zed/JetBrains clients — proper task lifecycle, cancel support, session persistence ([#3675](https://github.com/NousResearch/hermes-agent/pull/3675))
---
## 🧩 Skills & Plugins
### Skills System
- **External skill directories** — configure additional skill directories via `skills.external_dirs` in config.yaml ([#3678](https://github.com/NousResearch/hermes-agent/pull/3678))
- **Category path traversal blocked** — prevents `../` attacks in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844))
- **parallel-cli moved to optional-skills** — reduces default skill footprint ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)) — @kshitijk4poor
### New Skills
- **memento-flashcards** — spaced repetition flashcard system ([#3827](https://github.com/NousResearch/hermes-agent/pull/3827))
- **songwriting-and-ai-music** — songwriting craft and AI music generation prompts ([#3834](https://github.com/NousResearch/hermes-agent/pull/3834))
- **SiYuan Note** — integration with SiYuan note-taking app ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742))
- **Scrapling** — web scraping skill using Scrapling library ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742))
- **one-three-one-rule** — communication framework skill ([#3797](https://github.com/NousResearch/hermes-agent/pull/3797))
### Plugin System
- **Plugin enable/disable commands** — `hermes plugins enable/disable <name>` for managing plugin state without removing them ([#3747](https://github.com/NousResearch/hermes-agent/pull/3747))
- **Plugin message injection** — plugins can now inject messages into the conversation stream on behalf of the user via `ctx.inject_message()` ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778)) — @winglian
- **Honcho self-hosted support** — allow local Honcho instances without requiring an API key ([#3644](https://github.com/NousResearch/hermes-agent/pull/3644))
---
## 🔒 Security & Reliability
### Security Hardening
- **Hardened dangerous command detection** — expanded pattern matching for risky shell commands and added file tool path guards for sensitive locations (`/etc/`, `/boot/`, docker.sock) ([#3872](https://github.com/NousResearch/hermes-agent/pull/3872))
- **Sensitive path write checks** in approval system — catch writes to system config files through file tools, not just terminal ([#3859](https://github.com/NousResearch/hermes-agent/pull/3859))
- **Secret redaction expansion** — now covers ElevenLabs, Tavily, and Exa API keys ([#3920](https://github.com/NousResearch/hermes-agent/pull/3920))
- **Vision file rejection** — reject non-image files passed to vision analysis to prevent information disclosure ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845))
- **Category path traversal blocking** — prevent directory traversal in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844))
### Reliability
- **Atomic config.yaml writes** — prevent data loss during gateway crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800))
- **Clear __pycache__ on update** — prevent stale bytecode from causing ImportError after updates ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819))
- **Lazy imports for update safety** — prevent ImportError chains during `hermes update` when modules reference new functions ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776))
- **Restore terminalbench2 from patch corruption** — recovered file damaged by patch tool's secret redaction ([#3801](https://github.com/NousResearch/hermes-agent/pull/3801))
- **Terminal timeout preserves partial output** — no more lost command output on timeout ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868))
---
## 🐛 Notable Bug Fixes
- **OpenClaw migration model config overwrite** — migration no longer overwrites model config dict with a string ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924)) — @0xbyt4
- **OpenClaw migration expanded** — covers full data footprint including sessions, cron, memory ([#3869](https://github.com/NousResearch/hermes-agent/pull/3869))
- **Telegram deleted reply targets** — gracefully handle replies to deleted messages instead of crashing ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858))
- **Discord "thinking..." persistence** — properly cleans up deferred response indicators ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674))
- **WhatsApp LID↔phone aliases** — fixes allowlist matching failures with Linked ID format ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830))
- **Signal URL-encoded phone numbers** — fixes delivery failures with certain formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670))
- **Email connection leaks** — properly close SMTP/IMAP connections on error ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804))
- **_safe_print ValueError** — no more gateway thread crashes on closed stdout ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843))
- **Tool schema KeyError 'name'** — ensure name field always present in tool definitions ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811))
- **api_mode stale on provider switch** — correctly clear when switching providers via `hermes model` ([#3857](https://github.com/NousResearch/hermes-agent/pull/3857))
---
## 🧪 Testing
- Resolved 10+ CI failures across hooks, tiktoken, plugins, and skill tests ([#3848](https://github.com/NousResearch/hermes-agent/pull/3848), [#3721](https://github.com/NousResearch/hermes-agent/pull/3721), [#3936](https://github.com/NousResearch/hermes-agent/pull/3936))
---
## 📚 Documentation
- **Comprehensive OpenClaw migration guide** — step-by-step guide for migrating from OpenClaw/Claw3D to Hermes Agent ([#3864](https://github.com/NousResearch/hermes-agent/pull/3864), [#3900](https://github.com/NousResearch/hermes-agent/pull/3900))
- **Credential file passthrough docs** — document how to forward credential files and env vars to remote backends ([#3677](https://github.com/NousResearch/hermes-agent/pull/3677))
- **DuckDuckGo requirements clarified** — note runtime dependency on duckduckgo-search package ([#3680](https://github.com/NousResearch/hermes-agent/pull/3680))
- **Skills catalog updated** — added red-teaming category and optional skills listing ([#3745](https://github.com/NousResearch/hermes-agent/pull/3745))
- **Feishu docs MDX fix** — escape angle-bracket URLs that break Docusaurus build ([#3902](https://github.com/NousResearch/hermes-agent/pull/3902))
---
## 👥 Contributors
### Core
- **@teknium1** — 90 PRs across all subsystems
### Community Contributors
- **@kshitijk4poor** — 3 PRs: Signal phone number fix ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)), parallel-cli to optional-skills ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)), status bar wrapping fix ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883))
- **@winglian** — 1 PR: Plugin message injection interface ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778))
- **@binhnt92** — 1 PR: Audio download retry logic ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401))
- **@0xbyt4** — 1 PR: OpenClaw migration model config fix ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924))
### Issues Resolved from Community
@Material-Scientist ([#850](https://github.com/NousResearch/hermes-agent/issues/850)), @hanxu98121 ([#1734](https://github.com/NousResearch/hermes-agent/issues/1734)), @penwyp ([#1788](https://github.com/NousResearch/hermes-agent/issues/1788)), @dan-and ([#1945](https://github.com/NousResearch/hermes-agent/issues/1945)), @AdrianScott ([#1963](https://github.com/NousResearch/hermes-agent/issues/1963)), @clawdbot47 ([#3229](https://github.com/NousResearch/hermes-agent/issues/3229)), @alanfwilliams ([#3404](https://github.com/NousResearch/hermes-agent/issues/3404)), @kentimsit ([#3433](https://github.com/NousResearch/hermes-agent/issues/3433)), @hayka-pacha ([#3534](https://github.com/NousResearch/hermes-agent/issues/3534)), @primmer ([#3595](https://github.com/NousResearch/hermes-agent/issues/3595)), @dagelf ([#3609](https://github.com/NousResearch/hermes-agent/issues/3609)), @HenkDz ([#3685](https://github.com/NousResearch/hermes-agent/issues/3685)), @tmdgusya ([#3729](https://github.com/NousResearch/hermes-agent/issues/3729)), @TypQxQ ([#3753](https://github.com/NousResearch/hermes-agent/issues/3753)), @acsezen ([#3765](https://github.com/NousResearch/hermes-agent/issues/3765))
---
**Full Changelog**: [v2026.3.28...v2026.3.30](https://github.com/NousResearch/hermes-agent/compare/v2026.3.28...v2026.3.30)
+173
View File
@@ -0,0 +1,173 @@
"""Per-model rate limit handler with stepped cooldown.
Tracks 429 / rate-limit errors per model and applies a stepped cooldown
ladder:
1st hit → 30 s
2nd hit → 60 s
3rd+ hit → 300 s (5 min)
The step counter resets automatically after 10 minutes of *no* rate-limit
hits for a given model.
Thread-safe: all mutable state is guarded by a single ``threading.Lock``.
Usage example (inside an API retry loop)::
from agent.rate_limiter import rate_limiter
# Before calling the API honour any active cooldown
remaining = rate_limiter.check_rate_limit(model)
if remaining > 0:
time.sleep(remaining)
try:
response = client.chat.completions.create(...)
except RateLimitError:
cooldown = rate_limiter.record_rate_limit(model)
print(f"Rate limited on {model}, cooling down for {cooldown}s")
time.sleep(cooldown)
# … retry …
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Dict
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Stepped cooldown ladder (seconds)
_COOLDOWN_STEPS: tuple[int, ...] = (30, 60, 300)
# After this many seconds with no new rate-limit hits the step counter resets.
_RESET_WINDOW: float = 600.0 # 10 minutes
# ---------------------------------------------------------------------------
# Internal per-model state
# ---------------------------------------------------------------------------
@dataclass
class _ModelCooldownState:
"""Mutable cooldown state for a single model."""
# How many consecutive rate-limit hits (1-indexed).
step: int = 0
# ``time.monotonic()`` timestamp when the current cooldown ends.
cooldown_until: float = 0.0
# ``time.monotonic()`` of the last hit used for the reset window.
last_hit: float = 0.0
# ---------------------------------------------------------------------------
# Public API singleton ``RateLimiter``
# ---------------------------------------------------------------------------
class RateLimiter:
"""Thread-safe, per-model rate-limit handler with stepped cooldown."""
def __init__(
self,
cooldown_steps: tuple[int, ...] = _COOLDOWN_STEPS,
reset_window: float = _RESET_WINDOW,
) -> None:
self._cooldown_steps = cooldown_steps
self._reset_window = reset_window
self._lock = threading.Lock()
self._models: Dict[str, _ModelCooldownState] = {}
# -- helpers ----------------------------------------------------------
def _get_state(self, model: str) -> _ModelCooldownState:
"""Return (or create) the state object for *model*. Caller must hold ``_lock``."""
if model not in self._models:
self._models[model] = _ModelCooldownState()
return self._models[model]
def _maybe_reset(self, state: _ModelCooldownState, now: float) -> None:
"""Reset the step counter if the reset window has elapsed since the last hit.
Caller must hold ``_lock``.
"""
if state.last_hit and (now - state.last_hit) >= self._reset_window:
state.step = 0
# -- public interface -------------------------------------------------
def check_rate_limit(self, model: str) -> float:
"""Return remaining cooldown seconds for *model*, or ``0`` if none."""
now = time.monotonic()
with self._lock:
state = self._get_state(model)
remaining = max(0.0, state.cooldown_until - now)
return remaining
def record_rate_limit(self, model: str) -> float:
"""Record a rate-limit hit for *model* and return the cooldown duration (seconds).
The returned value is the number of seconds to wait before the next
attempt.
"""
now = time.monotonic()
with self._lock:
state = self._get_state(model)
# Reset step counter if the reset window elapsed.
self._maybe_reset(state, now)
# Advance the step (clamped to the ladder length).
state.step = min(state.step + 1, len(self._cooldown_steps))
# Look up the cooldown for this step (1-indexed → 0-indexed).
cooldown = self._cooldown_steps[state.step - 1]
state.cooldown_until = now + cooldown
state.last_hit = now
return float(cooldown)
def get_step(self, model: str) -> int:
"""Return the current step number for *model* (0 means no active cooldown)."""
now = time.monotonic()
with self._lock:
state = self._get_state(model)
self._maybe_reset(state, now)
return state.step
def get_cooldown_status(self) -> Dict[str, Dict[str, float]]:
"""Return a snapshot of all models with an active cooldown.
Returns a dict mapping model name → ``{"remaining": <secs>, "step": <int>}``.
Models whose cooldown has already expired are omitted.
"""
now = time.monotonic()
result: Dict[str, Dict[str, float]] = {}
with self._lock:
for model, state in self._models.items():
remaining = max(0.0, state.cooldown_until - now)
if remaining > 0:
result[model] = {
"remaining": round(remaining, 2),
"step": state.step,
}
return result
def reset(self, model: str | None = None) -> None:
"""Reset cooldown state. If *model* is ``None``, reset everything."""
with self._lock:
if model is None:
self._models.clear()
elif model in self._models:
del self._models[model]
# Module-level singleton for convenient import.
rate_limiter = RateLimiter()
-3
View File
@@ -37,9 +37,6 @@ _PREFIX_PATTERNS = [
r"dop_v1_[A-Za-z0-9]{10,}", # DigitalOcean PAT
r"doo_v1_[A-Za-z0-9]{10,}", # DigitalOcean OAuth
r"am_[A-Za-z0-9_-]{10,}", # AgentMail API key
r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash)
r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key
r"exa_[A-Za-z0-9]{10,}", # Exa search API key
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
-3
View File
@@ -324,9 +324,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:
+15 -10
View File
@@ -2789,12 +2789,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"))
@@ -6200,11 +6210,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()
+5 -6
View File
@@ -236,12 +236,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:
-1
View File
@@ -13,7 +13,6 @@ Core layers:
Concrete environments:
- terminal_test_env/: Simple file-creation tasks for testing the stack
- hermes_swe_env/: SWE-bench style tasks with Modal sandboxes
- endless_terminals/: Terminal tasks from HuggingFace dataset with Apptainer containers
Benchmarks (eval-only):
- benchmarks/terminalbench_2/: Terminal-Bench 2.0 evaluation
@@ -1,5 +0,0 @@
"""Endless Terminals Environment - Terminal task training from HuggingFace dataset."""
from .endless_terminals_env import EndlessTerminalsEnv, EndlessTerminalsEnvConfig
__all__ = ["EndlessTerminalsEnv", "EndlessTerminalsEnvConfig"]
File diff suppressed because it is too large Load Diff
@@ -1,91 +0,0 @@
# Endless Terminals - Qwen3-4B-Instruct-2507
# Single config for both trainer (launch_training.py) and env (endless_terminals_env.py serve)
#
# Usage:
# Terminal 1: run-api
# Terminal 2: cd tinker-atropos && python launch_training.py --config ../environments/endless_terminals/tinker_qwen.yaml
# Terminal 3: python environments/endless_terminals/endless_terminals_env.py serve --config environments/endless_terminals/tinker_qwen.yaml
env:
# Toolsets
enabled_toolsets: ["terminal", "file"]
# Model / tokenizer
tokenizer_name: "Qwen/Qwen3-4B-Instruct-2507"
# Agent configuration
max_agent_turns: 16
max_token_length: 2048
agent_temperature: 0.6
extra_body:
chat_template_kwargs:
enable_thinking: false
tool_call_parser: "hermes"
# Terminal backend
terminal_backend: "docker"
# Dataset settings
use_dataset: true
dataset_name: "obiwan96/endless-terminals"
dataset_split: "train"
dataset_cache_dir: "~/.cache/huggingface/datasets"
tasks_base_dir: "/Users/samherring/Desktop/Projects/Hermes-Agent/endless-terminals"
# Test execution
test_timeout_s: 180
default_docker_image: "ubuntu:22.04"
max_concurrent_containers: 16
# Training configuration
group_size: 16
batch_size: 64 # 4 groups × 16 rollouts per step
total_steps: 500
steps_per_eval: 5
min_items_sent_before_logging: 1
ensure_scores_are_not_same: true
max_num_workers: 2048
worker_timeout: 3600
inference_weight: 1.0
eval_limit_ratio: 0.1
rollout_server_url: "http://localhost:8000"
# Evaluation configuration
num_eval_tasks: 20
eval_split_ratio: 0.1
# Logging
use_wandb: true
wandb_name: "endless-terminals-qwen3-4b"
# System prompt
system_prompt: >
You are a skilled Linux system administrator and programmer.
You have access to a terminal and file tools to complete system administration
and programming tasks. Use the tools effectively to solve the given task,
and verify your solution works correctly before finishing.
Keep each command short and focused — break complex tasks into multiple steps
rather than writing long one-liners.
tinker:
lora_rank: 32
learning_rate: 0.0000005
max_token_trainer_length: 32768
checkpoint_dir: "./temp/"
save_checkpoint_interval: 50
wandb_project: "endless-terminals"
wandb_group: null
wandb_run_name: "qwen3-4b"
tool_call_parser: "hermes"
openai:
- model_name: "Qwen/Qwen3-4B-Instruct-2507"
base_url: "http://localhost:8001/v1"
api_key: "x"
weight: 1.0
num_requests_for_eval: 64
timeout: 600
server_type: "sglang"
slurm: false
testing: false
+1
View File
@@ -298,6 +298,7 @@ class HermesAgentBaseEnv(BaseEnv):
return False
server = self.server.servers[0]
# If the server is an OpenAI server (not VLLM/SGLang), use direct mode
from atroposlib.envs.server_handling.openai_server import OpenAIServer
return not isinstance(server, OpenAIServer)
@@ -48,13 +48,7 @@ class HermesToolCallParser(ToolCallParser):
if not raw_json.strip():
continue
try:
tc_data = json.loads(raw_json)
except json.JSONDecodeError:
# Fix invalid backslash escapes from shell commands in JSON strings
# e.g. \s \w \d \n (unescaped) → \\s \\w \\d \\n
fixed = re.sub(r'\\([^"\\/bfnrtu0-9\n])', r'\\\\\1', raw_json)
tc_data = json.loads(fixed)
tc_data = json.loads(raw_json)
tool_calls.append(
ChatCompletionMessageToolCall(
id=f"call_{uuid.uuid4().hex[:8]}",
+2 -3
View File
@@ -904,9 +904,8 @@ class MatrixAdapter(BasePlatformAdapter):
thread_id=thread_id,
)
# Use cached local path for images (voice messages already handled above).
if cached_path:
media_urls = [cached_path]
# Use cached local path for images, HTTP URL for other media types
media_urls = [cached_path] if cached_path else ([http_url] if http_url else None)
media_types = [media_type] if media_urls else None
msg_event = MessageEvent(
+31 -94
View File
@@ -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,34 +82,16 @@ 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
@@ -127,30 +104,12 @@ class SlackAdapter(BasePlatformAdapter):
self._set_fatal_error('slack_token_lock', message, retryable=False)
return False
# First token is the primary — used for AsyncApp / Socket Mode
primary_token = bot_tokens[0]
self._app = AsyncApp(token=primary_token)
self._app = AsyncApp(token=bot_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 +134,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
@@ -205,13 +161,6 @@ class SlackAdapter(BasePlatformAdapter):
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 +197,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 +219,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 +253,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 +295,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 +397,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 +413,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 +423,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 +434,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 +498,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 +558,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 +599,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 +627,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 +660,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 +676,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 +700,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 +711,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 +742,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 +771,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 +808,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 +839,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 +874,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:
+1 -1
View File
@@ -3891,7 +3891,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,
)
+2 -2
View File
@@ -11,5 +11,5 @@ Provides subcommands for:
- hermes cron - Manage cron jobs
"""
__version__ = "0.6.0"
__release_date__ = "2026.3.30"
__version__ = "0.5.0"
__release_date__ = "2026.3.28"
-5
View File
@@ -5,7 +5,6 @@ toggleable list of items. Falls back to a numbered text UI when
curses is unavailable (Windows without curses, piped stdin, etc.).
"""
import sys
from typing import List, Set
from hermes_cli.colors import Colors, color
@@ -27,10 +26,6 @@ def curses_checklist(
The indices the user confirmed as checked. On cancel (ESC/q),
returns ``pre_selected`` unchanged.
"""
# Safety: return defaults when stdin is not a terminal.
if not sys.stdin.isatty():
return set(pre_selected)
try:
import curses
selected = set(pre_selected)
+1 -2
View File
@@ -223,8 +223,7 @@ 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",
-6
View File
@@ -4,7 +4,6 @@ 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.
"""
import sys
from typing import Callable, List, Optional, Set
from hermes_cli.colors import Colors, color
@@ -32,11 +31,6 @@ def curses_checklist(
if cancel_returns is None:
cancel_returns = set(selected)
# Safety: curses and input() both hang or spin when stdin is not a
# terminal (e.g. subprocess pipe). Return defaults immediately.
if not sys.stdin.isatty():
return cancel_returns
try:
import curses
chosen = set(selected)
-23
View File
@@ -50,23 +50,6 @@ import sys
from pathlib import Path
from typing import Optional
def _require_tty(command_name: str) -> None:
"""Exit with a clear error if stdin is not a terminal.
Interactive TUI commands (hermes tools, hermes setup, hermes model) use
curses or input() prompts that spin at 100% CPU when stdin is a pipe.
This guard prevents accidental non-interactive invocation.
"""
if not sys.stdin.isatty():
print(
f"Error: 'hermes {command_name}' requires an interactive terminal.\n"
f"It cannot be run through a pipe or non-interactive subprocess.\n"
f"Run it directly in your terminal instead.",
file=sys.stderr,
)
sys.exit(1)
# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT))
@@ -634,7 +617,6 @@ def cmd_gateway(args):
def cmd_whatsapp(args):
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
_require_tty("whatsapp")
import subprocess
from pathlib import Path
from hermes_cli.config import get_env_value, save_env_value
@@ -821,14 +803,12 @@ def cmd_whatsapp(args):
def cmd_setup(args):
"""Interactive setup wizard."""
_require_tty("setup")
from hermes_cli.setup import run_setup_wizard
run_setup_wizard(args)
def cmd_model(args):
"""Select default model — starts with provider selection, then model picker."""
_require_tty("model")
from hermes_cli.auth import (
resolve_provider, AuthError, format_auth_error,
)
@@ -2479,7 +2459,6 @@ def cmd_version(args):
def cmd_uninstall(args):
"""Uninstall Hermes Agent."""
_require_tty("uninstall")
from hermes_cli.uninstall import run_uninstall
run_uninstall(args)
@@ -4152,7 +4131,6 @@ For more help on a command:
def cmd_skills(args):
# Route 'config' action to skills_config module
if getattr(args, 'skills_action', None) == 'config':
_require_tty("skills config")
from hermes_cli.skills_config import skills_command as skills_config_command
skills_config_command(args)
else:
@@ -4363,7 +4341,6 @@ For more help on a command:
from hermes_cli.tools_config import tools_disable_enable_command
tools_disable_enable_command(args)
else:
_require_tty("tools")
from hermes_cli.tools_config import tools_command
tools_command(args)
-4
View File
@@ -511,10 +511,6 @@ def _interpolate_value(value: str) -> str:
def cmd_mcp_configure(args):
"""Reconfigure which tools are enabled for an existing MCP server."""
import sys as _sys
if not _sys.stdin.isatty():
print("Error: 'hermes mcp configure' requires an interactive terminal.", file=_sys.stderr)
_sys.exit(1)
name = args.name
servers = _get_mcp_servers()
-29
View File
@@ -152,34 +152,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 +184,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
+2 -17
View File
@@ -354,14 +354,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
extra_metadata.update(getattr(bundle, "metadata", {}) or {})
# Quarantine the bundle
try:
q_path = quarantine_bundle(bundle)
except ValueError as exc:
c.print(f"[bold red]Installation blocked:[/] {exc}\n")
from tools.skills_hub import append_audit_log
append_audit_log("BLOCKED", bundle.name, bundle.source,
bundle.trust_level, "invalid_path", str(exc))
return
q_path = quarantine_bundle(bundle)
c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]")
# Scan
@@ -421,15 +414,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
return
# Install
try:
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
except ValueError as exc:
c.print(f"[bold red]Installation blocked:[/] {exc}\n")
shutil.rmtree(q_path, ignore_errors=True)
from tools.skills_hub import append_audit_log
append_audit_log("BLOCKED", bundle.name, bundle.source,
bundle.trust_level, "invalid_path", str(exc))
return
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
from tools.skills_hub import SKILLS_DIR
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
@@ -1297,11 +1297,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:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.6.0"
version = "0.5.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
requires-python = ">=3.11"
+58 -23
View File
@@ -100,6 +100,7 @@ from agent.trajectory import (
convert_scratchpad_to_think, has_incomplete_scratchpad,
save_trajectory as _save_trajectory_to_file,
)
from agent.rate_limiter import rate_limiter as _rate_limiter
from utils import atomic_json_write
HONCHO_TOOL_NAMES = {
@@ -2907,19 +2908,6 @@ class AIAgent:
})
return converted or None
@staticmethod
def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
"""Generate a deterministic call_id from tool call content.
Used as a fallback when the API doesn't provide a call_id.
Deterministic IDs prevent cache invalidation random UUIDs would
make every API call's prefix unique, breaking OpenAI's prompt cache.
"""
import hashlib
seed = f"{fn_name}:{arguments}:{index}"
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
return f"call_{digest}"
@staticmethod
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
"""Split a stored tool id into (call_id, response_item_id)."""
@@ -3026,8 +3014,7 @@ class AIAgent:
):
call_id = f"call_{embedded_response_item_id[len('fc_'):]}"
else:
_raw_args = str(fn.get("arguments", "{}"))
call_id = self._deterministic_call_id(fn_name, _raw_args, len(items))
call_id = f"call_{uuid.uuid4().hex[:12]}"
call_id = call_id.strip()
arguments = fn.get("arguments", "{}")
@@ -3391,7 +3378,7 @@ class AIAgent:
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
if not isinstance(call_id, str) or not call_id.strip():
call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls))
call_id = f"call_{uuid.uuid4().hex[:12]}"
call_id = call_id.strip()
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
@@ -3412,7 +3399,7 @@ class AIAgent:
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
if not isinstance(call_id, str) or not call_id.strip():
call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls))
call_id = f"call_{uuid.uuid4().hex[:12]}"
call_id = call_id.strip()
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
@@ -4947,10 +4934,7 @@ class AIAgent:
if isinstance(raw_id, str) and raw_id.strip():
call_id = raw_id.strip()
else:
_fn = getattr(tool_call, "function", None)
_fn_name = getattr(_fn, "name", "") if _fn else ""
_fn_args = getattr(_fn, "arguments", "{}") if _fn else "{}"
call_id = self._deterministic_call_id(_fn_name, _fn_args, len(tool_calls))
call_id = f"call_{uuid.uuid4().hex[:12]}"
call_id = call_id.strip()
response_item_id = getattr(tool_call, "response_item_id", None)
@@ -6532,7 +6516,7 @@ class AIAgent:
elif not isinstance(content_blocks, list):
response_invalid = True
error_details.append("response.content is not a list")
elif len(content_blocks) == 0:
elif len(content_blocks) == 0 and getattr(response, "stop_reason", None) != "sensitive":
response_invalid = True
error_details.append("response.content is empty")
else:
@@ -6648,11 +6632,14 @@ class AIAgent:
else:
finish_reason = "stop"
elif self.api_mode == "anthropic_messages":
stop_reason_map = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop"}
stop_reason_map = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop", "sensitive": "content_filter"}
finish_reason = stop_reason_map.get(response.stop_reason, "stop")
else:
finish_reason = response.choices[0].finish_reason
if finish_reason == "content_filter":
self._vprint(f"{self.log_prefix}⚠️ Response filtered by content policy (stop_reason='sensitive')", force=True)
if finish_reason == "length":
self._vprint(f"{self.log_prefix}⚠️ Response truncated (finish_reason='length') - model hit max output tokens", force=True)
@@ -7032,6 +7019,54 @@ class AIAgent:
retry_count = 0
continue
# --- Stepped rate-limit cooldown --------------------------
# If we're rate-limited (and fallback either isn't available
# or is already active), use the per-model stepped cooldown
# instead of the generic exponential backoff.
if is_rate_limited:
_rl_model = getattr(self, "model", "unknown") or "unknown"
_rl_cooldown = _rate_limiter.record_rate_limit(_rl_model)
_rl_step = _rate_limiter.get_step(_rl_model)
_rl_max_step = len(_rate_limiter._cooldown_steps)
self._vprint(
f"{self.log_prefix}🚦 Rate limited on {_rl_model}, "
f"cooling down for {_rl_cooldown:.0f}s "
f"(step {_rl_step}/{_rl_max_step})",
force=True,
)
self._emit_status(
f"🚦 Rate limited — cooling down {_rl_cooldown:.0f}s "
f"(step {_rl_step}/{_rl_max_step})..."
)
logging.warning(
"%sRate limited on %s — stepped cooldown %ss (step %s/%s)",
self.log_prefix, _rl_model, _rl_cooldown,
_rl_step, _rl_max_step,
)
# Sleep in small increments for interrupt responsiveness
_rl_end = time.time() + _rl_cooldown
while time.time() < _rl_end:
if self._interrupt_requested:
self._vprint(
f"{self.log_prefix}⚡ Interrupt during rate-limit cooldown.",
force=True,
)
self._persist_session(messages, conversation_history)
self.clear_interrupt()
return {
"final_response": (
f"Operation interrupted: rate-limit cooldown "
f"on {_rl_model} (step {_rl_step}/{_rl_max_step})."
),
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"interrupted": True,
}
time.sleep(0.2)
continue # retry the API call after cooldown
# ----------------------------------------------------------
is_payload_too_large = (
status_code == 413
or 'request entity too large' in error_msg
-4
View File
@@ -55,10 +55,6 @@ const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
function formatOutgoingMessage(message) {
// In bot mode, messages come from a different number so the prefix is
// redundant — the sender identity is already clear. Only prepend in
// self-chat mode where bot and user share the same number.
if (WHATSAPP_MODE !== 'self-chat') return message;
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
}
+320
View File
@@ -0,0 +1,320 @@
"""Tests for agent.rate_limiter per-model stepped cooldown."""
from __future__ import annotations
import threading
import time
from unittest import mock
import pytest
from agent.rate_limiter import RateLimiter, _COOLDOWN_STEPS, _RESET_WINDOW
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_limiter(steps: tuple[int, ...] = _COOLDOWN_STEPS, reset_window: float = _RESET_WINDOW) -> RateLimiter:
"""Create a fresh RateLimiter (not the module-level singleton)."""
return RateLimiter(cooldown_steps=steps, reset_window=reset_window)
# ---------------------------------------------------------------------------
# Test stepped cooldown escalation
# ---------------------------------------------------------------------------
class TestSteppedCooldown:
"""The cooldown should escalate through the ladder: 30s → 60s → 300s."""
def test_first_hit_returns_30s(self) -> None:
rl = _make_limiter()
assert rl.record_rate_limit("gpt-4") == 30
def test_second_hit_returns_60s(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("gpt-4")
assert rl.record_rate_limit("gpt-4") == 60
def test_third_hit_returns_300s(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("gpt-4")
rl.record_rate_limit("gpt-4")
assert rl.record_rate_limit("gpt-4") == 300
def test_fourth_hit_stays_at_max(self) -> None:
rl = _make_limiter()
for _ in range(3):
rl.record_rate_limit("gpt-4")
# 4th hit should stay clamped at step 3 (300s)
assert rl.record_rate_limit("gpt-4") == 300
def test_step_number_increments(self) -> None:
rl = _make_limiter()
assert rl.get_step("gpt-4") == 0
rl.record_rate_limit("gpt-4")
assert rl.get_step("gpt-4") == 1
rl.record_rate_limit("gpt-4")
assert rl.get_step("gpt-4") == 2
rl.record_rate_limit("gpt-4")
assert rl.get_step("gpt-4") == 3
# Stays clamped
rl.record_rate_limit("gpt-4")
assert rl.get_step("gpt-4") == 3
def test_custom_steps(self) -> None:
rl = _make_limiter(steps=(5, 10))
assert rl.record_rate_limit("m") == 5
assert rl.record_rate_limit("m") == 10
assert rl.record_rate_limit("m") == 10 # clamped
# ---------------------------------------------------------------------------
# Test cooldown reset after no hits
# ---------------------------------------------------------------------------
class TestCooldownReset:
"""Step counter should reset after reset_window seconds of no hits."""
def test_reset_after_window(self) -> None:
rl = _make_limiter(reset_window=10.0)
# Bump to step 2
rl.record_rate_limit("gpt-4")
rl.record_rate_limit("gpt-4")
assert rl.get_step("gpt-4") == 2
# Simulate 10+ seconds passing by manipulating last_hit
with rl._lock:
state = rl._models["gpt-4"]
state.last_hit = time.monotonic() - 11.0
state.cooldown_until = 0 # clear active cooldown too
# Next recording should start from step 1 again (reset happened)
assert rl.record_rate_limit("gpt-4") == 30
assert rl.get_step("gpt-4") == 1
def test_no_reset_within_window(self) -> None:
rl = _make_limiter(reset_window=600.0)
rl.record_rate_limit("gpt-4")
rl.record_rate_limit("gpt-4")
assert rl.get_step("gpt-4") == 2
# No time manipulation → still within window
assert rl.record_rate_limit("gpt-4") == 300
assert rl.get_step("gpt-4") == 3
def test_get_step_resets_when_window_elapsed(self) -> None:
rl = _make_limiter(reset_window=5.0)
rl.record_rate_limit("x")
assert rl.get_step("x") == 1
with rl._lock:
rl._models["x"].last_hit = time.monotonic() - 6.0
assert rl.get_step("x") == 0
# ---------------------------------------------------------------------------
# Test per-model isolation
# ---------------------------------------------------------------------------
class TestPerModelIsolation:
"""Each model should have its own independent cooldown state."""
def test_different_models_are_independent(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("gpt-4")
rl.record_rate_limit("gpt-4")
# Claude has not been hit yet → should start at step 1
assert rl.record_rate_limit("claude-3") == 30
assert rl.get_step("claude-3") == 1
# GPT-4 should still be at step 2 (plus the third hit now)
assert rl.record_rate_limit("gpt-4") == 300
assert rl.get_step("gpt-4") == 3
def test_reset_single_model(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("a")
rl.record_rate_limit("b")
rl.reset("a")
assert rl.get_step("a") == 0
assert rl.get_step("b") == 1
def test_reset_all(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("a")
rl.record_rate_limit("b")
rl.reset()
assert rl.get_step("a") == 0
assert rl.get_step("b") == 0
# ---------------------------------------------------------------------------
# Test check_rate_limit returns correct remaining time
# ---------------------------------------------------------------------------
class TestCheckRateLimit:
"""check_rate_limit should return remaining cooldown or 0."""
def test_no_cooldown_initially(self) -> None:
rl = _make_limiter()
assert rl.check_rate_limit("gpt-4") == 0.0
def test_remaining_time_after_hit(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("gpt-4") # 30s cooldown
remaining = rl.check_rate_limit("gpt-4")
# Should be very close to 30 (within a small tolerance)
assert 28.0 < remaining <= 30.0
def test_remaining_decreases_over_time(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("gpt-4")
# Simulate 10 seconds passing by adjusting cooldown_until
with rl._lock:
rl._models["gpt-4"].cooldown_until = time.monotonic() + 20.0
remaining = rl.check_rate_limit("gpt-4")
assert 18.0 < remaining <= 20.0
def test_returns_zero_after_cooldown_expires(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("gpt-4")
# Expire the cooldown
with rl._lock:
rl._models["gpt-4"].cooldown_until = time.monotonic() - 1.0
assert rl.check_rate_limit("gpt-4") == 0.0
# ---------------------------------------------------------------------------
# Test get_cooldown_status
# ---------------------------------------------------------------------------
class TestGetCooldownStatus:
"""get_cooldown_status should report all models with active cooldowns."""
def test_empty_when_no_hits(self) -> None:
rl = _make_limiter()
assert rl.get_cooldown_status() == {}
def test_shows_active_cooldowns(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("gpt-4")
rl.record_rate_limit("claude-3")
status = rl.get_cooldown_status()
assert "gpt-4" in status
assert "claude-3" in status
assert status["gpt-4"]["step"] == 1
assert status["gpt-4"]["remaining"] > 0
def test_omits_expired_cooldowns(self) -> None:
rl = _make_limiter()
rl.record_rate_limit("old")
rl.record_rate_limit("new")
# Expire "old"
with rl._lock:
rl._models["old"].cooldown_until = time.monotonic() - 1.0
status = rl.get_cooldown_status()
assert "old" not in status
assert "new" in status
# ---------------------------------------------------------------------------
# Test thread safety
# ---------------------------------------------------------------------------
class TestThreadSafety:
"""Concurrent access should not corrupt state."""
def test_concurrent_record(self) -> None:
rl = _make_limiter()
errors: list[Exception] = []
def _hit(model: str, n: int) -> None:
try:
for _ in range(n):
rl.record_rate_limit(model)
except Exception as exc:
errors.append(exc)
threads = []
for i in range(10):
t = threading.Thread(target=_hit, args=(f"model-{i % 3}", 50))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert not errors, f"Thread errors: {errors}"
# Each of the 3 models should have a valid step (clamped to max)
for i in range(3):
step = rl.get_step(f"model-{i}")
assert 1 <= step <= len(rl._cooldown_steps)
def test_concurrent_check_and_record(self) -> None:
rl = _make_limiter()
errors: list[Exception] = []
def _checker(model: str) -> None:
try:
for _ in range(100):
remaining = rl.check_rate_limit(model)
assert remaining >= 0
except Exception as exc:
errors.append(exc)
def _recorder(model: str) -> None:
try:
for _ in range(50):
cd = rl.record_rate_limit(model)
assert cd > 0
except Exception as exc:
errors.append(exc)
threads = [
threading.Thread(target=_checker, args=("m",)),
threading.Thread(target=_checker, args=("m",)),
threading.Thread(target=_recorder, args=("m",)),
threading.Thread(target=_recorder, args=("m",)),
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert not errors, f"Thread errors: {errors}"
# ---------------------------------------------------------------------------
# Test module-level singleton
# ---------------------------------------------------------------------------
class TestSingleton:
"""The module-level ``rate_limiter`` should be usable directly."""
def test_singleton_import(self) -> None:
from agent.rate_limiter import rate_limiter
assert isinstance(rate_limiter, RateLimiter)
def test_singleton_records(self) -> None:
from agent.rate_limiter import rate_limiter
# Reset to avoid pollution from other tests
rate_limiter.reset()
cd = rate_limiter.record_rate_limit("test-singleton-model")
assert cd == 30
rate_limiter.reset("test-singleton-model")
-49
View File
@@ -201,52 +201,3 @@ class TestSecretCapturePayloadRedaction:
text = '{"raw_secret": "ghp_abc123def456ghi789jkl"}'
result = redact_sensitive_text(text)
assert "abc123def456" not in result
class TestElevenLabsTavilyExaKeys:
"""Regression tests for ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys."""
def test_elevenlabs_key_redacted(self):
text = "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu"
result = redact_sensitive_text(text)
assert "abc123def456ghi" not in result
def test_elevenlabs_key_in_log_line(self):
text = "Connecting to ElevenLabs with key sk_abc123def456ghi789jklmnopqrstu"
result = redact_sensitive_text(text)
assert "abc123def456ghi" not in result
def test_tavily_key_redacted(self):
text = "TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000"
result = redact_sensitive_text(text)
assert "ABCdef123456789" not in result
def test_tavily_key_in_log_line(self):
text = "Initialising Tavily client with tvly-ABCdef123456789GHIJKL0000"
result = redact_sensitive_text(text)
assert "ABCdef123456789" not in result
def test_exa_key_redacted(self):
text = "EXA_API_KEY=exa_XYZ789abcdef000000000000000"
result = redact_sensitive_text(text)
assert "XYZ789abcdef" not in result
def test_exa_key_in_log_line(self):
text = "Using Exa client with key exa_XYZ789abcdef000000000000000"
result = redact_sensitive_text(text)
assert "XYZ789abcdef" not in result
def test_all_three_in_env_dump(self):
env_dump = (
"HOME=/home/user\n"
"ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu\n"
"TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000\n"
"EXA_API_KEY=exa_XYZ789abcdef000000000000000\n"
"SHELL=/bin/bash\n"
)
result = redact_sensitive_text(env_dump)
assert "abc123def456ghi" not in result
assert "ABCdef123456789" not in result
assert "XYZ789abcdef" not in result
assert "HOME=/home/user" in result
assert "SHELL=/bin/bash" in result
-11
View File
@@ -126,20 +126,9 @@ class TestAppMentionHandler:
"user": "testbot",
})
# Mock AsyncWebClient so multi-workspace auth_test is awaitable
mock_web_client = AsyncMock()
mock_web_client.auth_test = AsyncMock(return_value={
"user_id": "U_BOT",
"user": "testbot",
"team_id": "T_FAKE",
"team": "FakeTeam",
})
with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \
patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \
patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
patch("asyncio.create_task"):
asyncio.run(adapter.connect())
+16 -25
View File
@@ -60,43 +60,34 @@ class TestToolsSlashList:
class TestToolsSlashDisableWithReset:
def test_disable_applies_directly_and_resets_session(self):
"""Disable applies immediately (no confirmation prompt) and resets session."""
def test_disable_confirms_then_resets_session(self):
cli_obj = _make_cli(["web", "memory"])
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
patch("hermes_cli.tools_config.save_config"), \
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
patch("hermes_cli.config.load_config", return_value={}), \
patch.object(cli_obj, "new_session") as mock_reset:
patch.object(cli_obj, "new_session") as mock_reset, \
patch("builtins.input", return_value="y"):
cli_obj._handle_tools_command("/tools disable web")
mock_reset.assert_called_once()
assert "web" not in cli_obj.enabled_toolsets
def test_disable_does_not_prompt_for_confirmation(self):
"""Disable no longer uses input() — it applies directly."""
def test_disable_cancelled_does_not_reset(self):
cli_obj = _make_cli(["web", "memory"])
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
patch("hermes_cli.tools_config.save_config"), \
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
patch("hermes_cli.config.load_config", return_value={}), \
patch.object(cli_obj, "new_session"), \
patch("builtins.input") as mock_input:
with patch.object(cli_obj, "new_session") as mock_reset, \
patch("builtins.input", return_value="n"):
cli_obj._handle_tools_command("/tools disable web")
mock_input.assert_not_called()
mock_reset.assert_not_called()
# Toolsets unchanged
assert cli_obj.enabled_toolsets == {"web", "memory"}
def test_disable_always_resets_session(self):
"""Even without a confirmation prompt, disable always resets the session."""
def test_disable_eof_cancels(self):
cli_obj = _make_cli(["web", "memory"])
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
patch("hermes_cli.tools_config.save_config"), \
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
patch("hermes_cli.config.load_config", return_value={}), \
patch.object(cli_obj, "new_session") as mock_reset:
with patch.object(cli_obj, "new_session") as mock_reset, \
patch("builtins.input", side_effect=EOFError):
cli_obj._handle_tools_command("/tools disable web")
mock_reset.assert_called_once()
mock_reset.assert_not_called()
def test_disable_missing_name_prints_usage(self, capsys):
cli_obj = _make_cli()
@@ -110,15 +101,15 @@ class TestToolsSlashDisableWithReset:
class TestToolsSlashEnableWithReset:
def test_enable_applies_directly_and_resets_session(self):
"""Enable applies immediately (no confirmation prompt) and resets session."""
def test_enable_confirms_then_resets_session(self):
cli_obj = _make_cli(["memory"])
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["memory"]}}), \
patch("hermes_cli.tools_config.save_config"), \
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \
patch("hermes_cli.config.load_config", return_value={}), \
patch.object(cli_obj, "new_session") as mock_reset:
patch.object(cli_obj, "new_session") as mock_reset, \
patch("builtins.input", return_value="y"):
cli_obj._handle_tools_command("/tools enable web")
mock_reset.assert_called_once()
assert "web" in cli_obj.enabled_toolsets
+117 -158
View File
@@ -1,17 +1,13 @@
"""Tests for credential file passthrough and skills directory mounting."""
"""Tests for credential file passthrough registry (tools/credential_files.py)."""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from tools.credential_files import (
clear_credential_files,
get_credential_file_mounts,
get_skills_directory_mount,
iter_skills_files,
register_credential_file,
register_credential_files,
reset_config_cache,
@@ -19,8 +15,8 @@ from tools.credential_files import (
@pytest.fixture(autouse=True)
def _clean_state():
"""Reset module state between tests."""
def _clean_registry():
"""Reset registry between tests."""
clear_credential_files()
reset_config_cache()
yield
@@ -28,172 +24,135 @@ def _clean_state():
reset_config_cache()
class TestRegisterCredentialFiles:
def test_dict_with_path_key(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "token.json").write_text("{}")
class TestRegisterCredentialFile:
def test_registers_existing_file(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "token.json").write_text('{"token": "abc"}')
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([{"path": "token.json"}])
result = register_credential_file("token.json")
assert missing == []
assert result is True
mounts = get_credential_file_mounts()
assert len(mounts) == 1
assert mounts[0]["host_path"] == str(hermes_home / "token.json")
assert mounts[0]["host_path"] == str(tmp_path / "token.json")
assert mounts[0]["container_path"] == "/root/.hermes/token.json"
def test_dict_with_name_key_fallback(self, tmp_path):
"""Skills use 'name' instead of 'path' — both should work."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "google_token.json").write_text("{}")
def test_skips_missing_file(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([
{"name": "google_token.json", "description": "OAuth token"},
])
result = register_credential_file("nonexistent.json")
assert missing == []
mounts = get_credential_file_mounts()
assert len(mounts) == 1
assert "google_token.json" in mounts[0]["container_path"]
def test_string_entry(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "secret.key").write_text("key")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files(["secret.key"])
assert missing == []
mounts = get_credential_file_mounts()
assert len(mounts) == 1
def test_missing_file_reported(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([
{"name": "does_not_exist.json"},
])
assert "does_not_exist.json" in missing
assert result is False
assert get_credential_file_mounts() == []
def test_path_takes_precedence_over_name(self, tmp_path):
"""When both path and name are present, path wins."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "real.json").write_text("{}")
def test_custom_container_base(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "cred.json").write_text("{}")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([
{"path": "real.json", "name": "wrong.json"},
])
register_credential_file("cred.json", container_base="/home/user/.hermes")
mounts = get_credential_file_mounts()
assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json"
def test_deduplicates_by_container_path(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "token.json").write_text("{}")
register_credential_file("token.json")
register_credential_file("token.json")
mounts = get_credential_file_mounts()
assert len(mounts) == 1
class TestRegisterCredentialFiles:
def test_string_entries(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "a.json").write_text("{}")
(tmp_path / "b.json").write_text("{}")
missing = register_credential_files(["a.json", "b.json"])
assert missing == []
assert len(get_credential_file_mounts()) == 2
def test_dict_entries(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "token.json").write_text("{}")
missing = register_credential_files([
{"path": "token.json", "description": "OAuth token"},
])
assert missing == []
assert len(get_credential_file_mounts()) == 1
def test_returns_missing_files(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "exists.json").write_text("{}")
missing = register_credential_files([
"exists.json",
"missing.json",
{"path": "also_missing.json"},
])
assert missing == ["missing.json", "also_missing.json"]
assert len(get_credential_file_mounts()) == 1
def test_empty_list(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert register_credential_files([]) == []
class TestConfigCredentialFiles:
def test_loads_from_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "oauth.json").write_text("{}")
(tmp_path / "config.yaml").write_text(
"terminal:\n credential_files:\n - oauth.json\n"
)
mounts = get_credential_file_mounts()
assert "real.json" in mounts[0]["container_path"]
assert len(mounts) == 1
assert mounts[0]["host_path"] == str(tmp_path / "oauth.json")
def test_config_skips_missing_files(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(
"terminal:\n credential_files:\n - nonexistent.json\n"
)
mounts = get_credential_file_mounts()
assert mounts == []
def test_combines_skill_and_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "skill_token.json").write_text("{}")
(tmp_path / "config_token.json").write_text("{}")
(tmp_path / "config.yaml").write_text(
"terminal:\n credential_files:\n - config_token.json\n"
)
register_credential_file("skill_token.json")
mounts = get_credential_file_mounts()
assert len(mounts) == 2
paths = {m["container_path"] for m in mounts}
assert "/root/.hermes/skill_token.json" in paths
assert "/root/.hermes/config_token.json" in paths
class TestSkillsDirectoryMount:
def test_returns_mount_when_skills_dir_exists(self, tmp_path):
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "test-skill").mkdir()
(skills_dir / "test-skill" / "SKILL.md").write_text("# test")
class TestGetMountsRechecksExistence:
def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
token = tmp_path / "token.json"
token.write_text("{}")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
register_credential_file("token.json")
assert len(get_credential_file_mounts()) == 1
assert mount is not None
assert mount["host_path"] == str(skills_dir)
assert mount["container_path"] == "/root/.hermes/skills"
def test_returns_none_when_no_skills_dir(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
assert mount is None
def test_custom_container_base(self, tmp_path):
hermes_home = tmp_path / ".hermes"
(hermes_home / "skills").mkdir(parents=True)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount(container_base="/home/user/.hermes")
assert mount["container_path"] == "/home/user/.hermes/skills"
def test_symlinks_are_sanitized(self, tmp_path):
"""Symlinks in skills dir should be excluded from the mount."""
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "legit.md").write_text("# real skill")
# Create a symlink pointing outside the skills tree
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET")
(skills_dir / "evil_link").symlink_to(secret)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
assert mount is not None
# The mount path should be a sanitized copy, not the original
safe_path = Path(mount["host_path"])
assert safe_path != skills_dir
# Legitimate file should be present
assert (safe_path / "legit.md").exists()
assert (safe_path / "legit.md").read_text() == "# real skill"
# Symlink should NOT be present
assert not (safe_path / "evil_link").exists()
def test_no_symlinks_returns_original_dir(self, tmp_path):
"""When no symlinks exist, the original dir is returned (no copy)."""
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "skill.md").write_text("ok")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
assert mount["host_path"] == str(skills_dir)
class TestIterSkillsFiles:
def test_returns_files_skipping_symlinks(self, tmp_path):
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
(skills_dir / "cat" / "myskill").mkdir(parents=True)
(skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill")
(skills_dir / "cat" / "myskill" / "scripts").mkdir()
(skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash")
# Add a symlink that should be filtered
secret = tmp_path / "secret"
secret.write_text("nope")
(skills_dir / "cat" / "myskill" / "evil").symlink_to(secret)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
files = iter_skills_files()
paths = {f["container_path"] for f in files}
assert "/root/.hermes/skills/cat/myskill/SKILL.md" in paths
assert "/root/.hermes/skills/cat/myskill/scripts/run.sh" in paths
# Symlink should be excluded
assert not any("evil" in f["container_path"] for f in files)
def test_empty_when_no_skills_dir(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
assert iter_skills_files() == []
# Delete the file after registration
token.unlink()
assert get_credential_file_mounts() == []
-4
View File
@@ -61,10 +61,6 @@ def make_env(daytona_sdk, monkeypatch):
"""Factory that creates a DaytonaEnvironment with a mocked SDK."""
# Prevent is_interrupted from interfering
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)
# Prevent skills/credential sync from consuming mock exec calls
monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None)
monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: [])
def _factory(
sandbox=None,
-79
View File
@@ -5,7 +5,6 @@ from pathlib import Path
from unittest.mock import patch, MagicMock
import httpx
import pytest
from tools.skills_hub import (
GitHubAuth,
@@ -649,29 +648,6 @@ class TestWellKnownSkillSource:
assert bundle.files["SKILL.md"] == "# Code Review\n"
assert bundle.files["references/checklist.md"] == "- [ ] security\n"
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_fetch_rejects_unsafe_file_paths_from_well_known_endpoint(self, mock_get, _mock_read_cache, _mock_write_cache):
def fake_get(url, *args, **kwargs):
if url.endswith("/index.json"):
return MagicMock(status_code=200, json=lambda: {
"skills": [{
"name": "code-review",
"description": "Review code",
"files": ["SKILL.md", "../../../escape.txt"],
}]
})
if url.endswith("/code-review/SKILL.md"):
return MagicMock(status_code=200, text="# Code Review\n")
raise AssertionError(url)
mock_get.side_effect = fake_get
bundle = self._source().fetch("well-known:https://example.com/.well-known/skills/code-review")
assert bundle is None
class TestCheckForSkillUpdates:
def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path):
@@ -1167,61 +1143,6 @@ class TestQuarantineBundleBinaryAssets:
assert (q_path / "SKILL.md").read_text(encoding="utf-8").startswith("---")
assert (q_path / "assets" / "neutts-cli" / "samples" / "jo.wav").read_bytes() == b"RIFF\x00\x01fakewav"
def test_quarantine_bundle_rejects_traversal_file_paths(self, tmp_path):
import tools.skills_hub as hub
hub_dir = tmp_path / "skills" / ".hub"
with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \
patch.object(hub, "HUB_DIR", hub_dir), \
patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \
patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \
patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \
patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \
patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"):
bundle = SkillBundle(
name="demo",
files={
"SKILL.md": "---\nname: demo\n---\n",
"../../../escape.txt": "owned",
},
source="well-known",
identifier="well-known:https://example.com/.well-known/skills/demo",
trust_level="community",
)
with pytest.raises(ValueError, match="Unsafe bundle file path"):
quarantine_bundle(bundle)
assert not (tmp_path / "skills" / "escape.txt").exists()
def test_quarantine_bundle_rejects_absolute_file_paths(self, tmp_path):
import tools.skills_hub as hub
hub_dir = tmp_path / "skills" / ".hub"
absolute_target = tmp_path / "outside.txt"
with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \
patch.object(hub, "HUB_DIR", hub_dir), \
patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \
patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \
patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \
patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \
patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"):
bundle = SkillBundle(
name="demo",
files={
"SKILL.md": "---\nname: demo\n---\n",
str(absolute_target): "owned",
},
source="well-known",
identifier="well-known:https://example.com/.well-known/skills/demo",
trust_level="community",
)
with pytest.raises(ValueError, match="Unsafe bundle file path"):
quarantine_bundle(bundle)
assert not absolute_target.exists()
# ---------------------------------------------------------------------------
# GitHubSource._download_directory — tree API + fallback (#2940)
-6
View File
@@ -259,12 +259,6 @@ def test_check_website_access_uses_dynamic_hermes_home(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Invalidate the module-level cache so the new HERMES_HOME is picked up.
# A prior test may have cached a default policy (enabled=False) under the
# old HERMES_HOME set by the autouse _isolate_hermes_home fixture.
from tools.website_policy import invalidate_cache
invalidate_cache()
blocked = check_website_access("https://dynamic.example/path")
assert blocked is not None
+1 -102
View File
@@ -83,7 +83,7 @@ def register_credential_files(
if isinstance(entry, str):
rel_path = entry.strip()
elif isinstance(entry, dict):
rel_path = (entry.get("path") or entry.get("name") or "").strip()
rel_path = (entry.get("path") or "").strip()
else:
continue
if not rel_path:
@@ -152,107 +152,6 @@ def get_credential_file_mounts() -> List[Dict[str, str]]:
]
def get_skills_directory_mount(
container_base: str = "/root/.hermes",
) -> Dict[str, str] | None:
"""Return mount info for a symlink-safe copy of the skills directory.
Skills may include ``scripts/``, ``templates/``, and ``references/``
subdirectories that the agent needs to execute inside remote sandboxes.
**Security:** Bind mounts follow symlinks, so a malicious symlink inside
the skills tree could expose arbitrary host files to the container. When
symlinks are detected, this function creates a sanitized copy (regular
files only) in a temp directory and returns that path instead. When no
symlinks are present (the common case), the original directory is returned
directly with zero overhead.
Returns a dict with ``host_path`` and ``container_path`` keys, or None.
"""
hermes_home = _resolve_hermes_home()
skills_dir = hermes_home / "skills"
if not skills_dir.is_dir():
return None
host_path = _safe_skills_path(skills_dir)
return {
"host_path": host_path,
"container_path": f"{container_base.rstrip('/')}/skills",
}
_safe_skills_tempdir: Path | None = None
def _safe_skills_path(skills_dir: Path) -> str:
"""Return *skills_dir* if symlink-free, else a sanitized temp copy."""
global _safe_skills_tempdir
symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()]
if not symlinks:
return str(skills_dir)
for link in symlinks:
logger.warning("credential_files: skipping symlink in skills dir: %s -> %s",
link, os.readlink(link))
import atexit
import shutil
import tempfile
# Reuse the same temp dir across calls to avoid accumulation.
if _safe_skills_tempdir and _safe_skills_tempdir.is_dir():
shutil.rmtree(_safe_skills_tempdir, ignore_errors=True)
safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
_safe_skills_tempdir = safe_dir
for item in skills_dir.rglob("*"):
if item.is_symlink():
continue
rel = item.relative_to(skills_dir)
target = safe_dir / rel
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
elif item.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(item), str(target))
def _cleanup():
if safe_dir.is_dir():
shutil.rmtree(safe_dir, ignore_errors=True)
atexit.register(_cleanup)
logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir)
return str(safe_dir)
def iter_skills_files(
container_base: str = "/root/.hermes",
) -> List[Dict[str, str]]:
"""Yield individual (host_path, container_path) entries for skills files.
Skips symlinks entirely. Preferred for backends that upload files
individually (Daytona, Modal) rather than mounting a directory.
"""
hermes_home = _resolve_hermes_home()
skills_dir = hermes_home / "skills"
if not skills_dir.is_dir():
return []
container_root = f"{container_base.rstrip('/')}/skills"
result: List[Dict[str, str]] = []
for item in skills_dir.rglob("*"):
if item.is_symlink() or not item.is_file():
continue
rel = item.relative_to(skills_dir)
result.append({
"host_path": str(item),
"container_path": f"{container_root}/{rel}",
})
return result
def clear_credential_files() -> None:
"""Reset the skill-scoped registry (e.g. on session reset)."""
_registered_files.clear()
+8 -57
View File
@@ -113,61 +113,15 @@ class DaytonaEnvironment(BaseEnvironment):
logger.info("Daytona: created sandbox %s for task %s",
self._sandbox.id, task_id)
# Detect remote home dir first so mounts go to the right place.
self._remote_home = "/root"
try:
home = self._sandbox.process.exec("echo $HOME").result.strip()
if home:
self._remote_home = home
if self._requested_cwd in ("~", "/home/daytona"):
# Resolve cwd: detect actual home dir inside the sandbox
if self._requested_cwd in ("~", "/home/daytona"):
try:
home = self._sandbox.process.exec("echo $HOME").result.strip()
if home:
self.cwd = home
except Exception:
pass
logger.info("Daytona: resolved home to %s, cwd to %s", self._remote_home, self.cwd)
# Track synced files to avoid redundant uploads.
# Key: remote_path, Value: (mtime, size)
self._synced_files: Dict[str, tuple] = {}
# Upload credential files and skills directory into the sandbox.
self._sync_skills_and_credentials()
def _upload_if_changed(self, host_path: str, remote_path: str) -> bool:
"""Upload a file if its mtime/size changed since last sync."""
hp = Path(host_path)
try:
stat = hp.stat()
file_key = (stat.st_mtime, stat.st_size)
except OSError:
return False
if self._synced_files.get(remote_path) == file_key:
return False
try:
parent = str(Path(remote_path).parent)
self._sandbox.process.exec(f"mkdir -p {parent}")
self._sandbox.fs.upload_file(host_path, remote_path)
self._synced_files[remote_path] = file_key
return True
except Exception as e:
logger.debug("Daytona: upload failed %s: %s", host_path, e)
return False
def _sync_skills_and_credentials(self) -> None:
"""Upload changed credential files and skill files into the sandbox."""
container_base = f"{self._remote_home}/.hermes"
try:
from tools.credential_files import get_credential_file_mounts, iter_skills_files
for mount_entry in get_credential_file_mounts():
remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1)
if self._upload_if_changed(mount_entry["host_path"], remote_path):
logger.debug("Daytona: synced credential %s", remote_path)
for entry in iter_skills_files(container_base=container_base):
if self._upload_if_changed(entry["host_path"], entry["container_path"]):
logger.debug("Daytona: synced skill %s", entry["container_path"])
except Exception as e:
logger.debug("Daytona: could not sync skills/credentials: %s", e)
except Exception:
pass # leave cwd as-is; sandbox will use its own default
logger.info("Daytona: resolved cwd to %s", self.cwd)
def _ensure_sandbox_ready(self):
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
@@ -237,9 +191,6 @@ class DaytonaEnvironment(BaseEnvironment):
stdin_data: Optional[str] = None) -> dict:
with self._lock:
self._ensure_sandbox_ready()
# Incremental sync before each command so mid-session credential
# refreshes and skill updates are picked up.
self._sync_skills_and_credentials()
if stdin_data is not None:
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
+1 -15
View File
@@ -315,7 +315,7 @@ class DockerEnvironment(BaseEnvironment):
# Mount credential files (OAuth tokens, etc.) declared by skills.
# Read-only so the container can authenticate but not modify host creds.
try:
from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
from tools.credential_files import get_credential_file_mounts
for mount_entry in get_credential_file_mounts():
volume_args.extend([
@@ -327,20 +327,6 @@ class DockerEnvironment(BaseEnvironment):
mount_entry["host_path"],
mount_entry["container_path"],
)
# Mount the skills directory so skill scripts/templates are
# available inside the container at the same relative path.
skills_mount = get_skills_directory_mount()
if skills_mount:
volume_args.extend([
"-v",
f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro",
])
logger.info(
"Docker: mounting skills dir %s -> %s",
skills_mount["host_path"],
skills_mount["container_path"],
)
except Exception as e:
logger.debug("Docker: could not load credential file mounts: %s", e)
+52 -61
View File
@@ -142,7 +142,7 @@ class ModalEnvironment(BaseEnvironment):
# external services but can't modify the host's credentials.
cred_mounts = []
try:
from tools.credential_files import get_credential_file_mounts, iter_skills_files
from tools.credential_files import get_credential_file_mounts
for mount_entry in get_credential_file_mounts():
cred_mounts.append(
@@ -156,18 +156,6 @@ class ModalEnvironment(BaseEnvironment):
mount_entry["host_path"],
mount_entry["container_path"],
)
# Mount individual skill files (symlinks filtered out).
skills_files = iter_skills_files()
for entry in skills_files:
cred_mounts.append(
_modal.Mount.from_local_file(
entry["host_path"],
remote_path=entry["container_path"],
)
)
if skills_files:
logger.info("Modal: mounting %d skill files", len(skills_files))
except Exception as e:
logger.debug("Modal: could not load credential file mounts: %s", e)
@@ -196,69 +184,72 @@ class ModalEnvironment(BaseEnvironment):
self._app, self._sandbox = self._worker.run_coroutine(
_create_sandbox(), timeout=300
)
# Track synced files to avoid redundant pushes.
# Track synced credential files to avoid redundant pushes.
# Key: container_path, Value: (mtime, size) of last synced version.
self._synced_files: Dict[str, tuple] = {}
self._synced_creds: Dict[str, tuple] = {}
logger.info("Modal: sandbox created (task=%s)", self._task_id)
def _push_file_to_sandbox(self, host_path: str, container_path: str) -> bool:
"""Push a single file into the sandbox if changed. Returns True if synced."""
hp = Path(host_path)
try:
stat = hp.stat()
file_key = (stat.st_mtime, stat.st_size)
except OSError:
return False
def _sync_credential_files(self) -> None:
"""Push credential files into the running sandbox.
if self._synced_files.get(container_path) == file_key:
return False
try:
content = hp.read_bytes()
except Exception:
return False
import base64
b64 = base64.b64encode(content).decode("ascii")
container_dir = str(Path(container_path).parent)
cmd = (
f"mkdir -p {shlex.quote(container_dir)} && "
f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
)
async def _write():
proc = await self._sandbox.exec.aio("bash", "-c", cmd)
await proc.wait.aio()
self._worker.run_coroutine(_write(), timeout=15)
self._synced_files[container_path] = file_key
return True
def _sync_files(self) -> None:
"""Push credential files and skill files into the running sandbox.
Runs before each command. Uses mtime+size caching so only changed
files are pushed (~13μs overhead in the no-op case).
Mounts are set at sandbox creation, but credentials may be created
later (e.g. OAuth setup mid-session). This writes the current file
content into the sandbox via exec(), so new/updated credentials are
available without recreating the sandbox.
"""
try:
from tools.credential_files import get_credential_file_mounts, iter_skills_files
from tools.credential_files import get_credential_file_mounts
for entry in get_credential_file_mounts():
if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
logger.debug("Modal: synced credential %s", entry["container_path"])
mounts = get_credential_file_mounts()
if not mounts:
return
for entry in iter_skills_files():
if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
logger.debug("Modal: synced skill file %s", entry["container_path"])
for entry in mounts:
host_path = entry["host_path"]
container_path = entry["container_path"]
hp = Path(host_path)
try:
stat = hp.stat()
file_key = (stat.st_mtime, stat.st_size)
except OSError:
continue
# Skip if already synced with same mtime+size
if self._synced_creds.get(container_path) == file_key:
continue
try:
content = hp.read_text(encoding="utf-8")
except Exception:
continue
# Write via base64 to avoid shell escaping issues with JSON
import base64
b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
container_dir = str(Path(container_path).parent)
cmd = (
f"mkdir -p {shlex.quote(container_dir)} && "
f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
)
_cp = container_path # capture for closure
async def _write():
proc = await self._sandbox.exec.aio("bash", "-c", cmd)
await proc.wait.aio()
self._worker.run_coroutine(_write(), timeout=15)
self._synced_creds[container_path] = file_key
logger.debug("Modal: synced credential %s -> %s", host_path, container_path)
except Exception as e:
logger.debug("Modal: file sync failed: %s", e)
logger.debug("Modal: credential file sync failed: %s", e)
def execute(self, command: str, cwd: str = "", *,
timeout: int | None = None,
stdin_data: str | None = None) -> dict:
# Sync credential files before each command so mid-session
# OAuth setups are picked up without requiring a restart.
self._sync_files()
self._sync_credential_files()
if stdin_data is not None:
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
-22
View File
@@ -254,28 +254,6 @@ class SingularityEnvironment(BaseEnvironment):
else:
cmd.append("--writable-tmpfs")
# Mount credential files and skills directory (read-only).
try:
from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
for mount_entry in get_credential_file_mounts():
cmd.extend(["--bind", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro"])
logger.info(
"Singularity: binding credential %s -> %s",
mount_entry["host_path"],
mount_entry["container_path"],
)
skills_mount = get_skills_directory_mount()
if skills_mount:
cmd.extend(["--bind", f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro"])
logger.info(
"Singularity: binding skills dir %s -> %s",
skills_mount["host_path"],
skills_mount["container_path"],
)
except Exception as e:
logger.debug("Singularity: could not load credential/skills mounts: %s", e)
# Resource limits (cgroup-based, may require root or appropriate config)
if self._memory > 0:
cmd.extend(["--memory", f"{self._memory}M"])
-75
View File
@@ -55,8 +55,6 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
self.control_socket = self.control_dir / f"{user}@{host}:{port}.sock"
_ensure_ssh_available()
self._establish_connection()
self._remote_home = self._detect_remote_home()
self._sync_skills_and_credentials()
if self.persistent:
self._init_persistent_shell()
@@ -89,79 +87,6 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
except subprocess.TimeoutExpired:
raise RuntimeError(f"SSH connection to {self.user}@{self.host} timed out")
def _detect_remote_home(self) -> str:
"""Detect the remote user's home directory."""
try:
cmd = self._build_ssh_command()
cmd.append("echo $HOME")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
home = result.stdout.strip()
if home and result.returncode == 0:
logger.debug("SSH: remote home = %s", home)
return home
except Exception:
pass
# Fallback: guess from username
if self.user == "root":
return "/root"
return f"/home/{self.user}"
def _sync_skills_and_credentials(self) -> None:
"""Rsync skills directory and credential files to the remote host."""
try:
container_base = f"{self._remote_home}/.hermes"
from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
rsync_base = ["rsync", "-az", "--timeout=30", "--safe-links"]
ssh_opts = f"ssh -o ControlPath={self.control_socket} -o ControlMaster=auto"
if self.port != 22:
ssh_opts += f" -p {self.port}"
if self.key_path:
ssh_opts += f" -i {self.key_path}"
rsync_base.extend(["-e", ssh_opts])
dest_prefix = f"{self.user}@{self.host}"
# Sync individual credential files (remap /root/.hermes to detected home)
for mount_entry in get_credential_file_mounts():
remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1)
parent_dir = str(Path(remote_path).parent)
mkdir_cmd = self._build_ssh_command()
mkdir_cmd.append(f"mkdir -p {parent_dir}")
subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10)
cmd = rsync_base + [mount_entry["host_path"], f"{dest_prefix}:{remote_path}"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
logger.info("SSH: synced credential %s -> %s", mount_entry["host_path"], remote_path)
else:
logger.debug("SSH: rsync credential failed: %s", result.stderr.strip())
# Sync skills directory (remap to detected home)
skills_mount = get_skills_directory_mount(container_base=container_base)
if skills_mount:
remote_path = skills_mount["container_path"]
mkdir_cmd = self._build_ssh_command()
mkdir_cmd.append(f"mkdir -p {remote_path}")
subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10)
cmd = rsync_base + [
skills_mount["host_path"].rstrip("/") + "/",
f"{dest_prefix}:{remote_path}/",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
logger.info("SSH: synced skills dir %s -> %s", skills_mount["host_path"], remote_path)
else:
logger.debug("SSH: rsync skills dir failed: %s", result.stderr.strip())
except Exception as e:
logger.debug("SSH: could not sync skills/credentials: %s", e)
def execute(self, command: str, cwd: str = "", *,
timeout: int | None = None,
stdin_data: str | None = None) -> dict:
# Incremental sync before each command so mid-session credential
# refreshes and skill updates are picked up.
self._sync_skills_and_credentials()
return super().execute(command, cwd, timeout=timeout, stdin_data=stdin_data)
_poll_interval_start: float = 0.15 # SSH: higher initial interval (150ms) for network latency
@property
+16 -82
View File
@@ -24,7 +24,7 @@ import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from pathlib import Path
from hermes_constants import get_hermes_home
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urlparse, urlunparse
@@ -85,43 +85,6 @@ class SkillBundle:
metadata: Dict[str, Any] = field(default_factory=dict)
def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:
"""Normalize and validate bundle-controlled paths before touching disk."""
if not isinstance(path_value, str):
raise ValueError(f"Unsafe {field_name}: expected a string")
raw = path_value.strip()
if not raw:
raise ValueError(f"Unsafe {field_name}: empty path")
normalized = raw.replace("\\", "/")
path = PurePosixPath(normalized)
parts = [part for part in path.parts if part not in ("", ".")]
if normalized.startswith("/") or path.is_absolute():
raise ValueError(f"Unsafe {field_name}: {path_value}")
if not parts or any(part == ".." for part in parts):
raise ValueError(f"Unsafe {field_name}: {path_value}")
if re.fullmatch(r"[A-Za-z]:", parts[0]):
raise ValueError(f"Unsafe {field_name}: {path_value}")
if not allow_nested and len(parts) != 1:
raise ValueError(f"Unsafe {field_name}: {path_value}")
return "/".join(parts)
def _validate_skill_name(name: str) -> str:
return _normalize_bundle_path(name, field_name="skill name", allow_nested=False)
def _validate_category_name(category: str) -> str:
return _normalize_bundle_path(category, field_name="category", allow_nested=False)
def _validate_bundle_rel_path(rel_path: str) -> str:
return _normalize_bundle_path(rel_path, field_name="bundle file path", allow_nested=True)
# ---------------------------------------------------------------------------
# GitHub Authentication
# ---------------------------------------------------------------------------
@@ -738,12 +701,6 @@ class WellKnownSkillSource(SkillSource):
if not parsed:
return None
try:
skill_name = _validate_skill_name(parsed["skill_name"])
except ValueError:
logger.warning("Well-known skill identifier contained unsafe skill name: %s", identifier)
return None
entry = self._index_entry(parsed["index_url"], parsed["skill_name"])
if not entry:
return None
@@ -756,28 +713,19 @@ class WellKnownSkillSource(SkillSource):
for rel_path in files:
if not isinstance(rel_path, str) or not rel_path:
continue
try:
safe_rel_path = _validate_bundle_rel_path(rel_path)
except ValueError:
logger.warning(
"Well-known skill %s advertised unsafe file path: %r",
identifier,
rel_path,
)
return None
text = self._fetch_text(f"{parsed['skill_url']}/{safe_rel_path}")
text = self._fetch_text(f"{parsed['skill_url']}/{rel_path}")
if text is None:
return None
downloaded[safe_rel_path] = text
downloaded[rel_path] = text
if "SKILL.md" not in downloaded:
return None
return SkillBundle(
name=skill_name,
name=parsed["skill_name"],
files=downloaded,
source="well-known",
identifier=self._wrap_identifier(parsed["base_url"], skill_name),
identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]),
trust_level="community",
metadata={
"index_url": parsed["index_url"],
@@ -1804,10 +1752,9 @@ class ClawHubSource(SkillSource):
for info in zf.infolist():
if info.is_dir():
continue
try:
name = _validate_bundle_rel_path(info.filename)
except ValueError:
logger.debug("Skipping unsafe ZIP member path: %s", info.filename)
# Sanitize path — strip leading slashes and ..
name = info.filename.lstrip("/")
if ".." in name or name.startswith("/"):
continue
# Only extract text-sized files (skip large binaries)
if info.file_size > 500_000:
@@ -2476,19 +2423,13 @@ def ensure_hub_dirs() -> None:
def quarantine_bundle(bundle: SkillBundle) -> Path:
"""Write a skill bundle to the quarantine directory for scanning."""
ensure_hub_dirs()
skill_name = _validate_skill_name(bundle.name)
validated_files: List[Tuple[str, Union[str, bytes]]] = []
for rel_path, file_content in bundle.files.items():
safe_rel_path = _validate_bundle_rel_path(rel_path)
validated_files.append((safe_rel_path, file_content))
dest = QUARANTINE_DIR / skill_name
dest = QUARANTINE_DIR / bundle.name
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True)
for rel_path, file_content in validated_files:
file_dest = dest.joinpath(*rel_path.split("/"))
for rel_path, file_content in bundle.files.items():
file_dest = dest / rel_path
file_dest.parent.mkdir(parents=True, exist_ok=True)
if isinstance(file_content, bytes):
file_dest.write_bytes(file_content)
@@ -2506,17 +2447,10 @@ def install_from_quarantine(
scan_result: ScanResult,
) -> Path:
"""Move a scanned skill from quarantine into the skills directory."""
safe_skill_name = _validate_skill_name(skill_name)
safe_category = _validate_category_name(category) if category else ""
quarantine_resolved = quarantine_path.resolve()
quarantine_root = QUARANTINE_DIR.resolve()
if not quarantine_resolved.is_relative_to(quarantine_root):
raise ValueError(f"Unsafe quarantine path: {quarantine_path}")
if safe_category:
install_dir = SKILLS_DIR / safe_category / safe_skill_name
if category:
install_dir = SKILLS_DIR / category / skill_name
else:
install_dir = SKILLS_DIR / safe_skill_name
install_dir = SKILLS_DIR / skill_name
if install_dir.exists():
shutil.rmtree(install_dir)
@@ -2527,7 +2461,7 @@ def install_from_quarantine(
# Record in lock file
lock = HubLockFile()
lock.record_install(
name=safe_skill_name,
name=skill_name,
source=bundle.source,
identifier=bundle.identifier,
trust_level=bundle.trust_level,
@@ -2539,7 +2473,7 @@ def install_from_quarantine(
)
append_audit_log(
"INSTALL", safe_skill_name, bundle.source,
"INSTALL", skill_name, bundle.source,
bundle.trust_level, scan_result.verdict,
content_hash(install_dir),
)
+1 -27
View File
@@ -45,28 +45,6 @@ logger = logging.getLogger(__name__)
_debug = DebugSession("vision_tools", env_var="VISION_TOOLS_DEBUG")
# Configurable HTTP download timeout for _download_image().
# Separate from auxiliary.vision.timeout which governs the LLM API call.
# Resolution: config.yaml auxiliary.vision.download_timeout → env var → 30s default.
def _resolve_download_timeout() -> float:
env_val = os.getenv("HERMES_VISION_DOWNLOAD_TIMEOUT", "").strip()
if env_val:
try:
return float(env_val)
except ValueError:
pass
try:
from hermes_cli.config import load_config
cfg = load_config()
val = cfg.get("auxiliary", {}).get("vision", {}).get("download_timeout")
if val is not None:
return float(val)
except Exception:
pass
return 30.0
_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout()
def _validate_image_url(url: str) -> bool:
"""
@@ -168,7 +146,7 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
# Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum)
# SSRF: event_hooks validates each redirect target against private IP ranges
async with httpx.AsyncClient(
timeout=_VISION_DOWNLOAD_TIMEOUT,
timeout=30.0,
follow_redirects=True,
event_hooks={"response": [_ssrf_redirect_guard]},
) as client:
@@ -205,10 +183,6 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
exc_info=True,
)
if last_error is None:
raise RuntimeError(
f"_download_image exited retry loop without attempting (max_retries={max_retries})"
)
raise last_error
+2 -3
View File
@@ -1018,8 +1018,7 @@ auxiliary:
model: "" # e.g. "openai/gpt-4o", "google/gemini-2.5-flash"
base_url: "" # Custom OpenAI-compatible endpoint (overrides provider)
api_key: "" # API key for base_url (falls back to OPENAI_API_KEY)
timeout: 30 # seconds — LLM API call; increase for slow local vision models
download_timeout: 30 # seconds — image HTTP download; increase for slow connections
timeout: 30 # seconds — increase for slow local vision models
# Web page summarization + browser page text extraction
web_extract:
@@ -1043,7 +1042,7 @@ auxiliary:
```
:::tip
Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks. Vision also has a separate `download_timeout` (default 30s) for the HTTP image download — increase this for slow connections or self-hosted image servers.
Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks.
:::
:::info
+2 -2
View File
@@ -32,8 +32,8 @@ Set it to `false` only if you explicitly want one shared conversation per chat.
## Step 1: Create a Feishu / Lark App
1. Open the Feishu or Lark developer console:
- Feishu: [https://open.feishu.cn/](https://open.feishu.cn/)
- Lark: [https://open.larksuite.com/](https://open.larksuite.com/)
- Feishu: <https://open.feishu.cn/>
- Lark: <https://open.larksuite.com/>
2. Create a new app.
3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**.
4. Enable the **Bot** capability for the app.