Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f99b508c83 | |||
| 8fa96debc9 | |||
| a8409a161f | |||
| 452593319b | |||
| 73ba4987d5 | |||
| 41fa4fbaa5 | |||
| 11825ccefa | |||
| 91101065bb | |||
| 01bec40724 | |||
| 9b58b9bced | |||
| b66c8b409c | |||
| 09b1de5f71 | |||
| 3667138d05 | |||
| 66c0b719de | |||
| d905e612aa | |||
| fa7a18f42a | |||
| 82113f1f1e | |||
| 01d3b31479 | |||
| a5ffa1278c | |||
| b7d58320a8 | |||
| 605ba4adea | |||
| 24a0c08d58 | |||
| b4a100dfc0 | |||
| 4a8f23eddf | |||
| a54405e339 | |||
| 94023e6a85 |
@@ -333,6 +333,8 @@ 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
|
||||
@@ -367,6 +369,48 @@ 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`).
|
||||
|
||||
+56
-1
@@ -187,7 +187,58 @@ def _skill_is_platform_compatible(skill_file: Path) -> bool:
|
||||
return True # Err on the side of showing the skill
|
||||
|
||||
|
||||
def build_skills_system_prompt() -> str:
|
||||
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:
|
||||
"""Build a compact skill index for the system prompt.
|
||||
|
||||
Scans ~/.hermes/skills/ for SKILL.md files grouped by category.
|
||||
@@ -210,6 +261,10 @@ def build_skills_system_prompt() -> str:
|
||||
# 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:
|
||||
|
||||
+13
-7
@@ -168,16 +168,22 @@ def parse_schedule(schedule: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _ensure_aware(dt: datetime) -> datetime:
|
||||
"""Make a naive datetime tz-aware using the configured timezone.
|
||||
"""Return a timezone-aware datetime in Hermes configured timezone.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
target_tz = _hermes_now().tzinfo
|
||||
if dt.tzinfo is None:
|
||||
tz = _hermes_now().tzinfo
|
||||
return dt.replace(tzinfo=tz)
|
||||
return dt
|
||||
local_tz = datetime.now().astimezone().tzinfo
|
||||
return dt.replace(tzinfo=local_tz).astimezone(target_tz)
|
||||
return dt.astimezone(target_tz)
|
||||
|
||||
|
||||
def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]:
|
||||
|
||||
@@ -292,6 +292,18 @@ 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
|
||||
|
||||
|
||||
@@ -775,6 +775,46 @@ 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
|
||||
@@ -785,28 +825,33 @@ 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_id = str(message.channel.id)
|
||||
|
||||
# Global override: if DISCORD_REQUIRE_MENTION=false, all channels are free
|
||||
channel_ids = {str(message.channel.id)}
|
||||
if parent_channel_id:
|
||||
channel_ids.add(parent_channel_id)
|
||||
|
||||
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
|
||||
|
||||
is_free_channel = channel_id in free_channels
|
||||
|
||||
is_free_channel = bool(channel_ids & free_channels)
|
||||
|
||||
if require_mention and not is_free_channel:
|
||||
# Must be @mentioned to respond
|
||||
if self._client.user not in message.mentions:
|
||||
return # Silently ignore messages that don't mention the bot
|
||||
|
||||
# Strip the bot mention from the message text so the agent sees clean input
|
||||
return
|
||||
|
||||
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("/"):
|
||||
@@ -829,20 +874,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
chat_type = "dm"
|
||||
chat_name = message.author.name
|
||||
elif isinstance(message.channel, discord.Thread):
|
||||
elif is_thread:
|
||||
chat_type = "thread"
|
||||
chat_name = message.channel.name
|
||||
chat_name = self._format_thread_chat_name(message.channel)
|
||||
else:
|
||||
chat_type = "group" # Treat server channels as groups
|
||||
chat_type = "group"
|
||||
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)
|
||||
|
||||
|
||||
+38
-30
@@ -187,6 +187,30 @@ 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.
|
||||
@@ -258,8 +282,14 @@ 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"],
|
||||
@@ -1106,6 +1136,7 @@ class GatewayRunner:
|
||||
if len(_hyg_msgs) >= 4:
|
||||
_hyg_agent = AIAgent(
|
||||
**_hyg_runtime,
|
||||
model=_hyg_model,
|
||||
max_iterations=4,
|
||||
quiet_mode=True,
|
||||
enabled_toolsets=["memory"],
|
||||
@@ -1998,21 +2029,8 @@ class GatewayRunner:
|
||||
)
|
||||
return
|
||||
|
||||
# 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
|
||||
# Read model from config via shared helper
|
||||
model = _resolve_gateway_model()
|
||||
|
||||
# Determine toolset (same logic as _run_agent)
|
||||
default_toolset_map = {
|
||||
@@ -2169,6 +2187,9 @@ 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
|
||||
@@ -2179,6 +2200,7 @@ class GatewayRunner:
|
||||
|
||||
tmp_agent = AIAgent(
|
||||
**runtime_kwargs,
|
||||
model=model,
|
||||
max_iterations=4,
|
||||
quiet_mode=True,
|
||||
enabled_toolsets=["memory"],
|
||||
@@ -3093,21 +3115,7 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
model = _resolve_gateway_model()
|
||||
|
||||
try:
|
||||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||||
|
||||
+20
-2
@@ -17,6 +17,7 @@ import platform
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
@@ -207,6 +208,12 @@ 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)
|
||||
@@ -958,8 +965,19 @@ def save_env_value(key: str, value: str):
|
||||
lines[-1] += "\n"
|
||||
lines.append(f"{key}={value}\n")
|
||||
|
||||
with open(env_path, 'w', **write_kw) as f:
|
||||
f.writelines(lines)
|
||||
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
|
||||
_secure_file(env_path)
|
||||
|
||||
# Restrict .env permissions to owner-only (contains API keys)
|
||||
|
||||
+12
-5
@@ -490,13 +490,16 @@ 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"),
|
||||
("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"),
|
||||
("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),
|
||||
]
|
||||
for _pname, _env_vars, _default_url, _base_env in _apikey_providers:
|
||||
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
|
||||
_key = ""
|
||||
for _ev in _env_vars:
|
||||
_key = os.getenv(_ev, "")
|
||||
@@ -504,6 +507,10 @@ 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
|
||||
|
||||
+651
-287
File diff suppressed because it is too large
Load Diff
+74
-2
@@ -99,6 +99,51 @@ 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.
|
||||
|
||||
@@ -1406,7 +1451,14 @@ 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'])
|
||||
skills_prompt = build_skills_system_prompt() if has_skills_tools else ""
|
||||
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 = ""
|
||||
if skills_prompt:
|
||||
prompt_parts.append(skills_prompt)
|
||||
|
||||
@@ -3157,6 +3209,11 @@ 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())
|
||||
|
||||
@@ -3872,6 +3929,7 @@ 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:
|
||||
@@ -4256,6 +4314,7 @@ 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
|
||||
@@ -4265,7 +4324,20 @@ class AIAgent:
|
||||
if _tc_names == {"execute_code"}:
|
||||
self.iteration_budget.refund()
|
||||
|
||||
if self.compression_enabled and self.context_compressor.should_compress():
|
||||
# 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):
|
||||
messages, active_system_prompt = self._compress_context(
|
||||
messages, system_message,
|
||||
approx_tokens=self.context_compressor.last_prompt_tokens,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
name: phone-calls
|
||||
description: Make outbound phone calls on the user's behalf using AI voice agents (Bland.ai or Vapi). Schedule appointments, make reservations, or deliver messages via realistic voice calls. Always confirm with user before dialing.
|
||||
version: 2.0.0
|
||||
author: NousResearch
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [phone, calling, voice, appointments, scheduling, bland.ai, vapi, elevenlabs, twilio, telephony]
|
||||
related_skills: [google-workspace, find-nearby]
|
||||
---
|
||||
|
||||
# Phone Calls — AI Voice Agent
|
||||
|
||||
Make outbound phone calls on the user's behalf using AI voice agents. Uses the `phone_call.py` helper script (in this skill's `scripts/` directory) to call Bland.ai or Vapi APIs.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks you to **call someone** (schedule appointment, make reservation, leave message)
|
||||
- User asks to **schedule an appointment** (dentist, doctor, haircut, etc.)
|
||||
- User asks to **make a reservation** (restaurant, hotel, etc.)
|
||||
- User says "call", "phone", "ring", "dial", or mentions making a phone call
|
||||
|
||||
## Safety Rules — MANDATORY
|
||||
|
||||
1. **ALWAYS confirm with the user before making any call.** Show them:
|
||||
- The phone number you're about to call
|
||||
- A summary of what the AI will say/do
|
||||
- The voice and max duration
|
||||
2. **Never call emergency numbers** (911, 112, 999, etc.)
|
||||
3. **Only share user info they've explicitly authorized** (check memory/user profile)
|
||||
4. **Never make calls with hostile, harassing, or offensive content**
|
||||
5. **Phone number privacy:**
|
||||
- All phone numbers (except the user's own stored number) are SENSITIVE — never save to memory, never persist in session summaries or skills
|
||||
- Always mask numbers in responses: show last 4 digits only (e.g. "Called ***-***-1234")
|
||||
- The user's own number may only be shared with businesses during appointment booking when they need a callback/contact number — never in any other context
|
||||
- When confirming a call with the user, you may show the full number in the confirmation prompt, but mask it in all subsequent messages and summaries
|
||||
|
||||
## Providers
|
||||
|
||||
### Bland.ai (default — start here)
|
||||
- All-in-one platform, simplest setup, one API key and you're calling
|
||||
- Needs only `BLAND_API_KEY` env var
|
||||
- Sign up free at https://app.bland.ai (~$2 trial credit)
|
||||
- Voices: mason, josh, ryan, matt (male); evelyn, tina, june (female)
|
||||
- ~$0.07-0.12/min
|
||||
- Downside: voice quality is decent but noticeably robotic
|
||||
|
||||
### Vapi (upgrade — better voices)
|
||||
- Flexible platform: plug in any voice (ElevenLabs, Deepgram, PlayHT, Cartesia) and any LLM
|
||||
- Much more natural-sounding than Bland
|
||||
- Requires a Twilio number for outbound calls (Vapi's free numbers are inbound-only)
|
||||
- Setup:
|
||||
1. Sign up at https://dashboard.vapi.ai ($10 free credit)
|
||||
2. Sign up at https://twilio.com ($15 free credit)
|
||||
3. Buy a Twilio number (~$1/mo)
|
||||
4. Import it into Vapi (needs Twilio Account SID + Auth Token)
|
||||
5. Set `VAPI_API_KEY` and `VAPI_PHONE_NUMBER_ID`
|
||||
- ~$0.10-0.25/min depending on voice/LLM choices
|
||||
- If the user wants to upgrade from Bland, walk them through Twilio setup
|
||||
|
||||
## Helper Script
|
||||
|
||||
The script at `scripts/phone_call.py` handles all API calls. It uses only Python stdlib (no pip dependencies). Run it via `terminal` or `execute_code`.
|
||||
|
||||
```bash
|
||||
# Locate the script
|
||||
SCRIPT="$(find ~/.hermes/skills -path '*/phone-calls/scripts/phone_call.py' -print -quit)"
|
||||
|
||||
# Make a call
|
||||
python3 "$SCRIPT" call "+15551234567" "Schedule a cleaning for Tuesday afternoon" --voice mason
|
||||
|
||||
# Check call result
|
||||
python3 "$SCRIPT" status <call_id>
|
||||
|
||||
# Check call result with analysis questions (Bland only)
|
||||
python3 "$SCRIPT" status <call_id> --analyze "Was appointment confirmed?,What time?"
|
||||
|
||||
# Check configuration
|
||||
python3 "$SCRIPT" diagnose
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set via env vars (preferred) or `~/.hermes/config.yaml` under the `phone:` key. Env vars take priority.
|
||||
|
||||
**Bland.ai (quick start):**
|
||||
```
|
||||
BLAND_API_KEY=org_xxx
|
||||
BLAND_DEFAULT_VOICE=mason # optional
|
||||
```
|
||||
|
||||
**Vapi:**
|
||||
```
|
||||
PHONE_PROVIDER=vapi
|
||||
VAPI_API_KEY=xxx-xxx
|
||||
VAPI_PHONE_NUMBER_ID=xxx-xxx
|
||||
VAPI_VOICE_PROVIDER=11labs # optional (default: 11labs)
|
||||
VAPI_VOICE_ID=cjVigY5... # optional (default: ElevenLabs "Eric")
|
||||
VAPI_MODEL=gpt-4o # optional
|
||||
```
|
||||
|
||||
**config.yaml alternative:**
|
||||
```yaml
|
||||
phone:
|
||||
provider: bland # or "vapi"
|
||||
bland:
|
||||
api_key: org_xxx
|
||||
default_voice: mason
|
||||
vapi:
|
||||
api_key: xxx-xxx
|
||||
phone_number_id: xxx-xxx
|
||||
default_voice_provider: 11labs
|
||||
default_voice_id: cjVigY5qzO86Huf0OWal
|
||||
model: gpt-4o
|
||||
```
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 0: First-time setup (only once)
|
||||
|
||||
Run `diagnose` to check if a provider is configured:
|
||||
```bash
|
||||
python3 "$SCRIPT" diagnose
|
||||
```
|
||||
|
||||
If not configured, ask the user to choose a provider and set the env vars or config.
|
||||
|
||||
### Step 1: Gather call details
|
||||
|
||||
Collect from the user:
|
||||
- **Who to call**: Name and phone number (look up if needed)
|
||||
- **Purpose**: What should the AI say/accomplish
|
||||
- **User info to share**: Name, preferences, insurance, etc.
|
||||
- **Constraints**: Preferred times, budget, special requests
|
||||
|
||||
### Step 2: Craft the task prompt
|
||||
|
||||
Write the task like you're briefing a human assistant. Include:
|
||||
- All necessary details (names, dates, preferences)
|
||||
- Fallback options ("if Tuesday isn't available, try Wednesday")
|
||||
- Boundaries on what info to share
|
||||
- A natural first sentence
|
||||
|
||||
**Name pronunciation**: If the user's name has a non-obvious pronunciation, spell it phonetically in the task prompt (e.g., "Morganne" for Morgane).
|
||||
|
||||
### Step 3: Confirm with user
|
||||
|
||||
Present a summary and wait for explicit approval:
|
||||
```
|
||||
I'm ready to call:
|
||||
Number: +1 (555) 123-4567 (Dr. Smith's Dental Office)
|
||||
Purpose: Schedule a cleaning, Tuesday afternoon preferred
|
||||
Voice: mason (male)
|
||||
Max: 3 minutes
|
||||
|
||||
Shall I go ahead?
|
||||
```
|
||||
|
||||
### Step 4: Make the call
|
||||
|
||||
```bash
|
||||
python3 "$SCRIPT" call "+15551234567" "You are calling Dr. Smith's Dental Office on behalf of Morganne. Schedule a dental cleaning for Tuesday afternoon. If Tuesday is not available, try Wednesday or Thursday. Morganne's phone number for callbacks is +14385551234." --voice mason --max-duration 3
|
||||
```
|
||||
|
||||
### Step 5: Get results
|
||||
|
||||
Wait 60-90 seconds, then check the status:
|
||||
```bash
|
||||
python3 "$SCRIPT" status <call_id>
|
||||
```
|
||||
|
||||
If the call is still in progress, wait and try again. Once completed, present a summary:
|
||||
- Was the objective accomplished?
|
||||
- Key details (date, time, location, confirmations)
|
||||
- Any follow-up needed
|
||||
|
||||
For Bland.ai, you can also ask structured analysis questions:
|
||||
```bash
|
||||
python3 "$SCRIPT" status <call_id> --analyze "Was the appointment confirmed?,What date and time?,Any special instructions?"
|
||||
```
|
||||
|
||||
## Importing a Twilio Number into Vapi
|
||||
|
||||
For Vapi outbound calls, you need to import a Twilio number:
|
||||
|
||||
1. Sign up at https://www.twilio.com/try-twilio
|
||||
2. Buy a phone number in the Twilio console
|
||||
3. Copy your Account SID and Auth Token from Account > API keys & tokens
|
||||
4. Import into Vapi:
|
||||
```bash
|
||||
curl -X POST https://api.vapi.ai/phone-number \
|
||||
-H "Authorization: Bearer $VAPI_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"provider": "twilio",
|
||||
"number": "+1XXXXXXXXXX",
|
||||
"twilioAccountSid": "AC...",
|
||||
"twilioAuthToken": "..."
|
||||
}'
|
||||
```
|
||||
5. Use the returned `id` as `VAPI_PHONE_NUMBER_ID`
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **No pip dependencies needed**: The script uses only Python stdlib (`urllib`)
|
||||
- **Call goes to voicemail**: Check `answered_by` field in status results
|
||||
- **"Terrible voice"**: Switch from Bland to Vapi with ElevenLabs voices for much better quality
|
||||
- **Vapi free numbers can't make outbound calls**: You must import a Twilio number
|
||||
- **Vapi free numbers can't call international**: Canadian numbers count as international from US numbers
|
||||
- **Name pronunciation**: ElevenLabs ignores phonetic hyphens — spell names literally as they should be pronounced
|
||||
- **Transcript not ready**: Poll status a few times with 30-60s delays between attempts
|
||||
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phone Call CLI — Make and check outbound AI voice calls.
|
||||
|
||||
Usage:
|
||||
python3 phone_call.py call <phone_number> <task> [--voice NAME] [--first-sentence TEXT] [--max-duration MIN]
|
||||
python3 phone_call.py status <call_id> [--analyze "question1,question2"]
|
||||
python3 phone_call.py diagnose
|
||||
|
||||
Providers:
|
||||
Bland.ai (default): set BLAND_API_KEY env var
|
||||
Vapi: set VAPI_API_KEY + VAPI_PHONE_NUMBER_ID env vars
|
||||
and PHONE_PROVIDER=vapi
|
||||
|
||||
Configuration via env vars:
|
||||
PHONE_PROVIDER "bland" (default) or "vapi"
|
||||
BLAND_API_KEY Bland.ai organization key
|
||||
BLAND_DEFAULT_VOICE Bland voice name (default: mason)
|
||||
VAPI_API_KEY Vapi private key
|
||||
VAPI_PHONE_NUMBER_ID Vapi phone number ID (imported Twilio number)
|
||||
VAPI_VOICE_PROVIDER Voice provider for Vapi (default: 11labs)
|
||||
VAPI_VOICE_ID Voice ID for Vapi (default: ElevenLabs "Eric")
|
||||
VAPI_MODEL LLM model for Vapi assistant (default: gpt-4o)
|
||||
|
||||
Or via ~/.hermes/config.yaml under the 'phone:' key (env vars take priority).
|
||||
|
||||
Examples:
|
||||
# Make a call with Bland.ai
|
||||
BLAND_API_KEY=org_xxx python3 phone_call.py call "+15551234567" "Schedule a cleaning for Tuesday afternoon"
|
||||
|
||||
# Check call result
|
||||
python3 phone_call.py status abc-123-def
|
||||
|
||||
# Check config
|
||||
python3 phone_call.py diagnose
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
BLAND_API_BASE = "https://api.bland.ai/v1"
|
||||
BLAND_DEFAULT_VOICE = "mason"
|
||||
BLAND_DEFAULT_MODEL = "enhanced"
|
||||
BLAND_VOICES = {
|
||||
"mason": "Male, natural, friendly (recommended)",
|
||||
"josh": "Male, conversational",
|
||||
"ryan": "Male, professional",
|
||||
"matt": "Male, casual",
|
||||
"evelyn": "Female, natural, warm (recommended)",
|
||||
"tina": "Female, warm, friendly",
|
||||
"june": "Female, conversational",
|
||||
}
|
||||
|
||||
VAPI_API_BASE = "https://api.vapi.ai"
|
||||
VAPI_DEFAULT_VOICE_PROVIDER = "11labs"
|
||||
VAPI_DEFAULT_VOICE_ID = "cjVigY5qzO86Huf0OWal" # ElevenLabs "Eric"
|
||||
VAPI_DEFAULT_MODEL = "gpt-4o"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loading
|
||||
# ---------------------------------------------------------------------------
|
||||
def _load_config() -> dict:
|
||||
"""Load phone config from ~/.hermes/config.yaml, falling back to {}."""
|
||||
config_path = os.path.expanduser("~/.hermes/config.yaml")
|
||||
if not os.path.exists(config_path):
|
||||
return {}
|
||||
try:
|
||||
import yaml # optional dependency
|
||||
with open(config_path, "r") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
return cfg.get("phone", {})
|
||||
except ImportError:
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _env_or_config(env_key: str, config_path: list, default: str = "") -> str:
|
||||
"""Get a value from env var first, then config.yaml, then default."""
|
||||
val = os.environ.get(env_key, "")
|
||||
if val:
|
||||
return val
|
||||
cfg = _load_config()
|
||||
for key in config_path:
|
||||
if isinstance(cfg, dict):
|
||||
cfg = cfg.get(key, {})
|
||||
else:
|
||||
return default
|
||||
return str(cfg) if cfg and not isinstance(cfg, dict) else default
|
||||
|
||||
|
||||
def _get_provider() -> str:
|
||||
return _env_or_config("PHONE_PROVIDER", ["provider"], "bland").lower().strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helper (stdlib only — no requests dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _http(method: str, url: str, headers: dict, data: dict | None = None) -> dict:
|
||||
"""Make an HTTP request and return parsed JSON."""
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
err_body = e.read().decode() if e.fp else ""
|
||||
print(f"HTTP {e.code}: {e.reason}", file=sys.stderr)
|
||||
if err_body:
|
||||
print(err_body, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Connection error: {e.reason}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bland.ai
|
||||
# ---------------------------------------------------------------------------
|
||||
def _bland_api_key() -> str:
|
||||
return _env_or_config("BLAND_API_KEY", ["bland", "api_key"])
|
||||
|
||||
|
||||
def bland_call(phone_number: str, task: str, voice: str | None = None,
|
||||
first_sentence: str | None = None, max_duration: int = 3) -> dict:
|
||||
api_key = _bland_api_key()
|
||||
if not api_key:
|
||||
print("Error: No Bland.ai API key. Set BLAND_API_KEY or add to ~/.hermes/config.yaml under phone.bland.api_key", file=sys.stderr)
|
||||
print("Sign up free at https://app.bland.ai", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if voice is None:
|
||||
voice = _env_or_config("BLAND_DEFAULT_VOICE", ["bland", "default_voice"], BLAND_DEFAULT_VOICE)
|
||||
|
||||
payload = {
|
||||
"phone_number": phone_number,
|
||||
"task": task,
|
||||
"voice": voice,
|
||||
"model": BLAND_DEFAULT_MODEL,
|
||||
"max_duration": max_duration,
|
||||
"record": True,
|
||||
"wait_for_greeting": True,
|
||||
}
|
||||
if first_sentence:
|
||||
payload["first_sentence"] = first_sentence
|
||||
|
||||
result = _http("POST", f"{BLAND_API_BASE}/calls",
|
||||
{"Content-Type": "application/json", "authorization": api_key},
|
||||
payload)
|
||||
|
||||
call_id = result.get("call_id")
|
||||
if not call_id:
|
||||
print(f"Error: Bland.ai returned no call_id: {json.dumps(result)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"provider": "bland",
|
||||
"call_id": call_id,
|
||||
"phone_number": phone_number,
|
||||
"voice": voice,
|
||||
"max_duration": max_duration,
|
||||
"message": "Call initiated. Use 'status' command to check results.",
|
||||
}
|
||||
|
||||
|
||||
def bland_status(call_id: str, analyze: str | None = None) -> dict:
|
||||
api_key = _bland_api_key()
|
||||
if not api_key:
|
||||
print("Error: No Bland.ai API key.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
data = _http("GET", f"{BLAND_API_BASE}/calls/{call_id}",
|
||||
{"authorization": api_key})
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"provider": "bland",
|
||||
"status": data.get("status"),
|
||||
"duration_minutes": data.get("call_length"),
|
||||
"answered_by": data.get("answered_by"),
|
||||
"transcript": data.get("concatenated_transcript", ""),
|
||||
"recording_url": data.get("recording_url"),
|
||||
}
|
||||
|
||||
if analyze and data.get("status") == "completed":
|
||||
questions = [[q.strip(), "string"] for q in analyze.split(",") if q.strip()]
|
||||
if questions:
|
||||
try:
|
||||
analysis = _http("POST", f"{BLAND_API_BASE}/calls/{call_id}/analyze",
|
||||
{"Content-Type": "application/json", "authorization": api_key},
|
||||
{"questions": questions})
|
||||
result["analysis"] = analysis
|
||||
except Exception as e:
|
||||
result["analysis_error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vapi
|
||||
# ---------------------------------------------------------------------------
|
||||
def _vapi_api_key() -> str:
|
||||
return _env_or_config("VAPI_API_KEY", ["vapi", "api_key"])
|
||||
|
||||
|
||||
def _vapi_phone_number_id() -> str:
|
||||
return _env_or_config("VAPI_PHONE_NUMBER_ID", ["vapi", "phone_number_id"])
|
||||
|
||||
|
||||
def vapi_call(phone_number: str, task: str, voice_id: str | None = None,
|
||||
first_sentence: str | None = None, max_duration: int = 3) -> dict:
|
||||
api_key = _vapi_api_key()
|
||||
if not api_key:
|
||||
print("Error: No Vapi API key. Set VAPI_API_KEY or add to ~/.hermes/config.yaml under phone.vapi.api_key", file=sys.stderr)
|
||||
print("Sign up at https://dashboard.vapi.ai", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
phone_number_id = _vapi_phone_number_id()
|
||||
if not phone_number_id:
|
||||
print("Error: No Vapi phone number ID. Vapi requires a Twilio number for outbound calls.", file=sys.stderr)
|
||||
print("Setup: 1) Sign up at twilio.com 2) Buy a number 3) Import into Vapi 4) Set VAPI_PHONE_NUMBER_ID", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
voice_provider = _env_or_config("VAPI_VOICE_PROVIDER", ["vapi", "default_voice_provider"], VAPI_DEFAULT_VOICE_PROVIDER)
|
||||
if voice_id is None:
|
||||
voice_id = _env_or_config("VAPI_VOICE_ID", ["vapi", "default_voice_id"], VAPI_DEFAULT_VOICE_ID)
|
||||
model = _env_or_config("VAPI_MODEL", ["vapi", "model"], VAPI_DEFAULT_MODEL)
|
||||
|
||||
assistant = {
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"model": model,
|
||||
"messages": [{"role": "system", "content": task}],
|
||||
},
|
||||
"voice": {
|
||||
"provider": voice_provider,
|
||||
"voiceId": voice_id,
|
||||
},
|
||||
"maxDurationSeconds": max_duration * 60,
|
||||
}
|
||||
if first_sentence:
|
||||
assistant["firstMessage"] = first_sentence
|
||||
|
||||
payload = {
|
||||
"phoneNumberId": phone_number_id,
|
||||
"customer": {"number": phone_number},
|
||||
"assistant": assistant,
|
||||
}
|
||||
|
||||
result = _http("POST", f"{VAPI_API_BASE}/call",
|
||||
{"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
payload)
|
||||
|
||||
call_id = result.get("id")
|
||||
if not call_id:
|
||||
print(f"Error: Vapi returned no call id: {json.dumps(result)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"provider": "vapi",
|
||||
"call_id": call_id,
|
||||
"phone_number": phone_number,
|
||||
"voice_provider": voice_provider,
|
||||
"voice_id": voice_id,
|
||||
"max_duration": max_duration,
|
||||
"message": "Call initiated. Use 'status' command to check results.",
|
||||
}
|
||||
|
||||
|
||||
def vapi_status(call_id: str) -> dict:
|
||||
api_key = _vapi_api_key()
|
||||
if not api_key:
|
||||
print("Error: No Vapi API key.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
data = _http("GET", f"{VAPI_API_BASE}/call/{call_id}",
|
||||
{"Authorization": f"Bearer {api_key}"})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"provider": "vapi",
|
||||
"status": data.get("status"),
|
||||
"duration_seconds": data.get("duration"),
|
||||
"ended_reason": data.get("endedReason"),
|
||||
"transcript": data.get("transcript", ""),
|
||||
"recording_url": data.get("recordingUrl"),
|
||||
"summary": data.get("summary"),
|
||||
"cost": data.get("cost"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnose — check config
|
||||
# ---------------------------------------------------------------------------
|
||||
def diagnose():
|
||||
provider = _get_provider()
|
||||
print(f"Phone Call Tool — Diagnostics")
|
||||
print("=" * 45)
|
||||
print(f" Active provider: {provider}")
|
||||
|
||||
# Bland
|
||||
bland_key = _bland_api_key()
|
||||
bland_voice = _env_or_config("BLAND_DEFAULT_VOICE", ["bland", "default_voice"], BLAND_DEFAULT_VOICE)
|
||||
print(f"\n Bland.ai:")
|
||||
print(f" API key: {'set' if bland_key else 'NOT SET (BLAND_API_KEY)'}")
|
||||
print(f" Voice: {bland_voice}")
|
||||
|
||||
# Vapi
|
||||
vapi_key = _vapi_api_key()
|
||||
vapi_phone = _vapi_phone_number_id()
|
||||
vapi_voice_provider = _env_or_config("VAPI_VOICE_PROVIDER", ["vapi", "default_voice_provider"], VAPI_DEFAULT_VOICE_PROVIDER)
|
||||
vapi_voice_id = _env_or_config("VAPI_VOICE_ID", ["vapi", "default_voice_id"], VAPI_DEFAULT_VOICE_ID)
|
||||
vapi_model = _env_or_config("VAPI_MODEL", ["vapi", "model"], VAPI_DEFAULT_MODEL)
|
||||
print(f"\n Vapi:")
|
||||
print(f" API key: {'set' if vapi_key else 'NOT SET (VAPI_API_KEY)'}")
|
||||
print(f" Phone number: {'set' if vapi_phone else 'NOT SET (VAPI_PHONE_NUMBER_ID)'}")
|
||||
print(f" Voice: {vapi_voice_provider}:{vapi_voice_id}")
|
||||
print(f" Model: {vapi_model}")
|
||||
|
||||
print(f"\n Bland.ai voices:")
|
||||
for name, desc in BLAND_VOICES.items():
|
||||
print(f" {name:10s} — {desc}")
|
||||
|
||||
# Ready?
|
||||
ready = False
|
||||
if provider == "bland" and bland_key:
|
||||
ready = True
|
||||
elif provider == "vapi" and vapi_key and vapi_phone:
|
||||
ready = True
|
||||
print(f"\n Ready: {'YES' if ready else 'NO — configure API keys above'}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args or args[0] in ("-h", "--help", "help"):
|
||||
print(__doc__)
|
||||
sys.exit(0)
|
||||
|
||||
command = args[0]
|
||||
|
||||
if command == "diagnose":
|
||||
diagnose()
|
||||
return
|
||||
|
||||
if command == "call":
|
||||
if len(args) < 3:
|
||||
print("Usage: phone_call.py call <phone_number> <task> [options]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
phone_number = args[1]
|
||||
task = args[2]
|
||||
|
||||
# Parse optional flags
|
||||
voice = None
|
||||
first_sentence = None
|
||||
max_duration = 3
|
||||
i = 3
|
||||
while i < len(args):
|
||||
if args[i] == "--voice" and i + 1 < len(args):
|
||||
voice = args[i + 1]; i += 2
|
||||
elif args[i] == "--first-sentence" and i + 1 < len(args):
|
||||
first_sentence = args[i + 1]; i += 2
|
||||
elif args[i] == "--max-duration" and i + 1 < len(args):
|
||||
max_duration = int(args[i + 1]); i += 2
|
||||
else:
|
||||
print(f"Unknown option: {args[i]}", file=sys.stderr); sys.exit(1)
|
||||
|
||||
if not phone_number.startswith("+"):
|
||||
print(f"Error: Phone number must be E.164 format (e.g. +15551234567), got: {phone_number}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
provider = _get_provider()
|
||||
if provider == "vapi":
|
||||
result = vapi_call(phone_number, task, voice_id=voice,
|
||||
first_sentence=first_sentence, max_duration=max_duration)
|
||||
else:
|
||||
result = bland_call(phone_number, task, voice=voice,
|
||||
first_sentence=first_sentence, max_duration=max_duration)
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
elif command == "status":
|
||||
if len(args) < 2:
|
||||
print("Usage: phone_call.py status <call_id> [--analyze 'q1,q2']", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
call_id = args[1]
|
||||
analyze = None
|
||||
if len(args) > 2 and args[2] == "--analyze" and len(args) > 3:
|
||||
analyze = args[3]
|
||||
|
||||
provider = _get_provider()
|
||||
if provider == "vapi":
|
||||
result = vapi_status(call_id)
|
||||
else:
|
||||
result = bland_status(call_id, analyze=analyze)
|
||||
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}. Use 'call', 'status', or 'diagnose'.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,6 +8,7 @@ metadata:
|
||||
hermes:
|
||||
tags: [search, duckduckgo, web-search, free, fallback]
|
||||
related_skills: [arxiv]
|
||||
fallback_for_toolsets: [web]
|
||||
---
|
||||
|
||||
# DuckDuckGo Search
|
||||
|
||||
@@ -8,6 +8,8 @@ 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,
|
||||
@@ -277,3 +279,177 @@ 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
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,130 @@
|
||||
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"
|
||||
@@ -396,3 +396,73 @@ 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
|
||||
|
||||
@@ -1283,3 +1283,83 @@ 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"
|
||||
|
||||
@@ -249,6 +249,85 @@ 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
|
||||
|
||||
@@ -2326,3 +2326,127 @@ 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)
|
||||
|
||||
@@ -95,21 +95,34 @@ 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(
|
||||
["git"] + args,
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
cwd=str(Path(working_dir).resolve()),
|
||||
)
|
||||
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
|
||||
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
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "", f"git timed out after {timeout}s: git {' '.join(args)}"
|
||||
msg = f"git timed out after {timeout}s: {' '.join(cmd)}"
|
||||
logger.error(msg, exc_info=True)
|
||||
return False, "", msg
|
||||
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)
|
||||
|
||||
|
||||
@@ -287,7 +300,7 @@ class CheckpointManager:
|
||||
["cat-file", "-t", commit_hash], shadow, abs_dir,
|
||||
)
|
||||
if not ok:
|
||||
return {"success": False, "error": f"Checkpoint '{commit_hash}' not found"}
|
||||
return {"success": False, "error": f"Checkpoint '{commit_hash}' not found", "debug": err or None}
|
||||
|
||||
# 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]})")
|
||||
@@ -299,7 +312,7 @@ class CheckpointManager:
|
||||
)
|
||||
|
||||
if not ok:
|
||||
return {"success": False, "error": f"Restore failed: {err}"}
|
||||
return {"success": False, "error": "Restore failed", "debug": err or None}
|
||||
|
||||
# Get info about what was restored
|
||||
ok2, reason_out, _ = _run_git(
|
||||
|
||||
@@ -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)
|
||||
logger.error("Error upscaling image: %s", e, exc_info=True)
|
||||
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)
|
||||
logger.error("%s", error_msg, exc_info=True)
|
||||
|
||||
# Prepare error response - minimal format
|
||||
response_data = {
|
||||
|
||||
+7
-13
@@ -1331,29 +1331,23 @@ 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."""
|
||||
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 []
|
||||
return await _discover_and_register_server(name, cfg)
|
||||
|
||||
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 result in results:
|
||||
for name, result in zip(server_names, results):
|
||||
if isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
logger.warning("MCP discovery error: %s", result)
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server '%s': %s",
|
||||
name, result,
|
||||
)
|
||||
elif isinstance(result, list):
|
||||
all_tools.extend(result)
|
||||
else:
|
||||
|
||||
@@ -259,6 +259,7 @@ 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,6 +55,8 @@ 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
|
||||
@@ -90,6 +92,30 @@ 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
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user