Compare commits

..

1 Commits

Author SHA1 Message Date
teknium1 1e373745a9 fix: smart vision setup that respects the user's chosen provider
The old flow blindly asked for an OpenRouter API key after ANY non-OR
provider selection, even for Nous Portal and Codex which already
support vision natively. This was confusing and annoying.

New behavior:
- OpenRouter: skip — vision uses Gemini via their OR key
- Nous Portal OAuth: skip — vision uses Gemini via Nous
- OpenAI Codex: skip — gpt-5.3-codex supports vision
- Custom endpoint (api.openai.com): show OpenAI vision model picker
  (gpt-4o, gpt-4o-mini, gpt-4.1, etc.), saves AUXILIARY_VISION_MODEL
- Custom (other) / z.ai / kimi / minimax / nous-api:
  - First checks if existing OR/Nous creds already cover vision
  - If not, offers friendly choice: OpenRouter / OpenAI / Skip
  - No more 'enter OpenRouter key' thrown in your face

Also fixes the setup summary to check actual vision availability
across all providers instead of hardcoding 'requires OPENROUTER_API_KEY'.
MoA still correctly requires OpenRouter (calls multiple frontier models).
2026-03-11 07:59:07 -07:00
28 changed files with 518 additions and 2114 deletions
-18
View File
@@ -1,18 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml,json,toml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+18 -19
View File
@@ -1,4 +1,4 @@
name: CI
name: Tests
on:
push:
@@ -6,38 +6,37 @@ on:
pull_request:
branches: [main]
# Cancel in-progress runs for the same PR/branch
concurrency:
group: ci-${{ github.ref }}
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 3
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uvx ruff check run_agent.py model_tools.py toolsets.py cli.py hermes_state.py batch_runner.py tools/ hermes_cli/ gateway/ agent/ cron/
- run: uvx ruff format --check run_agent.py model_tools.py toolsets.py cli.py hermes_state.py batch_runner.py tools/ hermes_cli/ gateway/ agent/ cron/
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- run: uv python install 3.11
- run: |
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- run: |
- name: Run tests
run: |
source .venv/bin/activate
python -m pytest tests/ -q --ignore=tests/integration --tb=short -n auto
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
-18
View File
@@ -1,18 +0,0 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.12
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-yaml
args: [--allow-multiple-documents]
- id: check-added-large-files
args: [--maxkb=500]
-44
View File
@@ -333,8 +333,6 @@ metadata:
hermes:
tags: [Category, Subcategory, Keywords]
related_skills: [other-skill-name]
fallback_for_toolsets: [web] # Optional — show only when toolset is unavailable
requires_toolsets: [terminal] # Optional — show only when toolset is available
---
# Skill Title
@@ -369,48 +367,6 @@ platforms: [windows] # Windows only
If the field is omitted or empty, the skill loads on all platforms (backward compatible). See `skills/apple/` for examples of macOS-only skills.
### Conditional skill activation
Skills can declare conditions that control when they appear in the system prompt, based on which tools and toolsets are available in the current session. This is primarily used for **fallback skills** — alternatives that should only be shown when a primary tool is unavailable.
Four fields are supported under `metadata.hermes`:
```yaml
metadata:
hermes:
fallback_for_toolsets: [web] # Show ONLY when these toolsets are unavailable
requires_toolsets: [terminal] # Show ONLY when these toolsets are available
fallback_for_tools: [web_search] # Show ONLY when these specific tools are unavailable
requires_tools: [terminal] # Show ONLY when these specific tools are available
```
**Semantics:**
- `fallback_for_*`: The skill is a backup. It is **hidden** when the listed tools/toolsets are available, and **shown** when they are unavailable. Use this for free alternatives to premium tools.
- `requires_*`: The skill needs certain tools to function. It is **hidden** when the listed tools/toolsets are unavailable. Use this for skills that depend on specific capabilities (e.g., a skill that only makes sense with terminal access).
- If both are specified, both conditions must be satisfied for the skill to appear.
- If neither is specified, the skill is always shown (backward compatible).
**Examples:**
```yaml
# DuckDuckGo search — shown when Firecrawl (web toolset) is unavailable
metadata:
hermes:
fallback_for_toolsets: [web]
# Smart home skill — only useful when terminal is available
metadata:
hermes:
requires_toolsets: [terminal]
# Local browser fallback — shown when Browserbase is unavailable
metadata:
hermes:
fallback_for_toolsets: [browser]
```
The filtering happens at prompt build time in `agent/prompt_builder.py`. The `build_skills_system_prompt()` function receives the set of available tools and toolsets from the agent and uses `_skill_should_show()` to evaluate each skill's conditions.
### Skill guidelines
- **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`).
-69
View File
@@ -1,69 +0,0 @@
.DEFAULT_GOAL := help
SHELL := /bin/bash
VENV := .venv
UV := uv
SRC := run_agent.py model_tools.py toolsets.py cli.py hermes_state.py batch_runner.py \
tools/ hermes_cli/ gateway/ agent/ cron/
# ─── Setup ──────────────────────────────────────────────────────────────────────
.PHONY: setup sync clean
setup: ## Full dev setup (venv + deps + pre-commit)
$(UV) venv $(VENV) --python 3.11
. $(VENV)/bin/activate && $(UV) pip install -e ".[all,dev]"
. $(VENV)/bin/activate && $(UV) pip install -e "./mini-swe-agent"
. $(VENV)/bin/activate && pre-commit install
@echo "\n✅ Setup complete. Run: source $(VENV)/bin/activate"
sync: ## Reinstall deps into existing venv
. $(VENV)/bin/activate && $(UV) pip install -e ".[all,dev]"
clean: ## Remove build artifacts and caches
rm -rf .ruff_cache .mypy_cache .pytest_cache dist build *.egg-info
find . -type d -name __pycache__ -not -path "./.venv/*" -exec rm -rf {} +
# ─── Quality ────────────────────────────────────────────────────────────────────
.PHONY: lint fmt check
lint: ## Check lint + formatting (no changes)
. $(VENV)/bin/activate && ruff check $(SRC)
. $(VENV)/bin/activate && ruff format --check $(SRC)
fmt: ## Auto-fix lint + format
. $(VENV)/bin/activate && ruff format $(SRC)
. $(VENV)/bin/activate && ruff check --fix $(SRC)
check: lint test ## Lint + test (mirrors CI)
# ─── Test ───────────────────────────────────────────────────────────────────────
.PHONY: test test-fast test-watch
test: ## Run full test suite
. $(VENV)/bin/activate && python -m pytest tests/ -q --ignore=tests/integration --tb=short
test-fast: ## Run tests with fail-fast
. $(VENV)/bin/activate && python -m pytest tests/ -q --ignore=tests/integration --tb=short -x
test-watch: ## Rerun tests on file changes
. $(VENV)/bin/activate && python -m watchfiles "python -m pytest tests/ -q --ignore=tests/integration --tb=short -x" $(SRC) tests/
# ─── Dev Servers ────────────────────────────────────────────────────────────────
.PHONY: dev-cli dev-gateway
dev-cli: ## Auto-restart CLI on file changes
. $(VENV)/bin/activate && python -m watchfiles "python -m hermes_cli.main" $(SRC)
dev-gateway: ## Auto-restart gateway on file changes
. $(VENV)/bin/activate && python -m watchfiles "python -m gateway.run" $(SRC)
# ─── Misc ───────────────────────────────────────────────────────────────────────
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
+1 -56
View File
@@ -187,58 +187,7 @@ def _skill_is_platform_compatible(skill_file: Path) -> bool:
return True # Err on the side of showing the skill
def _read_skill_conditions(skill_file: Path) -> dict:
"""Extract conditional activation fields from SKILL.md frontmatter."""
try:
from tools.skills_tool import _parse_frontmatter
raw = skill_file.read_text(encoding="utf-8")[:2000]
frontmatter, _ = _parse_frontmatter(raw)
hermes = frontmatter.get("metadata", {}).get("hermes", {})
return {
"fallback_for_toolsets": hermes.get("fallback_for_toolsets", []),
"requires_toolsets": hermes.get("requires_toolsets", []),
"fallback_for_tools": hermes.get("fallback_for_tools", []),
"requires_tools": hermes.get("requires_tools", []),
}
except Exception:
return {}
def _skill_should_show(
conditions: dict,
available_tools: "set[str] | None",
available_toolsets: "set[str] | None",
) -> bool:
"""Return False if the skill's conditional activation rules exclude it."""
if available_tools is None and available_toolsets is None:
return True # No filtering info — show everything (backward compat)
at = available_tools or set()
ats = available_toolsets or set()
# fallback_for: hide when the primary tool/toolset IS available
for ts in conditions.get("fallback_for_toolsets", []):
if ts in ats:
return False
for t in conditions.get("fallback_for_tools", []):
if t in at:
return False
# requires: hide when a required tool/toolset is NOT available
for ts in conditions.get("requires_toolsets", []):
if ts not in ats:
return False
for t in conditions.get("requires_tools", []):
if t not in at:
return False
return True
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
) -> str:
def build_skills_system_prompt() -> str:
"""Build a compact skill index for the system prompt.
Scans ~/.hermes/skills/ for SKILL.md files grouped by category.
@@ -261,10 +210,6 @@ def build_skills_system_prompt(
# Skip skills incompatible with the current OS platform
if not _skill_is_platform_compatible(skill_file):
continue
# Skip skills whose conditional activation rules exclude them
conditions = _read_skill_conditions(skill_file)
if not _skill_should_show(conditions, available_tools, available_toolsets):
continue
rel_path = skill_file.relative_to(skills_dir)
parts = rel_path.parts
if len(parts) >= 2:
+7 -13
View File
@@ -168,22 +168,16 @@ def parse_schedule(schedule: str) -> Dict[str, Any]:
def _ensure_aware(dt: datetime) -> datetime:
"""Return a timezone-aware datetime in Hermes configured timezone.
"""Make a naive datetime tz-aware using the configured timezone.
Backward compatibility:
- Older stored timestamps may be naive.
- Naive values are interpreted as *system-local wall time* (the timezone
`datetime.now()` used when they were created), then converted to the
configured Hermes timezone.
This preserves relative ordering for legacy naive timestamps across
timezone changes and avoids false not-due results.
Handles backward compatibility: timestamps stored before timezone support
are naive (server-local). We assume they were in the same timezone as
the current configuration so comparisons work without crashing.
"""
target_tz = _hermes_now().tzinfo
if dt.tzinfo is None:
local_tz = datetime.now().astimezone().tzinfo
return dt.replace(tzinfo=local_tz).astimezone(target_tz)
return dt.astimezone(target_tz)
tz = _hermes_now().tzinfo
return dt.replace(tzinfo=tz)
return dt
def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]:
-12
View File
@@ -292,18 +292,6 @@ def load_gateway_config() -> GatewayConfig:
sr = yaml_cfg.get("session_reset")
if sr and isinstance(sr, dict):
config.default_reset_policy = SessionResetPolicy.from_dict(sr)
# Bridge discord settings from config.yaml to env vars
# (env vars take precedence — only set if not already defined)
discord_cfg = yaml_cfg.get("discord", {})
if isinstance(discord_cfg, dict):
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
frc = discord_cfg.get("free_response_channels")
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
except Exception:
pass
+22 -62
View File
@@ -775,46 +775,6 @@ class DiscordAdapter(BasePlatformAdapter):
except Exception as e:
return SendResult(success=False, error=str(e))
def _get_parent_channel_id(self, channel: Any) -> Optional[str]:
"""Return the parent channel ID for a Discord thread-like channel, if present."""
parent = getattr(channel, "parent", None)
if parent is not None and getattr(parent, "id", None) is not None:
return str(parent.id)
parent_id = getattr(channel, "parent_id", None)
if parent_id is not None:
return str(parent_id)
return None
def _is_forum_parent(self, channel: Any) -> bool:
"""Best-effort check for whether a Discord channel is a forum channel."""
if channel is None:
return False
forum_cls = getattr(discord, "ForumChannel", None)
if forum_cls and isinstance(channel, forum_cls):
return True
channel_type = getattr(channel, "type", None)
if channel_type is not None:
type_value = getattr(channel_type, "value", channel_type)
if type_value == 15:
return True
return False
def _format_thread_chat_name(self, thread: Any) -> str:
"""Build a readable chat name for thread-like Discord channels, including forum context when available."""
thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread"))
parent = getattr(thread, "parent", None)
guild = getattr(thread, "guild", None) or getattr(parent, "guild", None)
guild_name = getattr(guild, "name", None)
parent_name = getattr(parent, "name", None)
if self._is_forum_parent(parent) and guild_name and parent_name:
return f"{guild_name} / {parent_name} / {thread_name}"
if parent_name and guild_name:
return f"{guild_name} / #{parent_name} / {thread_name}"
if parent_name:
return f"{parent_name} / {thread_name}"
return thread_name
async def _handle_message(self, message: DiscordMessage) -> None:
"""Handle incoming Discord messages."""
# In server channels (not DMs), require the bot to be @mentioned
@@ -825,33 +785,28 @@ class DiscordAdapter(BasePlatformAdapter):
# bot responds to every message without needing a mention.
# DISCORD_REQUIRE_MENTION: Set to "false" to disable mention requirement
# globally (all channels become free-response). Default: "true".
# Can also be set via discord.require_mention in config.yaml.
thread_id = None
parent_channel_id = None
is_thread = isinstance(message.channel, discord.Thread)
if is_thread:
thread_id = str(message.channel.id)
parent_channel_id = self._get_parent_channel_id(message.channel)
if not isinstance(message.channel, discord.DMChannel):
# Check if this channel is in the free-response list
free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
channel_ids = {str(message.channel.id)}
if parent_channel_id:
channel_ids.add(parent_channel_id)
channel_id = str(message.channel.id)
# Global override: if DISCORD_REQUIRE_MENTION=false, all channels are free
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
is_free_channel = bool(channel_ids & free_channels)
is_free_channel = channel_id in free_channels
if require_mention and not is_free_channel:
# Must be @mentioned to respond
if self._client.user not in message.mentions:
return
return # Silently ignore messages that don't mention the bot
# Strip the bot mention from the message text so the agent sees clean input
if self._client.user and self._client.user in message.mentions:
message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
# Determine message type
msg_type = MessageType.TEXT
if message.content.startswith("/"):
@@ -874,15 +829,20 @@ class DiscordAdapter(BasePlatformAdapter):
if isinstance(message.channel, discord.DMChannel):
chat_type = "dm"
chat_name = message.author.name
elif is_thread:
elif isinstance(message.channel, discord.Thread):
chat_type = "thread"
chat_name = self._format_thread_chat_name(message.channel)
chat_name = message.channel.name
else:
chat_type = "group"
chat_type = "group" # Treat server channels as groups
chat_name = getattr(message.channel, "name", str(message.channel.id))
if hasattr(message.channel, "guild") and message.channel.guild:
chat_name = f"{message.channel.guild.name} / #{chat_name}"
# Get thread ID if in a thread
thread_id = None
if isinstance(message.channel, discord.Thread):
thread_id = str(message.channel.id)
# Get channel topic (if available - TextChannels have topics, DMs/threads don't)
chat_topic = getattr(message.channel, "topic", None)
+30 -38
View File
@@ -187,30 +187,6 @@ def _resolve_runtime_agent_kwargs() -> dict:
}
def _resolve_gateway_model() -> str:
"""Read model from env/config — mirrors the resolution in _run_agent_sync.
Without this, temporary AIAgent instances (memory flush, /compress) fall
back to the hardcoded default ("anthropic/claude-opus-4.6") which fails
when the active provider is openai-codex.
"""
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
try:
import yaml as _y
_cfg_path = _hermes_home / "config.yaml"
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
_cfg = _y.safe_load(_f) or {}
_model_cfg = _cfg.get("model", {})
if isinstance(_model_cfg, str):
model = _model_cfg
elif isinstance(_model_cfg, dict):
model = _model_cfg.get("default", model)
except Exception:
pass
return model
class GatewayRunner:
"""
Main gateway controller.
@@ -282,14 +258,8 @@ class GatewayRunner:
if not runtime_kwargs.get("api_key"):
return
# Resolve model from config — AIAgent's default is OpenRouter-
# formatted ("anthropic/claude-opus-4.6") which fails when the
# active provider is openai-codex.
model = _resolve_gateway_model()
tmp_agent = AIAgent(
**runtime_kwargs,
model=model,
max_iterations=8,
quiet_mode=True,
enabled_toolsets=["memory", "skills"],
@@ -1136,7 +1106,6 @@ class GatewayRunner:
if len(_hyg_msgs) >= 4:
_hyg_agent = AIAgent(
**_hyg_runtime,
model=_hyg_model,
max_iterations=4,
quiet_mode=True,
enabled_toolsets=["memory"],
@@ -2029,8 +1998,21 @@ class GatewayRunner:
)
return
# Read model from config via shared helper
model = _resolve_gateway_model()
# Read model from config (same as _run_agent)
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
try:
import yaml as _y
_cfg_path = _hermes_home / "config.yaml"
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
_cfg = _y.safe_load(_f) or {}
_model_cfg = _cfg.get("model", {})
if isinstance(_model_cfg, str):
model = _model_cfg
elif isinstance(_model_cfg, dict):
model = _model_cfg.get("default", model)
except Exception:
pass
# Determine toolset (same logic as _run_agent)
default_toolset_map = {
@@ -2187,9 +2169,6 @@ class GatewayRunner:
if not runtime_kwargs.get("api_key"):
return "No provider configured -- cannot compress."
# Resolve model from config (same reason as memory flush above).
model = _resolve_gateway_model()
msgs = [
{"role": m.get("role"), "content": m.get("content")}
for m in history
@@ -2200,7 +2179,6 @@ class GatewayRunner:
tmp_agent = AIAgent(
**runtime_kwargs,
model=model,
max_iterations=4,
quiet_mode=True,
enabled_toolsets=["memory"],
@@ -3115,7 +3093,21 @@ class GatewayRunner:
except Exception:
pass
model = _resolve_gateway_model()
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
try:
import yaml as _y
_cfg_path = _hermes_home / "config.yaml"
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
_cfg = _y.safe_load(_f) or {}
_model_cfg = _cfg.get("model", {})
if isinstance(_model_cfg, str):
model = _model_cfg
elif isinstance(_model_cfg, dict):
model = _model_cfg.get("default", model)
except Exception:
pass
try:
runtime_kwargs = _resolve_runtime_agent_kwargs()
+2 -20
View File
@@ -17,7 +17,6 @@ import platform
import stat
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
@@ -208,12 +207,6 @@ DEFAULT_CONFIG = {
# Empty string means use server-local time.
"timezone": "",
# Discord platform settings (gateway mode)
"discord": {
"require_mention": True, # Require @mention to respond in server channels
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
},
# Permanently allowed dangerous command patterns (added via "always" approval)
"command_allowlist": [],
# User-defined quick commands that bypass the agent loop (type: exec only)
@@ -965,19 +958,8 @@ def save_env_value(key: str, value: str):
lines[-1] += "\n"
lines.append(f"{key}={value}\n")
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
try:
with os.fdopen(fd, 'w', **write_kw) as f:
f.writelines(lines)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
with open(env_path, 'w', **write_kw) as f:
f.writelines(lines)
_secure_file(env_path)
# Restrict .env permissions to owner-only (contains API keys)
+5 -12
View File
@@ -490,16 +490,13 @@ def run_doctor(args):
print(f"\r {color('', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} ")
# -- API-key providers (Z.AI/GLM, Kimi, MiniMax, MiniMax-CN) --
# Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)
# If supports_models_endpoint is False, we skip the health check and just show "configured"
_apikey_providers = [
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
# MiniMax APIs don't support /models endpoint — https://github.com/NousResearch/hermes-agent/issues/811
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False),
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL"),
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL"),
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL"),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL"),
]
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
for _pname, _env_vars, _default_url, _base_env in _apikey_providers:
_key = ""
for _ev in _env_vars:
_key = os.getenv(_ev, "")
@@ -507,10 +504,6 @@ def run_doctor(args):
break
if _key:
_label = _pname.ljust(20)
# Some providers (like MiniMax) don't support /models endpoint
if not _supports_health_check:
print(f" {color('', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}")
continue
print(f" Checking {_pname} API...", end="", flush=True)
try:
import httpx
+410 -663
View File
File diff suppressed because it is too large Load Diff
+1 -33
View File
@@ -40,7 +40,7 @@ dependencies = [
[project.optional-dependencies]
modal = ["swe-rex[modal]>=1.4.0"]
daytona = ["daytona>=0.148.0"]
dev = ["pytest", "pytest-asyncio", "pytest-xdist", "mcp>=1.2.0", "ruff", "pre-commit", "watchfiles"]
dev = ["pytest", "pytest-asyncio", "pytest-xdist", "mcp>=1.2.0"]
messaging = ["python-telegram-bot>=20.0", "discord.py>=2.0", "aiohttp>=3.9.0", "slack-bolt>=1.18.0", "slack-sdk>=3.27.0"]
cron = ["croniter"]
slack = ["slack-bolt>=1.18.0", "slack-sdk>=3.27.0"]
@@ -79,38 +79,6 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
[tool.setuptools.packages.find]
include = ["tools", "hermes_cli", "gateway", "cron", "honcho_integration"]
[tool.ruff]
target-version = "py311"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
ignore = [
"E402", # late imports — intentional throughout codebase
"E501", # line too long — handled by formatter where it can
"E731", # lambda assignments — used in registry pattern
"E741", # ambiguous variable name
"F811", # redefined unused — intentional overrides
"F841", # unused variable
"B007", # unused loop variable
"B904", # raise from
"B905", # zip strict
"B027", # empty method without abstract decorator
"SIM102", # collapsible if
"SIM103", # needless bool
"SIM105", # suppressible exception
"SIM108", # ternary
"SIM110", # reimplemented builtin
"SIM112", # uncapitalized env var
"SIM115", # open file with context handler
"SIM117", # multiple with statements
"SIM118", # in-dict-keys
"SIM212", # if-expr twisted arms
]
[tool.ruff.lint.isort]
known-first-party = ["tools", "hermes_cli", "gateway", "agent", "cron"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
+2 -74
View File
@@ -99,51 +99,6 @@ from agent.trajectory import (
)
class _SafeWriter:
"""Transparent stdout wrapper that catches OSError from broken pipes.
When hermes-agent runs as a systemd service, Docker container, or headless
daemon, the stdout pipe can become unavailable (idle timeout, buffer
exhaustion, socket reset). Any print() call then raises
``OSError: [Errno 5] Input/output error``, which can crash
run_conversation() — especially via double-fault when the except handler
also tries to print.
This wrapper delegates all writes to the underlying stream and silently
catches OSError. It is installed once at the start of run_conversation()
and is transparent when stdout is healthy (zero overhead on the happy path).
"""
__slots__ = ("_inner",)
def __init__(self, inner):
object.__setattr__(self, "_inner", inner)
def write(self, data):
try:
return self._inner.write(data)
except OSError:
return len(data) if isinstance(data, str) else 0
def flush(self):
try:
self._inner.flush()
except OSError:
pass
def fileno(self):
return self._inner.fileno()
def isatty(self):
try:
return self._inner.isatty()
except OSError:
return False
def __getattr__(self, name):
return getattr(self._inner, name)
class IterationBudget:
"""Thread-safe shared iteration counter for parent and child agents.
@@ -1451,14 +1406,7 @@ class AIAgent:
prompt_parts.append(user_block)
has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage'])
if has_skills_tools:
avail_toolsets = {ts for ts, avail in check_toolset_requirements().items() if avail}
skills_prompt = build_skills_system_prompt(
available_tools=self.valid_tool_names,
available_toolsets=avail_toolsets,
)
else:
skills_prompt = ""
skills_prompt = build_skills_system_prompt() if has_skills_tools else ""
if skills_prompt:
prompt_parts.append(skills_prompt)
@@ -3209,11 +3157,6 @@ class AIAgent:
Returns:
Dict: Complete conversation result with final response and message history
"""
# Guard stdout against OSError from broken pipes (systemd/headless/daemon).
# Installed once, transparent when stdout is healthy, prevents crash on write.
if not isinstance(sys.stdout, _SafeWriter):
sys.stdout = _SafeWriter(sys.stdout)
# Generate unique task_id if not provided to isolate VMs between concurrent tasks
effective_task_id = task_id or str(uuid.uuid4())
@@ -3929,7 +3872,6 @@ class AIAgent:
'token limit', 'too many tokens', 'reduce the length',
'exceeds the limit', 'context window',
'request entity too large', # OpenRouter/Nous 413 safety net
'prompt is too long', # Anthropic: "prompt is too long: N tokens > M maximum"
])
if is_context_length_error:
@@ -4314,7 +4256,6 @@ class AIAgent:
messages.append(assistant_msg)
_msg_count_before_tools = len(messages)
self._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)
# Refund the iteration if the ONLY tool(s) called were
@@ -4324,20 +4265,7 @@ class AIAgent:
if _tc_names == {"execute_code"}:
self.iteration_budget.refund()
# Estimate next prompt size using real token counts from the
# last API response + rough estimate of newly appended tool
# results. This catches cases where tool results push the
# context past the limit that last_prompt_tokens alone misses
# (e.g. large file reads, web extractions).
_compressor = self.context_compressor
_new_tool_msgs = messages[_msg_count_before_tools:]
_new_chars = sum(len(str(m.get("content", "") or "")) for m in _new_tool_msgs)
_estimated_next_prompt = (
_compressor.last_prompt_tokens
+ _compressor.last_completion_tokens
+ _new_chars // 3 # conservative: JSON-heavy tool results ≈ 3 chars/token
)
if self.compression_enabled and _compressor.should_compress(_estimated_next_prompt):
if self.compression_enabled and self.context_compressor.should_compress():
messages, active_system_prompt = self._compress_context(
messages, system_message,
approx_tokens=self.context_compressor.last_prompt_tokens,
@@ -8,7 +8,6 @@ metadata:
hermes:
tags: [search, duckduckgo, web-search, free, fallback]
related_skills: [arxiv]
fallback_for_toolsets: [web]
---
# DuckDuckGo Search
-176
View File
@@ -8,8 +8,6 @@ from agent.prompt_builder import (
_scan_context_content,
_truncate_content,
_read_skill_description,
_read_skill_conditions,
_skill_should_show,
build_skills_system_prompt,
build_context_files_prompt,
CONTEXT_FILE_MAX_CHARS,
@@ -279,177 +277,3 @@ class TestPromptBuilderConstants:
assert "telegram" in PLATFORM_HINTS
assert "discord" in PLATFORM_HINTS
assert "cli" in PLATFORM_HINTS
# =========================================================================
# Conditional skill activation
# =========================================================================
class TestReadSkillConditions:
def test_no_conditions_returns_empty_lists(self, tmp_path):
skill_file = tmp_path / "SKILL.md"
skill_file.write_text("---\nname: test\ndescription: A skill\n---\n")
conditions = _read_skill_conditions(skill_file)
assert conditions["fallback_for_toolsets"] == []
assert conditions["requires_toolsets"] == []
assert conditions["fallback_for_tools"] == []
assert conditions["requires_tools"] == []
def test_reads_fallback_for_toolsets(self, tmp_path):
skill_file = tmp_path / "SKILL.md"
skill_file.write_text(
"---\nname: ddg\ndescription: DuckDuckGo\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n"
)
conditions = _read_skill_conditions(skill_file)
assert conditions["fallback_for_toolsets"] == ["web"]
def test_reads_requires_toolsets(self, tmp_path):
skill_file = tmp_path / "SKILL.md"
skill_file.write_text(
"---\nname: openhue\ndescription: Hue lights\nmetadata:\n hermes:\n requires_toolsets: [terminal]\n---\n"
)
conditions = _read_skill_conditions(skill_file)
assert conditions["requires_toolsets"] == ["terminal"]
def test_reads_multiple_conditions(self, tmp_path):
skill_file = tmp_path / "SKILL.md"
skill_file.write_text(
"---\nname: test\ndescription: Test\nmetadata:\n hermes:\n fallback_for_toolsets: [browser]\n requires_tools: [terminal]\n---\n"
)
conditions = _read_skill_conditions(skill_file)
assert conditions["fallback_for_toolsets"] == ["browser"]
assert conditions["requires_tools"] == ["terminal"]
def test_missing_file_returns_empty(self, tmp_path):
conditions = _read_skill_conditions(tmp_path / "missing.md")
assert conditions == {}
class TestSkillShouldShow:
def test_no_filter_info_always_shows(self):
assert _skill_should_show({}, None, None) is True
def test_empty_conditions_always_shows(self):
assert _skill_should_show(
{"fallback_for_toolsets": [], "requires_toolsets": [],
"fallback_for_tools": [], "requires_tools": []},
{"web_search"}, {"web"}
) is True
def test_fallback_hidden_when_toolset_available(self):
conditions = {"fallback_for_toolsets": ["web"], "requires_toolsets": [],
"fallback_for_tools": [], "requires_tools": []}
assert _skill_should_show(conditions, set(), {"web"}) is False
def test_fallback_shown_when_toolset_unavailable(self):
conditions = {"fallback_for_toolsets": ["web"], "requires_toolsets": [],
"fallback_for_tools": [], "requires_tools": []}
assert _skill_should_show(conditions, set(), set()) is True
def test_requires_shown_when_toolset_available(self):
conditions = {"fallback_for_toolsets": [], "requires_toolsets": ["terminal"],
"fallback_for_tools": [], "requires_tools": []}
assert _skill_should_show(conditions, set(), {"terminal"}) is True
def test_requires_hidden_when_toolset_missing(self):
conditions = {"fallback_for_toolsets": [], "requires_toolsets": ["terminal"],
"fallback_for_tools": [], "requires_tools": []}
assert _skill_should_show(conditions, set(), set()) is False
def test_fallback_for_tools_hidden_when_tool_available(self):
conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
"fallback_for_tools": ["web_search"], "requires_tools": []}
assert _skill_should_show(conditions, {"web_search"}, set()) is False
def test_fallback_for_tools_shown_when_tool_missing(self):
conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
"fallback_for_tools": ["web_search"], "requires_tools": []}
assert _skill_should_show(conditions, set(), set()) is True
def test_requires_tools_hidden_when_tool_missing(self):
conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
"fallback_for_tools": [], "requires_tools": ["terminal"]}
assert _skill_should_show(conditions, set(), set()) is False
def test_requires_tools_shown_when_tool_available(self):
conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
"fallback_for_tools": [], "requires_tools": ["terminal"]}
assert _skill_should_show(conditions, {"terminal"}, set()) is True
class TestBuildSkillsSystemPromptConditional:
def test_fallback_skill_hidden_when_primary_available(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
skill_dir = tmp_path / "skills" / "search" / "duckduckgo"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n"
)
result = build_skills_system_prompt(
available_tools=set(),
available_toolsets={"web"},
)
assert "duckduckgo" not in result
def test_fallback_skill_shown_when_primary_unavailable(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
skill_dir = tmp_path / "skills" / "search" / "duckduckgo"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n"
)
result = build_skills_system_prompt(
available_tools=set(),
available_toolsets=set(),
)
assert "duckduckgo" in result
def test_requires_skill_hidden_when_toolset_missing(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
skill_dir = tmp_path / "skills" / "iot" / "openhue"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: openhue\ndescription: Hue lights\nmetadata:\n hermes:\n requires_toolsets: [terminal]\n---\n"
)
result = build_skills_system_prompt(
available_tools=set(),
available_toolsets=set(),
)
assert "openhue" not in result
def test_requires_skill_shown_when_toolset_available(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
skill_dir = tmp_path / "skills" / "iot" / "openhue"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: openhue\ndescription: Hue lights\nmetadata:\n hermes:\n requires_toolsets: [terminal]\n---\n"
)
result = build_skills_system_prompt(
available_tools=set(),
available_toolsets={"terminal"},
)
assert "openhue" in result
def test_unconditional_skill_always_shown(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
skill_dir = tmp_path / "skills" / "general" / "notes"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: notes\ndescription: Take notes\n---\n"
)
result = build_skills_system_prompt(
available_tools=set(),
available_toolsets=set(),
)
assert "notes" in result
def test_no_args_shows_all_skills(self, monkeypatch, tmp_path):
"""Backward compat: calling with no args shows everything."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
skill_dir = tmp_path / "skills" / "search" / "duckduckgo"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n"
)
result = build_skills_system_prompt()
assert "duckduckgo" in result
-249
View File
@@ -1,249 +0,0 @@
"""Tests for Discord free-response defaults and mention gating."""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import sys
import pytest
from gateway.config import PlatformConfig
def _ensure_discord_mock():
"""Install a mock discord module when discord.py isn't available."""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.Client = MagicMock
discord_mod.File = MagicMock
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules.setdefault("discord", discord_mod)
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
_ensure_discord_mock()
import gateway.platforms.discord as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
class FakeDMChannel:
def __init__(self, channel_id: int = 1, name: str = "dm"):
self.id = channel_id
self.name = name
class FakeTextChannel:
def __init__(self, channel_id: int = 1, name: str = "general", guild_name: str = "Hermes Server"):
self.id = channel_id
self.name = name
self.guild = SimpleNamespace(name=guild_name)
self.topic = None
class FakeForumChannel:
def __init__(self, channel_id: int = 1, name: str = "support-forum", guild_name: str = "Hermes Server"):
self.id = channel_id
self.name = name
self.guild = SimpleNamespace(name=guild_name)
self.type = 15
self.topic = None
class FakeThread:
def __init__(self, channel_id: int = 1, name: str = "thread", parent=None, guild_name: str = "Hermes Server"):
self.id = channel_id
self.name = name
self.parent = parent
self.parent_id = getattr(parent, "id", None)
self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name)
self.topic = None
@pytest.fixture
def adapter(monkeypatch):
monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False)
monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False)
monkeypatch.setattr(discord_platform.discord, "ForumChannel", FakeForumChannel, raising=False)
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
adapter.handle_message = AsyncMock()
return adapter
def make_message(*, channel, content: str, mentions=None):
author = SimpleNamespace(id=42, display_name="Jezza", name="Jezza")
return SimpleNamespace(
id=123,
content=content,
mentions=list(mentions or []),
attachments=[],
reference=None,
created_at=datetime.now(timezone.utc),
channel=channel,
author=author,
)
@pytest.mark.asyncio
async def test_discord_defaults_to_require_mention(adapter, monkeypatch):
"""Default behavior: require @mention in server channels."""
monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel")
await adapter._handle_message(message)
# Should be ignored — no mention, require_mention defaults to true
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_discord_free_response_in_server_channels(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "hello from channel"
assert event.source.chat_id == "123"
assert event.source.chat_type == "group"
@pytest.mark.asyncio
async def test_discord_free_response_in_threads(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
thread = FakeThread(channel_id=456, name="Ghost reader skill")
message = make_message(channel=thread, content="hello from thread")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "hello from thread"
assert event.source.chat_id == "456"
assert event.source.thread_id == "456"
assert event.source.chat_type == "thread"
@pytest.mark.asyncio
async def test_discord_forum_threads_are_handled_as_threads(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
forum = FakeForumChannel(channel_id=222, name="support-forum")
thread = FakeThread(channel_id=456, name="Can Hermes reply here?", parent=forum)
message = make_message(channel=thread, content="hello from forum post")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "hello from forum post"
assert event.source.chat_id == "456"
assert event.source.thread_id == "456"
assert event.source.chat_type == "thread"
assert event.source.chat_name == "Hermes Server / support-forum / Can Hermes reply here?"
@pytest.mark.asyncio
async def test_discord_can_still_require_mentions_when_enabled(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeTextChannel(channel_id=789), content="ignored without mention")
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_discord_free_response_channel_overrides_mention_requirement(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789,999")
message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed without mention")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "allowed without mention"
@pytest.mark.asyncio
async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "222")
forum = FakeForumChannel(channel_id=222, name="support-forum")
thread = FakeThread(channel_id=333, name="Forum topic", parent=forum)
message = make_message(channel=thread, content="allowed from forum thread")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "allowed from forum thread"
assert event.source.chat_id == "333"
@pytest.mark.asyncio
async def test_discord_accepts_and_strips_bot_mentions_when_required(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
bot_user = adapter._client.user
message = make_message(
channel=FakeTextChannel(channel_id=321),
content=f"<@{bot_user.id}> hello with mention",
mentions=[bot_user],
)
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "hello with mention"
@pytest.mark.asyncio
async def test_discord_dms_ignore_mention_requirement(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeDMChannel(channel_id=654), content="dm without mention")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "dm without mention"
assert event.source.chat_type == "dm"
-130
View File
@@ -1,130 +0,0 @@
import json
from hermes_cli.auth import _update_config_for_provider, get_active_provider
from hermes_cli.config import load_config, save_config
from hermes_cli.setup import setup_model_provider
def _clear_provider_env(monkeypatch):
for key in (
"NOUS_API_KEY",
"OPENROUTER_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_API_KEY",
"LLM_MODEL",
):
monkeypatch.delenv(key, raising=False)
def test_nous_api_setup_preserves_model_provider_metadata(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
config = load_config()
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 0)
prompt_values = iter(
[
"nous-api-key",
"",
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
]
)
monkeypatch.setattr(
"hermes_cli.setup.prompt",
lambda *args, **kwargs: next(prompt_values),
)
setup_model_provider(config)
save_config(config)
reloaded = load_config()
assert isinstance(reloaded["model"], dict)
assert reloaded["model"]["provider"] == "nous-api"
assert reloaded["model"]["base_url"] == "https://inference-api.nousresearch.com/v1"
assert (
reloaded["model"]["default"]
== "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
)
def test_nous_oauth_setup_keeps_current_model_when_syncing_disk_provider(
tmp_path, monkeypatch
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
config = load_config()
prompt_choices = iter([1, 2])
monkeypatch.setattr(
"hermes_cli.setup.prompt_choice",
lambda *args, **kwargs: next(prompt_choices),
)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
def _fake_login_nous(*args, **kwargs):
auth_path = tmp_path / "auth.json"
auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}}))
_update_config_for_provider("nous", "https://inference.example.com/v1")
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["gemini-3-flash"],
)
setup_model_provider(config)
save_config(config)
reloaded = load_config()
assert isinstance(reloaded["model"], dict)
assert reloaded["model"]["provider"] == "nous"
assert reloaded["model"]["base_url"] == "https://inference.example.com/v1"
assert reloaded["model"]["default"] == "anthropic/claude-opus-4.6"
def test_custom_setup_clears_active_oauth_provider(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
auth_path = tmp_path / "auth.json"
auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}}))
config = load_config()
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 4)
prompt_values = iter(
[
"https://custom.example/v1",
"custom-api-key",
"custom/model",
"",
]
)
monkeypatch.setattr(
"hermes_cli.setup.prompt",
lambda *args, **kwargs: next(prompt_values),
)
setup_model_provider(config)
save_config(config)
reloaded = load_config()
assert get_active_provider() is None
assert isinstance(reloaded["model"], dict)
assert reloaded["model"]["provider"] == "custom"
assert reloaded["model"]["base_url"] == "https://custom.example/v1"
assert reloaded["model"]["default"] == "custom/model"
-70
View File
@@ -396,73 +396,3 @@ class TestPreflightCompression:
result = agent.run_conversation("hello", conversation_history=big_history)
mock_compress.assert_not_called()
class TestToolResultPreflightCompression:
"""Compression should trigger when tool results push context past the threshold."""
def test_large_tool_results_trigger_compression(self, agent):
"""When tool results push estimated tokens past threshold, compress before next call."""
agent.compression_enabled = True
agent.context_compressor.context_length = 200_000
agent.context_compressor.threshold_tokens = 140_000
agent.context_compressor.last_prompt_tokens = 130_000
agent.context_compressor.last_completion_tokens = 5_000
tc = SimpleNamespace(
id="tc1", type="function",
function=SimpleNamespace(name="web_search", arguments='{"query":"test"}'),
)
tool_resp = _mock_response(
content=None, finish_reason="stop", tool_calls=[tc],
usage={"prompt_tokens": 130_000, "completion_tokens": 5_000, "total_tokens": 135_000},
)
ok_resp = _mock_response(
content="Done after compression", finish_reason="stop",
usage={"prompt_tokens": 50_000, "completion_tokens": 100, "total_tokens": 50_100},
)
agent.client.chat.completions.create.side_effect = [tool_resp, ok_resp]
large_result = "x" * 100_000
with (
patch("run_agent.handle_function_call", return_value=large_result),
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
mock_compress.return_value = (
[{"role": "user", "content": "hello"}], "compressed prompt",
)
result = agent.run_conversation("hello")
mock_compress.assert_called_once()
assert result["completed"] is True
def test_anthropic_prompt_too_long_safety_net(self, agent):
"""Anthropic 'prompt is too long' error triggers compression as safety net."""
err_400 = Exception(
"Error code: 400 - {'type': 'error', 'error': {'type': 'invalid_request_error', "
"'message': 'prompt is too long: 233153 tokens > 200000 maximum'}}"
)
err_400.status_code = 400
ok_resp = _mock_response(content="Recovered", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [err_400, ok_resp]
prefill = [
{"role": "user", "content": "previous"},
{"role": "assistant", "content": "answer"},
]
with (
patch.object(agent, "_compress_context") as mock_compress,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
mock_compress.return_value = (
[{"role": "user", "content": "hello"}], "compressed",
)
result = agent.run_conversation("hello", conversation_history=prefill)
mock_compress.assert_called_once()
assert result["completed"] is True
-80
View File
@@ -1283,83 +1283,3 @@ class TestBudgetPressure:
messages[-1]["content"] = last_content + f"\n\n{warning}"
assert "plain text result" in messages[-1]["content"]
assert "BUDGET WARNING" in messages[-1]["content"]
class TestSafeWriter:
"""Verify _SafeWriter guards stdout against OSError (broken pipes)."""
def test_write_delegates_normally(self):
"""When stdout is healthy, _SafeWriter is transparent."""
from run_agent import _SafeWriter
from io import StringIO
inner = StringIO()
writer = _SafeWriter(inner)
writer.write("hello")
assert inner.getvalue() == "hello"
def test_write_catches_oserror(self):
"""OSError on write is silently caught, returns len(data)."""
from run_agent import _SafeWriter
from unittest.mock import MagicMock
inner = MagicMock()
inner.write.side_effect = OSError(5, "Input/output error")
writer = _SafeWriter(inner)
result = writer.write("hello")
assert result == 5 # len("hello")
def test_flush_catches_oserror(self):
"""OSError on flush is silently caught."""
from run_agent import _SafeWriter
from unittest.mock import MagicMock
inner = MagicMock()
inner.flush.side_effect = OSError(5, "Input/output error")
writer = _SafeWriter(inner)
writer.flush() # should not raise
def test_print_survives_broken_stdout(self, monkeypatch):
"""print() through _SafeWriter doesn't crash on broken pipe."""
import sys
from run_agent import _SafeWriter
from unittest.mock import MagicMock
broken = MagicMock()
broken.write.side_effect = OSError(5, "Input/output error")
original = sys.stdout
sys.stdout = _SafeWriter(broken)
try:
print("this should not crash") # would raise without _SafeWriter
finally:
sys.stdout = original
def test_installed_in_run_conversation(self, agent):
"""run_conversation installs _SafeWriter on sys.stdout."""
import sys
from run_agent import _SafeWriter
resp = _mock_response(content="Done", finish_reason="stop")
agent.client.chat.completions.create.return_value = resp
original = sys.stdout
try:
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
agent.run_conversation("test")
assert isinstance(sys.stdout, _SafeWriter)
finally:
sys.stdout = original
def test_double_wrap_prevented(self):
"""Wrapping an already-wrapped stream doesn't add layers."""
import sys
from run_agent import _SafeWriter
from io import StringIO
inner = StringIO()
wrapped = _SafeWriter(inner)
# isinstance check should prevent double-wrapping
assert isinstance(wrapped, _SafeWriter)
# The guard in run_conversation checks isinstance before wrapping
if not isinstance(wrapped, _SafeWriter):
wrapped = _SafeWriter(wrapped)
# Still just one layer
wrapped.write("test")
assert inner.getvalue() == "test"
-79
View File
@@ -249,85 +249,6 @@ class TestCronTimezone:
due = get_due_jobs()
assert len(due) == 1
def test_ensure_aware_naive_preserves_absolute_time(self):
"""_ensure_aware must preserve the absolute instant for naive datetimes.
Regression: the old code used replace(tzinfo=hermes_tz) which shifted
absolute time when system-local tz != Hermes tz. The fix interprets
naive values as system-local wall time, then converts.
"""
from cron.jobs import _ensure_aware
os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata"
hermes_time.reset_cache()
# Create a naive datetime — will be interpreted as system-local time
naive_dt = datetime(2026, 3, 11, 12, 0, 0)
result = _ensure_aware(naive_dt)
# The result should be in Kolkata tz
assert result.tzinfo is not None
# The UTC equivalent must match what we'd get by correctly interpreting
# the naive dt as system-local time first, then converting
system_tz = datetime.now().astimezone().tzinfo
expected_utc = naive_dt.replace(tzinfo=system_tz).astimezone(timezone.utc)
actual_utc = result.astimezone(timezone.utc)
assert actual_utc == expected_utc, (
f"Absolute time shifted: expected {expected_utc}, got {actual_utc}"
)
def test_ensure_aware_normalizes_aware_to_hermes_tz(self):
"""Already-aware datetimes should be normalized to Hermes tz."""
from cron.jobs import _ensure_aware
os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata"
hermes_time.reset_cache()
# Create an aware datetime in UTC
utc_dt = datetime(2026, 3, 11, 15, 0, 0, tzinfo=timezone.utc)
result = _ensure_aware(utc_dt)
# Must be in Hermes tz (Kolkata) but same absolute instant
kolkata = ZoneInfo("Asia/Kolkata")
assert result.utctimetuple()[:5] == (2026, 3, 11, 15, 0)
expected_local = utc_dt.astimezone(kolkata)
assert result == expected_local
def test_ensure_aware_due_job_not_skipped_when_system_ahead(self, tmp_path, monkeypatch):
"""Reproduce the actual bug: system tz ahead of Hermes tz caused
overdue jobs to appear as not-yet-due.
Scenario: system is Asia/Kolkata (UTC+5:30), Hermes is UTC.
A naive timestamp from 5 minutes ago (local time) should still
be recognized as due after conversion.
"""
import cron.jobs as jobs_module
monkeypatch.setattr(jobs_module, "CRON_DIR", tmp_path / "cron")
monkeypatch.setattr(jobs_module, "JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr(jobs_module, "OUTPUT_DIR", tmp_path / "cron" / "output")
os.environ["HERMES_TIMEZONE"] = "UTC"
hermes_time.reset_cache()
from cron.jobs import create_job, load_jobs, save_jobs, get_due_jobs
job = create_job(prompt="Bug repro", schedule="every 1h")
jobs = load_jobs()
# Simulate a naive timestamp that was written by datetime.now() on a
# system running in UTC+5:30 — 5 minutes in the past (local time)
naive_past = (datetime.now() - timedelta(minutes=5)).isoformat()
jobs[0]["next_run_at"] = naive_past
save_jobs(jobs)
# Must be recognized as due regardless of tz mismatch
due = get_due_jobs()
assert len(due) == 1, (
"Overdue job was skipped — _ensure_aware likely shifted absolute time"
)
def test_create_job_stores_tz_aware_timestamps(self, tmp_path, monkeypatch):
"""New jobs store timezone-aware created_at and next_run_at."""
import cron.jobs as jobs_module
-124
View File
@@ -2326,127 +2326,3 @@ class TestMCPServerTaskSamplingIntegration:
kwargs = server._sampling.session_kwargs()
assert "sampling_callback" in kwargs
assert "sampling_capabilities" in kwargs
# ---------------------------------------------------------------------------
# Discovery failed_count tracking
# ---------------------------------------------------------------------------
class TestDiscoveryFailedCount:
"""Verify discover_mcp_tools() correctly tracks failed server connections."""
def test_failed_server_increments_failed_count(self):
"""When _discover_and_register_server raises, failed_count increments."""
from tools.mcp_tool import discover_mcp_tools, _servers, _ensure_mcp_loop
fake_config = {
"good_server": {"command": "npx", "args": ["good"]},
"bad_server": {"command": "npx", "args": ["bad"]},
}
async def fake_register(name, cfg):
if name == "bad_server":
raise ConnectionError("Connection refused")
# Simulate successful registration
from tools.mcp_tool import MCPServerTask
server = MCPServerTask(name)
server.session = MagicMock()
server._tools = [_make_mcp_tool("tool_a")]
_servers[name] = server
return [f"mcp_{name}_tool_a"]
with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \
patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \
patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_good_server_tool_a"]):
_ensure_mcp_loop()
# Capture the logger to verify failed_count in summary
with patch("tools.mcp_tool.logger") as mock_logger:
discover_mcp_tools()
# Find the summary info call
info_calls = [
str(call)
for call in mock_logger.info.call_args_list
if "failed" in str(call).lower() or "MCP:" in str(call)
]
# The summary should mention the failure
assert any("1 failed" in str(c) for c in info_calls), (
f"Summary should report 1 failed server, got: {info_calls}"
)
_servers.pop("good_server", None)
_servers.pop("bad_server", None)
def test_all_servers_fail_still_prints_summary(self):
"""When all servers fail, a summary with failure count is still printed."""
from tools.mcp_tool import discover_mcp_tools, _servers, _ensure_mcp_loop
fake_config = {
"srv1": {"command": "npx", "args": ["a"]},
"srv2": {"command": "npx", "args": ["b"]},
}
async def always_fail(name, cfg):
raise ConnectionError(f"Server {name} refused")
with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \
patch("tools.mcp_tool._discover_and_register_server", side_effect=always_fail), \
patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._existing_tool_names", return_value=[]):
_ensure_mcp_loop()
with patch("tools.mcp_tool.logger") as mock_logger:
discover_mcp_tools()
# Summary must be printed even when all servers fail
info_calls = [str(call) for call in mock_logger.info.call_args_list]
assert any("2 failed" in str(c) for c in info_calls), (
f"Summary should report 2 failed servers, got: {info_calls}"
)
_servers.pop("srv1", None)
_servers.pop("srv2", None)
def test_ok_servers_excludes_failures(self):
"""ok_servers count correctly excludes failed servers."""
from tools.mcp_tool import discover_mcp_tools, _servers, _ensure_mcp_loop
fake_config = {
"ok1": {"command": "npx", "args": ["ok1"]},
"ok2": {"command": "npx", "args": ["ok2"]},
"fail1": {"command": "npx", "args": ["fail"]},
}
async def selective_register(name, cfg):
if name == "fail1":
raise ConnectionError("Refused")
from tools.mcp_tool import MCPServerTask
server = MCPServerTask(name)
server.session = MagicMock()
server._tools = [_make_mcp_tool("t")]
_servers[name] = server
return [f"mcp_{name}_t"]
with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \
patch("tools.mcp_tool._discover_and_register_server", side_effect=selective_register), \
patch("tools.mcp_tool._MCP_AVAILABLE", True), \
patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_ok1_t", "mcp_ok2_t"]):
_ensure_mcp_loop()
with patch("tools.mcp_tool.logger") as mock_logger:
discover_mcp_tools()
info_calls = [str(call) for call in mock_logger.info.call_args_list]
# Should say "2 server(s)" not "3 server(s)"
assert any("2 server" in str(c) for c in info_calls), (
f"Summary should report 2 ok servers, got: {info_calls}"
)
assert any("1 failed" in str(c) for c in info_calls), (
f"Summary should report 1 failed, got: {info_calls}"
)
_servers.pop("ok1", None)
_servers.pop("ok2", None)
_servers.pop("fail1", None)
+5 -18
View File
@@ -95,34 +95,21 @@ def _run_git(
) -> tuple:
"""Run a git command against the shadow repo. Returns (ok, stdout, stderr)."""
env = _git_env(shadow_repo, working_dir)
cmd = ["git"] + list(args)
try:
result = subprocess.run(
cmd,
["git"] + args,
capture_output=True,
text=True,
timeout=timeout,
env=env,
cwd=str(Path(working_dir).resolve()),
)
ok = result.returncode == 0
stdout = result.stdout.strip()
stderr = result.stderr.strip()
if not ok:
logger.error(
"Git command failed: %s (rc=%d) stderr=%s",
" ".join(cmd), result.returncode, stderr,
)
return ok, stdout, stderr
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
msg = f"git timed out after {timeout}s: {' '.join(cmd)}"
logger.error(msg, exc_info=True)
return False, "", msg
return False, "", f"git timed out after {timeout}s: git {' '.join(args)}"
except FileNotFoundError:
logger.error("Git executable not found: %s", " ".join(cmd), exc_info=True)
return False, "", "git not found"
except Exception as exc:
logger.error("Unexpected git error running %s: %s", " ".join(cmd), exc, exc_info=True)
return False, "", str(exc)
@@ -300,7 +287,7 @@ class CheckpointManager:
["cat-file", "-t", commit_hash], shadow, abs_dir,
)
if not ok:
return {"success": False, "error": f"Checkpoint '{commit_hash}' not found", "debug": err or None}
return {"success": False, "error": f"Checkpoint '{commit_hash}' not found"}
# Take a checkpoint of current state before restoring (so you can undo the undo)
self._take(abs_dir, f"pre-rollback snapshot (restoring to {commit_hash[:8]})")
@@ -312,7 +299,7 @@ class CheckpointManager:
)
if not ok:
return {"success": False, "error": "Restore failed", "debug": err or None}
return {"success": False, "error": f"Restore failed: {err}"}
# Get info about what was restored
ok2, reason_out, _ = _run_git(
+2 -2
View File
@@ -209,7 +209,7 @@ def _upscale_image(image_url: str, original_prompt: str) -> Dict[str, Any]:
return None
except Exception as e:
logger.error("Error upscaling image: %s", e, exc_info=True)
logger.error("Error upscaling image: %s", e)
return None
@@ -377,7 +377,7 @@ def image_generate_tool(
except Exception as e:
generation_time = (datetime.datetime.now() - start_time).total_seconds()
error_msg = f"Error generating image: {str(e)}"
logger.error("%s", error_msg, exc_info=True)
logger.error("%s", error_msg)
# Prepare error response - minimal format
response_data = {
+13 -7
View File
@@ -1331,23 +1331,29 @@ def discover_mcp_tools() -> List[str]:
async def _discover_one(name: str, cfg: dict) -> List[str]:
"""Connect to a single server and return its registered tool names."""
return await _discover_and_register_server(name, cfg)
transport_desc = cfg.get("url", f'{cfg.get("command", "?")} {" ".join(cfg.get("args", [])[:2])}')
try:
registered = await _discover_and_register_server(name, cfg)
transport_type = "HTTP" if "url" in cfg else "stdio"
return registered
except Exception as exc:
logger.warning(
"Failed to connect to MCP server '%s': %s",
name, exc,
)
return []
async def _discover_all():
nonlocal failed_count
server_names = list(new_servers.keys())
# Connect to all servers in PARALLEL
results = await asyncio.gather(
*(_discover_one(name, cfg) for name, cfg in new_servers.items()),
return_exceptions=True,
)
for name, result in zip(server_names, results):
for result in results:
if isinstance(result, Exception):
failed_count += 1
logger.warning(
"Failed to connect to MCP server '%s': %s",
name, result,
)
logger.warning("MCP discovery error: %s", result)
elif isinstance(result, list):
all_tools.extend(result)
else:
-1
View File
@@ -259,7 +259,6 @@ async def vision_analyze_tool(
# Check auxiliary vision client availability
if _aux_async_client is None or DEFAULT_VISION_MODEL is None:
logger.error("Vision analysis unavailable: no auxiliary vision model configured")
return json.dumps({
"success": False,
"analysis": "Vision analysis unavailable: no auxiliary vision model configured. "
@@ -55,8 +55,6 @@ metadata:
hermes:
tags: [python, automation]
category: devops
fallback_for_toolsets: [web] # Optional — conditional activation (see below)
requires_toolsets: [terminal] # Optional — conditional activation (see below)
---
# Skill Title
@@ -92,30 +90,6 @@ platforms: [macos, linux] # macOS and Linux
When set, the skill is automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. If omitted, the skill loads on all platforms.
### Conditional Activation (Fallback Skills)
Skills can automatically show or hide themselves based on which tools are available in the current session. This is most useful for **fallback skills** — free or local alternatives that should only appear when a premium tool is unavailable.
```yaml
metadata:
hermes:
fallback_for_toolsets: [web] # Show ONLY when these toolsets are unavailable
requires_toolsets: [terminal] # Show ONLY when these toolsets are available
fallback_for_tools: [web_search] # Show ONLY when these specific tools are unavailable
requires_tools: [terminal] # Show ONLY when these specific tools are available
```
| Field | Behavior |
|-------|----------|
| `fallback_for_toolsets` | Skill is **hidden** when the listed toolsets are available. Shown when they're missing. |
| `fallback_for_tools` | Same, but checks individual tools instead of toolsets. |
| `requires_toolsets` | Skill is **hidden** when the listed toolsets are unavailable. Shown when they're present. |
| `requires_tools` | Same, but checks individual tools. |
**Example:** The built-in `duckduckgo-search` skill uses `fallback_for_toolsets: [web]`. When you have `FIRECRAWL_API_KEY` set, the web toolset is available and the agent uses `web_search` — the DuckDuckGo skill stays hidden. If the API key is missing, the web toolset is unavailable and the DuckDuckGo skill automatically appears as a fallback.
Skills without any conditional fields behave exactly as before — they're always shown.
## Skill Directory Structure
```