mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 12:52:03 +08:00
Compare commits
36 Commits
v2026.3.17
...
fix/discor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8582fc4a2 | ||
|
|
0351e4fa90 | ||
|
|
1b2d6c424c | ||
|
|
28c35d045d | ||
|
|
1f6a1f0028 | ||
|
|
d7029489d6 | ||
|
|
12afccd9ca | ||
|
|
81f76111b0 | ||
|
|
96dac22194 | ||
|
|
2d36819503 | ||
|
|
8e20a7e035 | ||
|
|
4920c5940f | ||
|
|
3744118311 | ||
|
|
5ada0b95e9 | ||
|
|
19eaf5d956 | ||
|
|
365d175100 | ||
|
|
c3ca68d25b | ||
|
|
eaa9ceeb43 | ||
|
|
949fac192f | ||
|
|
4b96d10bc3 | ||
|
|
c16870277c | ||
|
|
247e3c1470 | ||
|
|
2af4af6390 | ||
|
|
749e9977a0 | ||
|
|
1c61ab6bd9 | ||
|
|
e9f1a8e39b | ||
|
|
b6a51c955e | ||
|
|
634c1f6752 | ||
|
|
67546746d4 | ||
|
|
d44b6b7f1b | ||
|
|
285300528b | ||
|
|
673f132151 | ||
|
|
8d0a96a8bf | ||
|
|
43b8ecd172 | ||
|
|
606f57a3ab | ||
|
|
a5359e61e7 |
81
cli.py
81
cli.py
@@ -468,7 +468,7 @@ from hermes_cli.banner import (
|
||||
VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER,
|
||||
build_welcome_banner,
|
||||
)
|
||||
from hermes_cli.commands import COMMANDS, SlashCommandCompleter
|
||||
from hermes_cli.commands import COMMANDS, SlashCommandCompleter, SlashCommandAutoSuggest
|
||||
from hermes_cli import callbacks as _callbacks
|
||||
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
|
||||
|
||||
@@ -3618,18 +3618,18 @@ class HermesCLI:
|
||||
full_name = matches[0]
|
||||
if full_name == typed_base:
|
||||
# Already an exact token — no expansion possible; fall through
|
||||
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
|
||||
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
||||
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
|
||||
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
|
||||
else:
|
||||
remainder = cmd_original.strip()[len(typed_base):]
|
||||
full_cmd = full_name + remainder
|
||||
return self.process_command(full_cmd)
|
||||
elif len(matches) > 1:
|
||||
self.console.print(f"[bold yellow]Ambiguous command: {cmd_lower}[/]")
|
||||
self.console.print(f"[dim]Did you mean: {', '.join(sorted(matches))}?[/]")
|
||||
_cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}")
|
||||
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
|
||||
else:
|
||||
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
|
||||
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
||||
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
|
||||
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
|
||||
|
||||
return True
|
||||
|
||||
@@ -5746,6 +5746,34 @@ class HermesCLI:
|
||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
@kb.add('tab', eager=True)
|
||||
def handle_tab(event):
|
||||
"""Tab: accept completion and re-trigger if we just completed a provider.
|
||||
|
||||
After accepting a provider like 'anthropic:', the completion menu
|
||||
closes and complete_while_typing doesn't fire (no keystroke).
|
||||
This binding re-triggers completions so stage-2 models appear
|
||||
immediately.
|
||||
"""
|
||||
buf = event.current_buffer
|
||||
if buf.complete_state:
|
||||
completion = buf.complete_state.current_completion
|
||||
if completion is None:
|
||||
# Menu open but nothing selected — select first then grab it
|
||||
buf.go_to_completion(0)
|
||||
completion = buf.complete_state and buf.complete_state.current_completion
|
||||
if completion is None:
|
||||
return
|
||||
# Accept the selected completion
|
||||
buf.apply_completion(completion)
|
||||
# If text now looks like "/model provider:", re-trigger completions
|
||||
text = buf.document.text_before_cursor
|
||||
if text.startswith("/model ") and text.endswith(":"):
|
||||
buf.start_completion()
|
||||
else:
|
||||
# No menu open — start completions from scratch
|
||||
buf.start_completion()
|
||||
|
||||
# --- Clarify tool: arrow-key navigation for multiple-choice questions ---
|
||||
|
||||
@kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))
|
||||
@@ -6012,6 +6040,39 @@ class HermesCLI:
|
||||
return cli_ref._get_tui_prompt_fragments()
|
||||
|
||||
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
|
||||
def _get_model_completer_info() -> dict:
|
||||
"""Return provider/model info for /model autocomplete."""
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_LABELS, _PROVIDER_MODELS, normalize_provider,
|
||||
provider_model_ids,
|
||||
)
|
||||
current = getattr(cli_ref, "provider", None) or getattr(cli_ref, "requested_provider", "openrouter")
|
||||
current = normalize_provider(current)
|
||||
|
||||
# Provider map: id -> label (only providers with known models)
|
||||
providers = {}
|
||||
for pid, plabel in _PROVIDER_LABELS.items():
|
||||
providers[pid] = plabel
|
||||
|
||||
def models_for(provider_name: str) -> list[str]:
|
||||
norm = normalize_provider(provider_name)
|
||||
return provider_model_ids(norm)
|
||||
|
||||
return {
|
||||
"current_provider": current,
|
||||
"providers": providers,
|
||||
"models_for": models_for,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
_completer = SlashCommandCompleter(
|
||||
skill_commands_provider=lambda: _skill_commands,
|
||||
model_completer_provider=_get_model_completer_info,
|
||||
)
|
||||
input_area = TextArea(
|
||||
height=Dimension(min=1, max=8, preferred=1),
|
||||
prompt=get_prompt,
|
||||
@@ -6020,8 +6081,12 @@ class HermesCLI:
|
||||
wrap_lines=True,
|
||||
read_only=Condition(lambda: bool(cli_ref._command_running)),
|
||||
history=FileHistory(str(self._history_file)),
|
||||
completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands),
|
||||
completer=_completer,
|
||||
complete_while_typing=True,
|
||||
auto_suggest=SlashCommandAutoSuggest(
|
||||
history_suggest=AutoSuggestFromHistory(),
|
||||
completer=_completer,
|
||||
),
|
||||
)
|
||||
|
||||
# Dynamic height: accounts for both explicit newlines AND visual
|
||||
|
||||
@@ -510,6 +510,7 @@ class BasePlatformAdapter(ABC):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""
|
||||
Send an image natively via the platform API.
|
||||
@@ -537,7 +538,7 @@ class BasePlatformAdapter(ABC):
|
||||
(e.g., Telegram send_animation) so they auto-play inline.
|
||||
Default falls back to send_image.
|
||||
"""
|
||||
return await self.send_image(chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to)
|
||||
return await self.send_image(chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to, metadata=metadata)
|
||||
|
||||
@staticmethod
|
||||
def _is_animation_url(url: str) -> bool:
|
||||
@@ -727,7 +728,75 @@ class BasePlatformAdapter(ABC):
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
|
||||
|
||||
return media, cleaned
|
||||
|
||||
|
||||
@staticmethod
|
||||
def extract_local_files(content: str) -> Tuple[List[str], str]:
|
||||
"""
|
||||
Detect bare local file paths in response text for native media delivery.
|
||||
|
||||
Matches absolute paths (/...) and tilde paths (~/) ending in common
|
||||
image or video extensions. Validates each candidate with
|
||||
``os.path.isfile()`` to avoid false positives from URLs or
|
||||
non-existent paths.
|
||||
|
||||
Paths inside fenced code blocks (``` ... ```) and inline code
|
||||
(`...`) are ignored so that code samples are never mutilated.
|
||||
|
||||
Returns:
|
||||
Tuple of (list of expanded file paths, cleaned text with the
|
||||
raw path strings removed).
|
||||
"""
|
||||
_LOCAL_MEDIA_EXTS = (
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp',
|
||||
'.mp4', '.mov', '.avi', '.mkv', '.webm',
|
||||
)
|
||||
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
# and relative paths (./foo.png)
|
||||
# (?:~/|/) anchors to absolute or home-relative paths
|
||||
path_re = re.compile(
|
||||
r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Build spans covered by fenced code blocks and inline code
|
||||
code_spans: list = []
|
||||
for m in re.finditer(r'```[^\n]*\n.*?```', content, re.DOTALL):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
for m in re.finditer(r'`[^`\n]+`', content):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
|
||||
def _in_code(pos: int) -> bool:
|
||||
return any(s <= pos < e for s, e in code_spans)
|
||||
|
||||
found: list = [] # (raw_match_text, expanded_path)
|
||||
for match in path_re.finditer(content):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
raw = match.group(0)
|
||||
expanded = os.path.expanduser(raw)
|
||||
if os.path.isfile(expanded):
|
||||
found.append((raw, expanded))
|
||||
|
||||
# Deduplicate by expanded path, preserving discovery order
|
||||
seen: set = set()
|
||||
unique: list = []
|
||||
for raw, expanded in found:
|
||||
if expanded not in seen:
|
||||
seen.add(expanded)
|
||||
unique.append((raw, expanded))
|
||||
|
||||
paths = [expanded for _, expanded in unique]
|
||||
|
||||
cleaned = content
|
||||
if unique:
|
||||
for raw, _exp in unique:
|
||||
cleaned = cleaned.replace(raw, '')
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
|
||||
|
||||
return paths, cleaned
|
||||
|
||||
async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None) -> None:
|
||||
"""
|
||||
Continuously send typing indicator until cancelled.
|
||||
@@ -840,8 +909,17 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Extract image URLs and send them as native platform attachments
|
||||
images, text_content = self.extract_images(response)
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
# Auto-detect bare local file paths for native media delivery
|
||||
# (helps small models that don't use MEDIA: syntax)
|
||||
local_files, text_content = self.extract_local_files(text_content)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Skipped when the chat has voice mode disabled (/voice off)
|
||||
@@ -935,7 +1013,7 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Send extracted media files — route by file type
|
||||
_AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
|
||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.3gp'}
|
||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
|
||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
for media_path, is_voice in media_files:
|
||||
@@ -972,7 +1050,34 @@ class BasePlatformAdapter(ABC):
|
||||
print(f"[{self.name}] Failed to send media ({ext}): {media_result.error}")
|
||||
except Exception as media_err:
|
||||
print(f"[{self.name}] Error sending media: {media_err}")
|
||||
|
||||
|
||||
# Send auto-detected local files as native attachments
|
||||
for file_path in local_files:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
await self.send_image_file(
|
||||
chat_id=event.source.chat_id,
|
||||
image_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
file_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
# Check if there's a pending message that was queued during our processing
|
||||
if session_key in self._pending_messages:
|
||||
pending_event = self._pending_messages.pop(session_key)
|
||||
@@ -1078,7 +1183,8 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return content
|
||||
|
||||
def truncate_message(self, content: str, max_length: int = 4096) -> List[str]:
|
||||
@staticmethod
|
||||
def truncate_message(content: str, max_length: int = 4096) -> List[str]:
|
||||
"""
|
||||
Split a long message into chunks, preserving code block boundaries.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Uses discord.py library for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
@@ -18,6 +19,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -434,8 +436,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._voice_input_callback: Optional[Callable] = None # set by run.py
|
||||
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
|
||||
# Track threads where the bot has participated so follow-up messages
|
||||
# in those threads don't require @mention.
|
||||
self._bot_participated_threads: set = set()
|
||||
# in those threads don't require @mention. Persisted to disk so the
|
||||
# set survives gateway restarts.
|
||||
self._bot_participated_threads: set = self._load_participated_threads()
|
||||
# Cap to prevent unbounded growth (Discord threads get archived).
|
||||
self._MAX_TRACKED_THREADS = 500
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Discord and start receiving events."""
|
||||
@@ -1573,6 +1578,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
|
||||
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
|
||||
|
||||
# Track thread participation so follow-ups don't require @mention
|
||||
if thread_id:
|
||||
self._track_thread(thread_id)
|
||||
|
||||
# If a message was provided, kick off a new Hermes session in the thread
|
||||
starter = (message or "").strip()
|
||||
if starter and thread_id:
|
||||
@@ -1798,6 +1807,49 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return f"{parent_name} / {thread_name}"
|
||||
return thread_name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread participation persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _thread_state_path() -> Path:
|
||||
"""Path to the persisted thread participation set."""
|
||||
from hermes_cli.config import get_hermes_home
|
||||
return get_hermes_home() / "discord_threads.json"
|
||||
|
||||
@classmethod
|
||||
def _load_participated_threads(cls) -> set:
|
||||
"""Load persisted thread IDs from disk."""
|
||||
path = cls._thread_state_path()
|
||||
try:
|
||||
if path.exists():
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return set(data)
|
||||
except Exception as e:
|
||||
logger.debug("Could not load discord thread state: %s", e)
|
||||
return set()
|
||||
|
||||
def _save_participated_threads(self) -> None:
|
||||
"""Persist the current thread set to disk (best-effort)."""
|
||||
path = self._thread_state_path()
|
||||
try:
|
||||
# Trim to most recent entries if over cap
|
||||
thread_list = list(self._bot_participated_threads)
|
||||
if len(thread_list) > self._MAX_TRACKED_THREADS:
|
||||
thread_list = thread_list[-self._MAX_TRACKED_THREADS:]
|
||||
self._bot_participated_threads = set(thread_list)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(thread_list), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.debug("Could not save discord thread state: %s", e)
|
||||
|
||||
def _track_thread(self, thread_id: str) -> None:
|
||||
"""Add a thread to the participation set and persist."""
|
||||
if thread_id not in self._bot_participated_threads:
|
||||
self._bot_participated_threads.add(thread_id)
|
||||
self._save_participated_threads()
|
||||
|
||||
async def _handle_message(self, message: DiscordMessage) -> None:
|
||||
"""Handle incoming Discord messages."""
|
||||
# In server channels (not DMs), require the bot to be @mentioned
|
||||
@@ -1850,7 +1902,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
is_thread = True
|
||||
thread_id = str(thread.id)
|
||||
auto_threaded_channel = thread
|
||||
self._bot_participated_threads.add(thread_id)
|
||||
self._track_thread(thread_id)
|
||||
|
||||
# Determine message type
|
||||
msg_type = MessageType.TEXT
|
||||
@@ -1954,7 +2006,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# Track thread participation so the bot won't require @mention for
|
||||
# follow-up messages in threads it has already engaged in.
|
||||
if thread_id:
|
||||
self._bot_participated_threads.add(thread_id)
|
||||
self._track_thread(thread_id)
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
113
gateway/run.py
113
gateway/run.py
@@ -1869,11 +1869,31 @@ class GatewayRunner:
|
||||
# Surface error details when the agent failed silently (final_response=None)
|
||||
if not response and agent_result.get("failed"):
|
||||
error_detail = agent_result.get("error", "unknown error")
|
||||
response = (
|
||||
f"The request failed: {str(error_detail)[:300]}\n"
|
||||
"Try again or use /reset to start a fresh session."
|
||||
error_str = str(error_detail).lower()
|
||||
|
||||
# Detect context-overflow failures and give specific guidance.
|
||||
# Generic 400 "Error" from Anthropic with large sessions is the
|
||||
# most common cause of this (#1630).
|
||||
_is_ctx_fail = any(p in error_str for p in (
|
||||
"context", "token", "too large", "too long",
|
||||
"exceed", "payload",
|
||||
)) or (
|
||||
"400" in error_str
|
||||
and len(history) > 50
|
||||
)
|
||||
|
||||
if _is_ctx_fail:
|
||||
response = (
|
||||
"⚠️ Session too large for the model's context window.\n"
|
||||
"Use /compact to compress the conversation, or "
|
||||
"/reset to start fresh."
|
||||
)
|
||||
else:
|
||||
response = (
|
||||
f"The request failed: {str(error_detail)[:300]}\n"
|
||||
"Try again or use /reset to start a fresh session."
|
||||
)
|
||||
|
||||
# If the agent's session_id changed during compression, update
|
||||
# session_entry so transcript writes below go to the right session.
|
||||
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
|
||||
@@ -1920,12 +1940,30 @@ class GatewayRunner:
|
||||
# This preserves the complete agent loop (tool_calls, tool results,
|
||||
# intermediate reasoning) so sessions can be resumed with full context
|
||||
# and transcripts are useful for debugging and training data.
|
||||
#
|
||||
# IMPORTANT: When the agent failed before producing any response
|
||||
# (e.g. context-overflow 400), do NOT persist the user's message.
|
||||
# Persisting it would make the session even larger, causing the
|
||||
# same failure on the next attempt — an infinite loop. (#1630)
|
||||
agent_failed_early = (
|
||||
agent_result.get("failed")
|
||||
and not agent_result.get("final_response")
|
||||
)
|
||||
if agent_failed_early:
|
||||
logger.info(
|
||||
"Skipping transcript persistence for failed request in "
|
||||
"session %s to prevent session growth loop.",
|
||||
session_entry.session_id,
|
||||
)
|
||||
|
||||
ts = datetime.now().isoformat()
|
||||
|
||||
# If this is a fresh session (no history), write the full tool
|
||||
# definitions as the first entry so the transcript is self-describing
|
||||
# -- the same list of dicts sent as tools=[...] in the API request.
|
||||
if not history:
|
||||
if agent_failed_early:
|
||||
pass # Skip all transcript writes — don't grow a broken session
|
||||
elif not history:
|
||||
tool_defs = agent_result.get("tools", [])
|
||||
self.session_store.append_to_transcript(
|
||||
session_entry.session_id,
|
||||
@@ -1942,36 +1980,37 @@ class GatewayRunner:
|
||||
# Use the filtered history length (history_offset) that was actually
|
||||
# passed to the agent, not len(history) which includes session_meta
|
||||
# entries that were stripped before the agent saw them.
|
||||
history_len = agent_result.get("history_offset", len(history))
|
||||
new_messages = agent_messages[history_len:] if len(agent_messages) > history_len else []
|
||||
|
||||
# If no new messages found (edge case), fall back to simple user/assistant
|
||||
if not new_messages:
|
||||
self.session_store.append_to_transcript(
|
||||
session_entry.session_id,
|
||||
{"role": "user", "content": message_text, "timestamp": ts}
|
||||
)
|
||||
if response:
|
||||
if not agent_failed_early:
|
||||
history_len = agent_result.get("history_offset", len(history))
|
||||
new_messages = agent_messages[history_len:] if len(agent_messages) > history_len else []
|
||||
|
||||
# If no new messages found (edge case), fall back to simple user/assistant
|
||||
if not new_messages:
|
||||
self.session_store.append_to_transcript(
|
||||
session_entry.session_id,
|
||||
{"role": "assistant", "content": response, "timestamp": ts}
|
||||
)
|
||||
else:
|
||||
# The agent already persisted these messages to SQLite via
|
||||
# _flush_messages_to_session_db(), so skip the DB write here
|
||||
# to prevent the duplicate-write bug (#860). We still write
|
||||
# to JSONL for backward compatibility and as a backup.
|
||||
agent_persisted = self._session_db is not None
|
||||
for msg in new_messages:
|
||||
# Skip system messages (they're rebuilt each run)
|
||||
if msg.get("role") == "system":
|
||||
continue
|
||||
# Add timestamp to each message for debugging
|
||||
entry = {**msg, "timestamp": ts}
|
||||
self.session_store.append_to_transcript(
|
||||
session_entry.session_id, entry,
|
||||
skip_db=agent_persisted,
|
||||
{"role": "user", "content": message_text, "timestamp": ts}
|
||||
)
|
||||
if response:
|
||||
self.session_store.append_to_transcript(
|
||||
session_entry.session_id,
|
||||
{"role": "assistant", "content": response, "timestamp": ts}
|
||||
)
|
||||
else:
|
||||
# The agent already persisted these messages to SQLite via
|
||||
# _flush_messages_to_session_db(), so skip the DB write here
|
||||
# to prevent the duplicate-write bug (#860). We still write
|
||||
# to JSONL for backward compatibility and as a backup.
|
||||
agent_persisted = self._session_db is not None
|
||||
for msg in new_messages:
|
||||
# Skip system messages (they're rebuilt each run)
|
||||
if msg.get("role") == "system":
|
||||
continue
|
||||
# Add timestamp to each message for debugging
|
||||
entry = {**msg, "timestamp": ts}
|
||||
self.session_store.append_to_transcript(
|
||||
session_entry.session_id, entry,
|
||||
skip_db=agent_persisted,
|
||||
)
|
||||
|
||||
# Update session with actual prompt token count and model from the agent
|
||||
self.session_store.update_session(
|
||||
@@ -2005,6 +2044,18 @@ class GatewayRunner:
|
||||
status_hint = " You are being rate-limited. Please wait a moment and try again."
|
||||
elif status_code == 529:
|
||||
status_hint = " The API is temporarily overloaded. Please try again shortly."
|
||||
elif status_code == 400:
|
||||
# 400 with a large session is almost always a context overflow.
|
||||
# Give specific guidance instead of a generic error. (#1630)
|
||||
_hist_len = len(history) if 'history' in locals() else 0
|
||||
if _hist_len > 50:
|
||||
return (
|
||||
"⚠️ Session too large for the model's context window.\n"
|
||||
"Use /compact to compress the conversation, or "
|
||||
"/reset to start fresh."
|
||||
)
|
||||
else:
|
||||
status_hint = " The request was rejected by the API."
|
||||
return (
|
||||
f"Sorry, I encountered an error ({error_type}).\n"
|
||||
f"{error_detail}\n"
|
||||
|
||||
@@ -195,8 +195,8 @@ def write_runtime_status(
|
||||
payload = _read_json_file(path) or _build_runtime_status_record()
|
||||
payload.setdefault("platforms", {})
|
||||
payload.setdefault("kind", _GATEWAY_KIND)
|
||||
payload.setdefault("pid", os.getpid())
|
||||
payload.setdefault("start_time", _get_process_start_time(os.getpid()))
|
||||
payload["pid"] = os.getpid()
|
||||
payload["start_time"] = _get_process_start_time(os.getpid())
|
||||
payload["updated_at"] = _utc_now_iso()
|
||||
|
||||
if gateway_state is not None:
|
||||
|
||||
@@ -11,11 +11,13 @@ To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
|
||||
@@ -32,6 +34,7 @@ class CommandDef:
|
||||
category: str # "Session", "Configuration", etc.
|
||||
aliases: tuple[str, ...] = () # alternative names: ("bg",)
|
||||
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
|
||||
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
||||
cli_only: bool = False # only available in CLI
|
||||
gateway_only: bool = False # only available in gateway/messaging
|
||||
|
||||
@@ -75,17 +78,18 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("provider", "Show available providers and current provider",
|
||||
"Configuration"),
|
||||
CommandDef("prompt", "View/set custom system prompt", "Configuration",
|
||||
cli_only=True, args_hint="[text]"),
|
||||
cli_only=True, args_hint="[text]", subcommands=("clear",)),
|
||||
CommandDef("personality", "Set a predefined personality", "Configuration",
|
||||
args_hint="[name]"),
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||
"Configuration", cli_only=True),
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
args_hint="[level|show|hide]"),
|
||||
args_hint="[level|show|hide]",
|
||||
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
args_hint="[on|off|tts|status]"),
|
||||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||||
|
||||
# Tools & Skills
|
||||
CommandDef("tools", "List available tools", "Tools & Skills",
|
||||
@@ -93,9 +97,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
|
||||
cli_only=True),
|
||||
CommandDef("skills", "Search, install, inspect, or manage skills",
|
||||
"Tools & Skills", cli_only=True),
|
||||
"Tools & Skills", cli_only=True,
|
||||
subcommands=("search", "browse", "inspect", "install")),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]"),
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("plugins", "List installed plugins and their status",
|
||||
@@ -169,6 +175,26 @@ for _cmd in COMMAND_REGISTRY:
|
||||
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
|
||||
|
||||
|
||||
# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
|
||||
SUBCOMMANDS: dict[str, list[str]] = {}
|
||||
for _cmd in COMMAND_REGISTRY:
|
||||
if _cmd.subcommands:
|
||||
SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
|
||||
|
||||
# Also extract subcommands hinted in args_hint via pipe-separated patterns
|
||||
# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
|
||||
# NOTE: If a command already has explicit subcommands, this fallback is skipped.
|
||||
# Use the `subcommands` field on CommandDef for intentional tab-completable args.
|
||||
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
|
||||
for _cmd in COMMAND_REGISTRY:
|
||||
key = f"/{_cmd.name}"
|
||||
if key in SUBCOMMANDS or not _cmd.args_hint:
|
||||
continue
|
||||
m = _PIPE_SUBS_RE.search(_cmd.args_hint)
|
||||
if m:
|
||||
SUBCOMMANDS[key] = m.group(0).split("|")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -237,13 +263,34 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SlashCommandCompleter(Completer):
|
||||
"""Autocomplete for built-in slash commands and optional skill commands."""
|
||||
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
||||
model_completer_provider: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._skill_commands_provider = skill_commands_provider
|
||||
# model_completer_provider returns {"current_provider": str,
|
||||
# "providers": {id: label, ...}, "models_for": callable(provider) -> list[str]}
|
||||
self._model_completer_provider = model_completer_provider
|
||||
self._model_info_cache: dict[str, Any] | None = None
|
||||
self._model_info_cache_time: float = 0
|
||||
|
||||
def _get_model_info(self) -> dict[str, Any]:
|
||||
"""Get cached model/provider info for /model autocomplete."""
|
||||
import time
|
||||
now = time.monotonic()
|
||||
if self._model_info_cache is not None and now - self._model_info_cache_time < 60:
|
||||
return self._model_info_cache
|
||||
if self._model_completer_provider is None:
|
||||
return {}
|
||||
try:
|
||||
self._model_info_cache = self._model_completer_provider() or {}
|
||||
self._model_info_cache_time = now
|
||||
except Exception:
|
||||
self._model_info_cache = self._model_info_cache or {}
|
||||
return self._model_info_cache
|
||||
|
||||
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
|
||||
if self._skill_commands_provider is None:
|
||||
@@ -348,6 +395,70 @@ class SlashCommandCompleter(Completer):
|
||||
yield from self._path_completions(path_word)
|
||||
return
|
||||
|
||||
# Check if we're completing a subcommand (base command already typed)
|
||||
parts = text.split(maxsplit=1)
|
||||
base_cmd = parts[0].lower()
|
||||
if len(parts) > 1 or (len(parts) == 1 and text.endswith(" ")):
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# /model gets two-stage completion:
|
||||
# Stage 1: provider names (with : suffix)
|
||||
# Stage 2: after "provider:", list that provider's models
|
||||
if base_cmd == "/model" and " " not in sub_text:
|
||||
info = self._get_model_info()
|
||||
if info:
|
||||
current_prov = info.get("current_provider", "")
|
||||
providers = info.get("providers", {})
|
||||
models_for = info.get("models_for")
|
||||
|
||||
if ":" in sub_text:
|
||||
# Stage 2: "anthropic:cl" → models for anthropic
|
||||
prov_part, model_part = sub_text.split(":", 1)
|
||||
model_lower = model_part.lower()
|
||||
if models_for:
|
||||
try:
|
||||
prov_models = models_for(prov_part)
|
||||
except Exception:
|
||||
prov_models = []
|
||||
for mid in prov_models:
|
||||
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
|
||||
full = f"{prov_part}:{mid}"
|
||||
yield Completion(
|
||||
full,
|
||||
start_position=-len(sub_text),
|
||||
display=mid,
|
||||
)
|
||||
else:
|
||||
# Stage 1: providers sorted: non-current first, current last
|
||||
for pid, plabel in sorted(
|
||||
providers.items(),
|
||||
key=lambda kv: (kv[0] == current_prov, kv[0]),
|
||||
):
|
||||
display_name = f"{pid}:"
|
||||
if display_name.lower().startswith(sub_lower):
|
||||
meta = f"({plabel})" if plabel != pid else ""
|
||||
if pid == current_prov:
|
||||
meta = f"(current — {plabel})" if plabel != pid else "(current)"
|
||||
yield Completion(
|
||||
display_name,
|
||||
start_position=-len(sub_text),
|
||||
display=display_name,
|
||||
display_meta=meta,
|
||||
)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||||
yield Completion(
|
||||
sub,
|
||||
start_position=-len(sub_text),
|
||||
display=sub,
|
||||
)
|
||||
return
|
||||
|
||||
word = text[1:]
|
||||
|
||||
for cmd, desc in COMMANDS.items():
|
||||
@@ -373,6 +484,90 @@ class SlashCommandCompleter(Completer):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline auto-suggest (ghost text) for slash commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SlashCommandAutoSuggest(AutoSuggest):
|
||||
"""Inline ghost-text suggestions for slash commands and their subcommands.
|
||||
|
||||
Shows the rest of a command or subcommand in dim text as you type.
|
||||
Falls back to history-based suggestions for non-slash input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
history_suggest: AutoSuggest | None = None,
|
||||
completer: SlashCommandCompleter | None = None,
|
||||
) -> None:
|
||||
self._history = history_suggest
|
||||
self._completer = completer # Reuse its model cache
|
||||
|
||||
def get_suggestion(self, buffer, document):
|
||||
text = document.text_before_cursor
|
||||
|
||||
# Only suggest for slash commands
|
||||
if not text.startswith("/"):
|
||||
# Fall back to history for regular text
|
||||
if self._history:
|
||||
return self._history.get_suggestion(buffer, document)
|
||||
return None
|
||||
|
||||
parts = text.split(maxsplit=1)
|
||||
base_cmd = parts[0].lower()
|
||||
|
||||
if len(parts) == 1 and not text.endswith(" "):
|
||||
# Still typing the command name: /upd → suggest "ate"
|
||||
word = text[1:].lower()
|
||||
for cmd in COMMANDS:
|
||||
cmd_name = cmd[1:] # strip leading /
|
||||
if cmd_name.startswith(word) and cmd_name != word:
|
||||
return Suggestion(cmd_name[len(word):])
|
||||
return None
|
||||
|
||||
# Command is complete — suggest subcommands or model names
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# /model gets two-stage ghost text
|
||||
if base_cmd == "/model" and " " not in sub_text and self._completer:
|
||||
info = self._completer._get_model_info()
|
||||
if info:
|
||||
providers = info.get("providers", {})
|
||||
models_for = info.get("models_for")
|
||||
current_prov = info.get("current_provider", "")
|
||||
|
||||
if ":" in sub_text:
|
||||
# Stage 2: after provider:, suggest model
|
||||
prov_part, model_part = sub_text.split(":", 1)
|
||||
model_lower = model_part.lower()
|
||||
if models_for:
|
||||
try:
|
||||
for mid in models_for(prov_part):
|
||||
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
|
||||
return Suggestion(mid[len(model_part):])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Stage 1: suggest provider name with :
|
||||
for pid in sorted(providers, key=lambda p: (p == current_prov, p)):
|
||||
candidate = f"{pid}:"
|
||||
if candidate.lower().startswith(sub_lower) and candidate.lower() != sub_lower:
|
||||
return Suggestion(candidate[len(sub_text):])
|
||||
|
||||
# Static subcommands
|
||||
if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
|
||||
if " " not in sub_text:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||||
return Suggestion(sub[len(sub_text):])
|
||||
|
||||
# Fall back to history
|
||||
if self._history:
|
||||
return self._history.get_suggestion(buffer, document)
|
||||
return None
|
||||
|
||||
|
||||
def _file_size_label(path: str) -> str:
|
||||
"""Return a compact human-readable file size, or '' on error."""
|
||||
try:
|
||||
|
||||
@@ -25,6 +25,18 @@ from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
|
||||
# (managed by setup/provider flows directly).
|
||||
_EXTRA_ENV_KEYS = frozenset({
|
||||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"AUXILIARY_VISION_MODEL",
|
||||
"DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL",
|
||||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
})
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -337,7 +349,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 8,
|
||||
"_config_version": 9,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -507,6 +519,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"BROWSER_USE_API_KEY": {
|
||||
"description": "Browser Use API key for cloud browser (optional — local browser works without this)",
|
||||
"prompt": "Browser Use API key",
|
||||
"url": "https://browser-use.com/",
|
||||
"tools": ["browser_navigate", "browser_click"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"FAL_KEY": {
|
||||
"description": "FAL API key for image generation",
|
||||
"prompt": "FAL API key",
|
||||
@@ -765,7 +785,15 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
|
||||
"""
|
||||
results = {"env_added": [], "config_added": [], "warnings": []}
|
||||
|
||||
|
||||
# ── Always: sanitize .env (split concatenated keys) ──
|
||||
try:
|
||||
fixes = sanitize_env_file()
|
||||
if fixes and not quiet:
|
||||
print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)")
|
||||
except Exception:
|
||||
pass # best-effort; don't block migration on sanitize failure
|
||||
|
||||
# Check config version
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
@@ -808,6 +836,18 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
tz_display = config["timezone"] or "(server-local)"
|
||||
print(f" ✓ Added timezone to config.yaml: {tz_display}")
|
||||
|
||||
# ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──
|
||||
# The new Anthropic auth flow no longer uses this env var.
|
||||
if current_ver < 9:
|
||||
try:
|
||||
old_token = get_env_value("ANTHROPIC_TOKEN")
|
||||
if old_token:
|
||||
save_env_value("ANTHROPIC_TOKEN", "")
|
||||
if not quiet:
|
||||
print(" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
@@ -1121,6 +1161,102 @@ def load_env() -> Dict[str, str]:
|
||||
return env_vars
|
||||
|
||||
|
||||
def _sanitize_env_lines(lines: list) -> list:
|
||||
"""Fix corrupted .env lines before writing.
|
||||
|
||||
Handles two known corruption patterns:
|
||||
1. Concatenated KEY=VALUE pairs on a single line (missing newline between
|
||||
entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).
|
||||
2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.
|
||||
|
||||
Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only
|
||||
split on real Hermes env var names, avoiding false positives from values
|
||||
that happen to contain uppercase text with ``=``.
|
||||
"""
|
||||
# Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.
|
||||
# Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.
|
||||
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
|
||||
|
||||
sanitized: list[str] = []
|
||||
for line in lines:
|
||||
raw = line.rstrip("\r\n")
|
||||
stripped = raw.strip()
|
||||
|
||||
# Preserve blank lines and comments
|
||||
if not stripped or stripped.startswith("#"):
|
||||
sanitized.append(raw + "\n")
|
||||
continue
|
||||
|
||||
# Detect concatenated KEY=VALUE pairs on one line.
|
||||
# Search for known KEY= patterns at any position in the line.
|
||||
split_positions = []
|
||||
for key_name in known_keys:
|
||||
needle = key_name + "="
|
||||
idx = stripped.find(needle)
|
||||
while idx >= 0:
|
||||
split_positions.append(idx)
|
||||
idx = stripped.find(needle, idx + len(needle))
|
||||
|
||||
if len(split_positions) > 1:
|
||||
split_positions.sort()
|
||||
# Deduplicate (shouldn't happen, but be safe)
|
||||
split_positions = sorted(set(split_positions))
|
||||
for i, pos in enumerate(split_positions):
|
||||
end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
|
||||
part = stripped[pos:end].strip()
|
||||
if part:
|
||||
sanitized.append(part + "\n")
|
||||
else:
|
||||
sanitized.append(stripped + "\n")
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def sanitize_env_file() -> int:
|
||||
"""Read, sanitize, and rewrite ~/.hermes/.env in place.
|
||||
|
||||
Returns the number of lines that were fixed (concatenation splits +
|
||||
placeholder removals). Returns 0 when no changes are needed.
|
||||
"""
|
||||
env_path = get_env_path()
|
||||
if not env_path.exists():
|
||||
return 0
|
||||
|
||||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||||
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
|
||||
|
||||
with open(env_path, **read_kw) as f:
|
||||
original_lines = f.readlines()
|
||||
|
||||
sanitized = _sanitize_env_lines(original_lines)
|
||||
|
||||
if sanitized == original_lines:
|
||||
return 0
|
||||
|
||||
# Count fixes: difference in line count (from splits) + removed lines
|
||||
fixes = abs(len(sanitized) - len(original_lines))
|
||||
if fixes == 0:
|
||||
# Lines changed content (e.g. *** removal) even if count is same
|
||||
fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)
|
||||
fixes += abs(len(sanitized) - len(original_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(sanitized)
|
||||
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)
|
||||
return fixes
|
||||
|
||||
|
||||
def save_env_value(key: str, value: str):
|
||||
"""Save or update a value in ~/.hermes/.env."""
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
@@ -1138,6 +1274,8 @@ def save_env_value(key: str, value: str):
|
||||
if env_path.exists():
|
||||
with open(env_path, **read_kw) as f:
|
||||
lines = f.readlines()
|
||||
# Sanitize on every read: split concatenated keys, drop stale placeholders
|
||||
lines = _sanitize_env_lines(lines)
|
||||
|
||||
# Find and update or append
|
||||
found = False
|
||||
@@ -1258,6 +1396,7 @@ def show_config():
|
||||
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
|
||||
("FIRECRAWL_API_KEY", "Firecrawl"),
|
||||
("BROWSERBASE_API_KEY", "Browserbase"),
|
||||
("BROWSER_USE_API_KEY", "Browser Use"),
|
||||
("FAL_KEY", "FAL"),
|
||||
]
|
||||
|
||||
@@ -1404,7 +1543,7 @@ def set_config_value(key: str, value: str):
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||
'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID',
|
||||
'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
|
||||
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
||||
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
||||
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
|
||||
|
||||
@@ -562,6 +562,12 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
|
||||
scope_flag = " --system" if system else ""
|
||||
|
||||
if unit_path.exists() and not force:
|
||||
if not systemd_unit_is_current(system=system):
|
||||
print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}")
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service definition updated")
|
||||
return
|
||||
print(f"Service already installed at: {unit_path}")
|
||||
print("Use --force to reinstall")
|
||||
return
|
||||
@@ -787,6 +793,11 @@ def launchd_install(force: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
|
||||
if plist_path.exists() and not force:
|
||||
if not launchd_plist_is_current():
|
||||
print(f"↻ Repairing outdated launchd service at: {plist_path}")
|
||||
refresh_launchd_plist_if_needed()
|
||||
print("✓ Service definition updated")
|
||||
return
|
||||
print(f"Service already installed at: {plist_path}")
|
||||
print("Use --force to reinstall")
|
||||
return
|
||||
@@ -816,7 +827,15 @@ def launchd_uninstall():
|
||||
|
||||
def launchd_start():
|
||||
refresh_launchd_plist_if_needed()
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
plist_path = get_launchd_plist_path()
|
||||
try:
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3 or not plist_path.exists():
|
||||
raise
|
||||
print("↻ launchd job was unloaded; reloading service definition")
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
print("✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
@@ -824,22 +843,36 @@ def launchd_stop():
|
||||
print("✓ Service stopped")
|
||||
|
||||
def launchd_restart():
|
||||
refresh_launchd_plist_if_needed()
|
||||
launchd_stop()
|
||||
try:
|
||||
launchd_stop()
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3:
|
||||
raise
|
||||
print("↻ launchd job was unloaded; skipping stop")
|
||||
launchd_start()
|
||||
|
||||
def launchd_status(deep: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
print(f"Launchd plist: {plist_path}")
|
||||
if launchd_plist_is_current():
|
||||
print("✓ Service definition matches the current Hermes install")
|
||||
else:
|
||||
print("⚠ Service definition is stale relative to the current Hermes install")
|
||||
print(" Run: hermes gateway start")
|
||||
|
||||
if result.returncode == 0:
|
||||
print("✓ Gateway service is loaded")
|
||||
print(result.stdout)
|
||||
else:
|
||||
print("✗ Gateway service is not loaded")
|
||||
print(" Service definition exists locally but launchd has not loaded it.")
|
||||
print(" Run: hermes gateway start")
|
||||
|
||||
if deep:
|
||||
log_file = get_hermes_home() / "logs" / "gateway.log"
|
||||
@@ -1555,14 +1588,17 @@ def gateway_command(args):
|
||||
# Try service first, fall back to killing and restarting
|
||||
service_available = False
|
||||
system = getattr(args, 'system', False)
|
||||
service_configured = False
|
||||
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
service_configured = True
|
||||
try:
|
||||
systemd_restart(system=system)
|
||||
service_available = True
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
service_configured = True
|
||||
try:
|
||||
launchd_restart()
|
||||
service_available = True
|
||||
@@ -1586,6 +1622,13 @@ def gateway_command(args):
|
||||
print(" hermes gateway restart")
|
||||
return
|
||||
|
||||
if service_configured:
|
||||
print()
|
||||
print("✗ Gateway service restart failed.")
|
||||
print(" The service definition exists, but the service manager did not recover it.")
|
||||
print(" Fix the service, then retry: hermes gateway start")
|
||||
sys.exit(1)
|
||||
|
||||
# Manual restart: kill existing processes
|
||||
killed = kill_gateway_processes()
|
||||
if killed:
|
||||
|
||||
@@ -2124,7 +2124,17 @@ def _restore_stashed_changes(
|
||||
print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
|
||||
return True
|
||||
|
||||
|
||||
def _invalidate_update_cache():
|
||||
"""Delete the update-check cache so ``hermes --version`` doesn't
|
||||
report a stale "commits behind" count after a successful update."""
|
||||
try:
|
||||
cache_file = Path(os.getenv(
|
||||
"HERMES_HOME", Path.home() / ".hermes"
|
||||
)) / ".update_check"
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version."""
|
||||
@@ -2197,6 +2207,7 @@ def cmd_update(args):
|
||||
commit_count = int(result.stdout.strip())
|
||||
|
||||
if commit_count == 0:
|
||||
_invalidate_update_cache()
|
||||
print("✓ Already up to date!")
|
||||
return
|
||||
|
||||
@@ -2217,6 +2228,8 @@ def cmd_update(args):
|
||||
prompt_user=prompt_for_restore,
|
||||
)
|
||||
|
||||
_invalidate_update_cache()
|
||||
|
||||
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
|
||||
print("→ Updating Python dependencies...")
|
||||
uv_bin = shutil.which("uv")
|
||||
@@ -2980,7 +2993,8 @@ For more help on a command:
|
||||
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
|
||||
skills_install.add_argument("identifier", help="Skill identifier (e.g. openai/skills/skill-creator)")
|
||||
skills_install.add_argument("--category", default="", help="Category folder to install into")
|
||||
skills_install.add_argument("--force", "--yes", "-y", dest="force", action="store_true", help="Install despite blocked scan verdict")
|
||||
skills_install.add_argument("--force", action="store_true", help="Install despite blocked scan verdict")
|
||||
skills_install.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt (needed in TUI mode)")
|
||||
|
||||
skills_inspect = skills_subparsers.add_parser("inspect", help="Preview a skill without installing")
|
||||
skills_inspect.add_argument("identifier", help="Skill identifier")
|
||||
|
||||
@@ -162,7 +162,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
_PROVIDER_ORDER = [
|
||||
"openrouter", "nous", "openai-codex",
|
||||
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic",
|
||||
"ai-gateway", "deepseek",
|
||||
"ai-gateway", "deepseek", "custom",
|
||||
]
|
||||
# Build reverse alias map
|
||||
aliases_for: dict[str, list[str]] = {}
|
||||
@@ -176,9 +176,12 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
# Check if this provider has credentials available
|
||||
has_creds = False
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
runtime = resolve_runtime_provider(requested=pid)
|
||||
has_creds = bool(runtime.get("api_key"))
|
||||
if pid == "custom":
|
||||
has_creds = bool(_get_custom_base_url())
|
||||
else:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
runtime = resolve_runtime_provider(requested=pid)
|
||||
has_creds = bool(runtime.get("api_key"))
|
||||
except Exception:
|
||||
pass
|
||||
result.append({
|
||||
@@ -217,6 +220,19 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
|
||||
return (current_provider, stripped)
|
||||
|
||||
|
||||
def _get_custom_base_url() -> str:
|
||||
"""Get the custom endpoint base_url from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
model_cfg = config.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
return str(model_cfg.get("base_url", "")).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]]:
|
||||
"""Return ``(model_id, description)`` tuples for a provider's model list.
|
||||
|
||||
@@ -396,6 +412,18 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
|
||||
live = _fetch_ai_gateway_models()
|
||||
if live:
|
||||
return live
|
||||
if normalized == "custom":
|
||||
base_url = _get_custom_base_url()
|
||||
if base_url:
|
||||
# Try common API key env vars for custom endpoints
|
||||
api_key = (
|
||||
os.getenv("CUSTOM_API_KEY", "")
|
||||
or os.getenv("OPENAI_API_KEY", "")
|
||||
or os.getenv("OPENROUTER_API_KEY", "")
|
||||
)
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
if live:
|
||||
return live
|
||||
return list(_PROVIDER_MODELS.get(normalized, []))
|
||||
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
console: Optional[Console] = None) -> None:
|
||||
console: Optional[Console] = None, skip_confirm: bool = False) -> None:
|
||||
"""Fetch, quarantine, scan, confirm, and install a skill."""
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, ensure_hub_dirs,
|
||||
@@ -378,7 +378,8 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
c.print(Panel("\n".join(metadata_lines), title="Upstream Metadata", border_style="blue"))
|
||||
|
||||
# Confirm with user — show appropriate warning based on source
|
||||
if not force:
|
||||
# skip_confirm bypasses the prompt (needed in TUI mode where input() hangs)
|
||||
if not force and not skip_confirm:
|
||||
c.print()
|
||||
if bundle.source == "official":
|
||||
c.print(Panel(
|
||||
@@ -598,20 +599,23 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> N
|
||||
c.print()
|
||||
|
||||
|
||||
def do_uninstall(name: str, console: Optional[Console] = None) -> None:
|
||||
def do_uninstall(name: str, console: Optional[Console] = None,
|
||||
skip_confirm: bool = False) -> None:
|
||||
"""Remove a hub-installed skill with confirmation."""
|
||||
from tools.skills_hub import uninstall_skill
|
||||
|
||||
c = console or _console
|
||||
|
||||
c.print(f"\n[bold]Uninstall '{name}'?[/]")
|
||||
try:
|
||||
answer = input("Confirm [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
if answer not in ("y", "yes"):
|
||||
c.print("[dim]Cancelled.[/]\n")
|
||||
return
|
||||
# skip_confirm bypasses the prompt (needed in TUI mode where input() hangs)
|
||||
if not skip_confirm:
|
||||
c.print(f"\n[bold]Uninstall '{name}'?[/]")
|
||||
try:
|
||||
answer = input("Confirm [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
if answer not in ("y", "yes"):
|
||||
c.print("[dim]Cancelled.[/]\n")
|
||||
return
|
||||
|
||||
success, msg = uninstall_skill(name)
|
||||
if success:
|
||||
@@ -923,7 +927,8 @@ def skills_command(args) -> None:
|
||||
elif action == "search":
|
||||
do_search(args.query, source=args.source, limit=args.limit)
|
||||
elif action == "install":
|
||||
do_install(args.identifier, category=args.category, force=args.force)
|
||||
do_install(args.identifier, category=args.category, force=args.force,
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
elif action == "inspect":
|
||||
do_inspect(args.identifier)
|
||||
elif action == "list":
|
||||
@@ -1054,11 +1059,15 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
return
|
||||
identifier = args[0]
|
||||
category = ""
|
||||
force = any(flag in args for flag in ("--force", "--yes", "-y"))
|
||||
# --yes / -y bypasses confirmation prompt (needed in TUI mode)
|
||||
# --force handles reinstall override
|
||||
skip_confirm = any(flag in args for flag in ("--yes", "-y"))
|
||||
force = "--force" in args
|
||||
for i, a in enumerate(args):
|
||||
if a == "--category" and i + 1 < len(args):
|
||||
category = args[i + 1]
|
||||
do_install(identifier, category=category, force=force, console=c)
|
||||
do_install(identifier, category=category, force=force,
|
||||
skip_confirm=skip_confirm, console=c)
|
||||
|
||||
elif action == "inspect":
|
||||
if not args:
|
||||
@@ -1088,9 +1097,10 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "uninstall":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills uninstall <name>\n")
|
||||
c.print("[bold red]Usage:[/] /skills uninstall <name> [--yes]\n")
|
||||
return
|
||||
do_uninstall(args[0], console=c)
|
||||
skip_confirm = any(flag in args for flag in ("--yes", "-y"))
|
||||
do_uninstall(args[0], console=c, skip_confirm=skip_confirm)
|
||||
|
||||
elif action == "publish":
|
||||
if not args:
|
||||
|
||||
@@ -190,6 +190,7 @@ TOOL_CATEGORIES = {
|
||||
"name": "Local Browser",
|
||||
"tag": "Free headless Chromium (no API key needed)",
|
||||
"env_vars": [],
|
||||
"browser_provider": None,
|
||||
"post_setup": "browserbase", # Same npm install for agent-browser
|
||||
},
|
||||
{
|
||||
@@ -199,6 +200,16 @@ TOOL_CATEGORIES = {
|
||||
{"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
|
||||
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
|
||||
],
|
||||
"browser_provider": "browserbase",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
{
|
||||
"name": "Browser Use",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
|
||||
],
|
||||
"browser_provider": "browser-use",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
],
|
||||
@@ -575,10 +586,10 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
if _is_provider_active(p, config):
|
||||
configured = " [active]"
|
||||
elif not env_vars:
|
||||
configured = " [active]" if config.get("tts", {}).get("provider", "edge") == p.get("tts_provider", "") else ""
|
||||
configured = ""
|
||||
else:
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{tag}{configured}")
|
||||
@@ -587,15 +598,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
provider_choices.append("Skip — keep defaults / configure later")
|
||||
|
||||
# Detect current provider as default
|
||||
default_idx = 0
|
||||
for i, p in enumerate(providers):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
default_idx = i
|
||||
break
|
||||
env_vars = p.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(v["key"]) for v in env_vars):
|
||||
default_idx = i
|
||||
break
|
||||
default_idx = _detect_active_provider_index(providers, config)
|
||||
|
||||
provider_idx = _prompt_choice(f" {title}:", provider_choices, default_idx)
|
||||
|
||||
@@ -607,6 +610,28 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
_configure_provider(providers[provider_idx], config)
|
||||
|
||||
|
||||
def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
"""Check if a provider entry matches the currently active config."""
|
||||
if provider.get("tts_provider"):
|
||||
return config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||
if "browser_provider" in provider:
|
||||
current = config.get("browser", {}).get("cloud_provider")
|
||||
return provider["browser_provider"] == current
|
||||
return False
|
||||
|
||||
|
||||
def _detect_active_provider_index(providers: list, config: dict) -> int:
|
||||
"""Return the index of the currently active provider, or 0."""
|
||||
for i, p in enumerate(providers):
|
||||
if _is_provider_active(p, config):
|
||||
return i
|
||||
# Fallback: env vars present → likely configured
|
||||
env_vars = p.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(v["key"]) for v in env_vars):
|
||||
return i
|
||||
return 0
|
||||
|
||||
|
||||
def _configure_provider(provider: dict, config: dict):
|
||||
"""Configure a single provider - prompt for API keys and set config."""
|
||||
env_vars = provider.get("env_vars", [])
|
||||
@@ -615,6 +640,15 @@ def _configure_provider(provider: dict, config: dict):
|
||||
if provider.get("tts_provider"):
|
||||
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
|
||||
|
||||
# Set browser cloud provider in config if applicable
|
||||
if "browser_provider" in provider:
|
||||
bp = provider["browser_provider"]
|
||||
if bp:
|
||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
||||
_print_success(f" Browser cloud provider set to: {bp}")
|
||||
else:
|
||||
config.get("browser", {}).pop("cloud_provider", None)
|
||||
|
||||
if not env_vars:
|
||||
_print_success(f" {provider['name']} - no configuration needed!")
|
||||
return
|
||||
@@ -767,7 +801,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
if _is_provider_active(p, config):
|
||||
configured = " [active]"
|
||||
elif not env_vars:
|
||||
configured = ""
|
||||
@@ -775,15 +809,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{tag}{configured}")
|
||||
|
||||
default_idx = 0
|
||||
for i, p in enumerate(providers):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
default_idx = i
|
||||
break
|
||||
env_vars = p.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(v["key"]) for v in env_vars):
|
||||
default_idx = i
|
||||
break
|
||||
default_idx = _detect_active_provider_index(providers, config)
|
||||
|
||||
provider_idx = _prompt_choice(" Select provider:", provider_choices, default_idx)
|
||||
_reconfigure_provider(providers[provider_idx], config)
|
||||
@@ -797,6 +823,15 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
|
||||
_print_success(f" TTS provider set to: {provider['tts_provider']}")
|
||||
|
||||
if "browser_provider" in provider:
|
||||
bp = provider["browser_provider"]
|
||||
if bp:
|
||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
||||
_print_success(f" Browser cloud provider set to: {bp}")
|
||||
else:
|
||||
config.get("browser", {}).pop("cloud_provider", None)
|
||||
_print_success(f" Browser set to local mode")
|
||||
|
||||
if not env_vars:
|
||||
_print_success(f" {provider['name']} - no configuration needed!")
|
||||
return
|
||||
|
||||
231
optional-skills/blockchain/base/SKILL.md
Normal file
231
optional-skills/blockchain/base/SKILL.md
Normal file
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: base
|
||||
description: Query Base (Ethereum L2) blockchain data with USD pricing — wallet balances, token info, transaction details, gas analysis, contract inspection, whale detection, and live network stats. Uses Base RPC + CoinGecko. No API key required.
|
||||
version: 0.1.0
|
||||
author: youssefea
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Base, Blockchain, Crypto, Web3, RPC, DeFi, EVM, L2, Ethereum]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Base Blockchain Skill
|
||||
|
||||
Query Base (Ethereum L2) on-chain data enriched with USD pricing via CoinGecko.
|
||||
8 commands: wallet portfolio, token info, transactions, gas analysis,
|
||||
contract inspection, whale detection, network stats, and price lookup.
|
||||
|
||||
No API key needed. Uses only Python standard library (urllib, json, argparse).
|
||||
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks for a Base wallet balance, token holdings, or portfolio value
|
||||
- User wants to inspect a specific transaction by hash
|
||||
- User wants ERC-20 token metadata, price, supply, or market cap
|
||||
- User wants to understand Base gas costs and L1 data fees
|
||||
- User wants to inspect a contract (ERC type detection, proxy resolution)
|
||||
- User wants to find large ETH transfers (whale detection)
|
||||
- User wants Base network health, gas price, or ETH price
|
||||
- User asks "what's the price of USDC/AERO/DEGEN/ETH?"
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The helper script uses only Python standard library (urllib, json, argparse).
|
||||
No external packages required.
|
||||
|
||||
Pricing data comes from CoinGecko's free API (no key needed, rate-limited
|
||||
to ~10-30 requests/minute). For faster lookups, use `--no-prices` flag.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
RPC endpoint (default): https://mainnet.base.org
|
||||
Override: export BASE_RPC_URL=https://your-private-rpc.com
|
||||
|
||||
Helper script path: ~/.hermes/skills/blockchain/base/scripts/base_client.py
|
||||
|
||||
```
|
||||
python3 base_client.py wallet <address> [--limit N] [--all] [--no-prices]
|
||||
python3 base_client.py tx <hash>
|
||||
python3 base_client.py token <contract_address>
|
||||
python3 base_client.py gas
|
||||
python3 base_client.py contract <address>
|
||||
python3 base_client.py whales [--min-eth N]
|
||||
python3 base_client.py stats
|
||||
python3 base_client.py price <contract_address_or_symbol>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Procedure
|
||||
|
||||
### 0. Setup Check
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
|
||||
# Optional: set a private RPC for better rate limits
|
||||
export BASE_RPC_URL="https://mainnet.base.org"
|
||||
|
||||
# Confirm connectivity
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats
|
||||
```
|
||||
|
||||
### 1. Wallet Portfolio
|
||||
|
||||
Get ETH balance and ERC-20 token holdings with USD values.
|
||||
Checks ~15 well-known Base tokens (USDC, WETH, AERO, DEGEN, etc.)
|
||||
via on-chain `balanceOf` calls. Tokens sorted by value, dust filtered.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \
|
||||
wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--limit N` — show top N tokens (default: 20)
|
||||
- `--all` — show all tokens, no dust filter, no limit
|
||||
- `--no-prices` — skip CoinGecko price lookups (faster, RPC-only)
|
||||
|
||||
Output includes: ETH balance + USD value, token list with prices sorted
|
||||
by value, dust count, total portfolio value in USD.
|
||||
|
||||
Note: Only checks known tokens. Unknown ERC-20s are not discovered.
|
||||
Use the `token` command with a specific contract address for any token.
|
||||
|
||||
### 2. Transaction Details
|
||||
|
||||
Inspect a full transaction by its hash. Shows ETH value transferred,
|
||||
gas used, fee in ETH/USD, status, and decoded ERC-20/ERC-721 transfers.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \
|
||||
tx 0xabc123...your_tx_hash_here
|
||||
```
|
||||
|
||||
Output: hash, block, from, to, value (ETH + USD), gas price, gas used,
|
||||
fee, status, contract creation address (if any), token transfers.
|
||||
|
||||
### 3. Token Info
|
||||
|
||||
Get ERC-20 token metadata: name, symbol, decimals, total supply, price,
|
||||
market cap, and contract code size.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \
|
||||
token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
|
||||
```
|
||||
|
||||
Output: name, symbol, decimals, total supply, price, market cap.
|
||||
Reads name/symbol/decimals directly from the contract via eth_call.
|
||||
|
||||
### 4. Gas Analysis
|
||||
|
||||
Detailed gas analysis with cost estimates for common operations.
|
||||
Shows current gas price, base fee trends over 10 blocks, block
|
||||
utilization, and estimated costs for ETH transfers, ERC-20 transfers,
|
||||
and swaps.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py gas
|
||||
```
|
||||
|
||||
Output: current gas price, base fee, block utilization, 10-block trend,
|
||||
cost estimates in ETH and USD.
|
||||
|
||||
Note: Base is an L2 — actual transaction costs include an L1 data
|
||||
posting fee that depends on calldata size and L1 gas prices. The
|
||||
estimates shown are for L2 execution only.
|
||||
|
||||
### 5. Contract Inspection
|
||||
|
||||
Inspect an address: determine if it's an EOA or contract, detect
|
||||
ERC-20/ERC-721/ERC-1155 interfaces, resolve EIP-1967 proxy
|
||||
implementation addresses.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \
|
||||
contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
|
||||
```
|
||||
|
||||
Output: is_contract, code size, ETH balance, detected interfaces
|
||||
(ERC-20, ERC-721, ERC-1155), ERC-20 metadata, proxy implementation
|
||||
address.
|
||||
|
||||
### 6. Whale Detector
|
||||
|
||||
Scan the most recent block for large ETH transfers with USD values.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \
|
||||
whales --min-eth 1.0
|
||||
```
|
||||
|
||||
Note: scans the latest block only — point-in-time snapshot, not historical.
|
||||
Default threshold is 1.0 ETH (lower than Solana's default since ETH
|
||||
values are higher).
|
||||
|
||||
### 7. Network Stats
|
||||
|
||||
Live Base network health: latest block, chain ID, gas price, base fee,
|
||||
block utilization, transaction count, and ETH price.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats
|
||||
```
|
||||
|
||||
### 8. Price Lookup
|
||||
|
||||
Quick price check for any token by contract address or known symbol.
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price ETH
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price USDC
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price AERO
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price DEGEN
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
|
||||
```
|
||||
|
||||
Known symbols: ETH, WETH, USDC, cbETH, AERO, DEGEN, TOSHI, BRETT,
|
||||
WELL, wstETH, rETH, cbBTC.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **CoinGecko rate-limits** — free tier allows ~10-30 requests/minute.
|
||||
Price lookups use 1 request per token. Use `--no-prices` for speed.
|
||||
- **Public RPC rate-limits** — Base's public RPC limits requests.
|
||||
For production use, set BASE_RPC_URL to a private endpoint
|
||||
(Alchemy, QuickNode, Infura).
|
||||
- **Wallet shows known tokens only** — unlike Solana, EVM chains have no
|
||||
built-in "get all tokens" RPC. The wallet command checks ~15 popular
|
||||
Base tokens via `balanceOf`. Unknown ERC-20s won't appear. Use the
|
||||
`token` command for any specific contract.
|
||||
- **Token names read from contract** — if a contract doesn't implement
|
||||
`name()` or `symbol()`, these fields may be empty. Known tokens have
|
||||
hardcoded labels as fallback.
|
||||
- **Gas estimates are L2 only** — Base transaction costs include an L1
|
||||
data posting fee (depends on calldata size and L1 gas prices). The gas
|
||||
command estimates L2 execution cost only.
|
||||
- **Whale detector scans latest block only** — not historical. Results
|
||||
vary by the moment you query. Default threshold is 1.0 ETH.
|
||||
- **Proxy detection** — only EIP-1967 proxies are detected. Other proxy
|
||||
patterns (EIP-1167 minimal proxy, custom storage slots) are not checked.
|
||||
- **Retry on 429** — both RPC and CoinGecko calls retry up to 2 times
|
||||
with exponential backoff on rate-limit errors.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Should print Base chain ID (8453), latest block, gas price, and ETH price
|
||||
python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats
|
||||
```
|
||||
1008
optional-skills/blockchain/base/scripts/base_client.py
Normal file
1008
optional-skills/blockchain/base/scripts/base_client.py
Normal file
File diff suppressed because it is too large
Load Diff
52
run_agent.py
52
run_agent.py
@@ -5258,6 +5258,15 @@ class AIAgent:
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
if self.api_mode in ("codex_responses", "anthropic_messages"):
|
||||
prompt_tokens = getattr(response.usage, 'input_tokens', 0) or 0
|
||||
if self.api_mode == "anthropic_messages":
|
||||
# Anthropic splits input into cache_read + cache_creation
|
||||
# + non-cached input_tokens. Without adding the cached
|
||||
# portions, the context bar shows only the tiny non-cached
|
||||
# portion (e.g. 3 tokens) instead of the real total (~18K).
|
||||
# Other providers (OpenAI/Codex) already include cached
|
||||
# tokens in their input_tokens/prompt_tokens field.
|
||||
prompt_tokens += getattr(response.usage, 'cache_read_input_tokens', 0) or 0
|
||||
prompt_tokens += getattr(response.usage, 'cache_creation_input_tokens', 0) or 0
|
||||
completion_tokens = getattr(response.usage, 'output_tokens', 0) or 0
|
||||
total_tokens = (
|
||||
getattr(response.usage, 'total_tokens', None)
|
||||
@@ -5483,6 +5492,27 @@ class AIAgent:
|
||||
'request entity too large', # OpenRouter/Nous 413 safety net
|
||||
'prompt is too long', # Anthropic: "prompt is too long: N tokens > M maximum"
|
||||
])
|
||||
|
||||
# Fallback heuristic: Anthropic sometimes returns a generic
|
||||
# 400 invalid_request_error with just "Error" as the message
|
||||
# when the context is too large. If the error message is very
|
||||
# short/generic AND the session is large, treat it as a
|
||||
# probable context-length error and attempt compression rather
|
||||
# than aborting. This prevents an infinite failure loop where
|
||||
# each failed message gets persisted, making the session even
|
||||
# larger. (#1630)
|
||||
if not is_context_length_error and status_code == 400:
|
||||
ctx_len = getattr(getattr(self, 'context_compressor', None), 'context_length', 200000)
|
||||
is_large_session = approx_tokens > ctx_len * 0.4 or len(api_messages) > 80
|
||||
is_generic_error = len(error_msg.strip()) < 30 # e.g. just "error"
|
||||
if is_large_session and is_generic_error:
|
||||
is_context_length_error = True
|
||||
self._vprint(
|
||||
f"{self.log_prefix}⚠️ Generic 400 with large session "
|
||||
f"(~{approx_tokens:,} tokens, {len(api_messages)} msgs) — "
|
||||
f"treating as probable context overflow.",
|
||||
force=True,
|
||||
)
|
||||
|
||||
if is_context_length_error:
|
||||
compressor = self.context_compressor
|
||||
@@ -5555,7 +5585,13 @@ class AIAgent:
|
||||
# are programming bugs, not transient failures.
|
||||
_RETRYABLE_STATUS_CODES = {413, 429, 529}
|
||||
is_local_validation_error = isinstance(api_error, (ValueError, TypeError))
|
||||
is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 and status_code not in _RETRYABLE_STATUS_CODES
|
||||
# Detect generic 400s from Anthropic OAuth (transient server-side failures).
|
||||
# Real invalid_request_error responses include a descriptive message;
|
||||
# transient ones contain only "Error" or are empty. (ref: issue #1608)
|
||||
_err_body = getattr(api_error, "body", None) or {}
|
||||
_err_message = (_err_body.get("error", {}).get("message", "") if isinstance(_err_body, dict) else "")
|
||||
_is_generic_400 = (status_code == 400 and _err_message.strip().lower() in ("error", ""))
|
||||
is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 and status_code not in _RETRYABLE_STATUS_CODES and not _is_generic_400
|
||||
is_client_error = (is_local_validation_error or is_client_status_error or any(phrase in error_msg for phrase in [
|
||||
'error code: 401', 'error code: 403',
|
||||
'error code: 404', 'error code: 422',
|
||||
@@ -5576,7 +5612,19 @@ class AIAgent:
|
||||
self._vprint(f"{self.log_prefix}❌ Non-retryable client error detected. Aborting immediately.", force=True)
|
||||
self._vprint(f"{self.log_prefix} 💡 This type of error won't be fixed by retrying.", force=True)
|
||||
logging.error(f"{self.log_prefix}Non-retryable client error: {api_error}")
|
||||
self._persist_session(messages, conversation_history)
|
||||
# Skip session persistence when the error is likely
|
||||
# context-overflow related (status 400 + large session).
|
||||
# Persisting the failed user message would make the
|
||||
# session even larger, causing the same failure on the
|
||||
# next attempt. (#1630)
|
||||
if status_code == 400 and (approx_tokens > 50000 or len(api_messages) > 80):
|
||||
self._vprint(
|
||||
f"{self.log_prefix}⚠️ Skipping session persistence "
|
||||
f"for large failed session to prevent growth loop.",
|
||||
force=True,
|
||||
)
|
||||
else:
|
||||
self._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": messages,
|
||||
|
||||
83
tests/gateway/test_discord_thread_persistence.py
Normal file
83
tests/gateway/test_discord_thread_persistence.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for Discord thread participation persistence.
|
||||
|
||||
Verifies that _bot_participated_threads survives adapter restarts by
|
||||
being persisted to ~/.hermes/discord_threads.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDiscordThreadPersistence:
|
||||
"""Thread IDs are saved to disk and reloaded on init."""
|
||||
|
||||
def _make_adapter(self, tmp_path):
|
||||
"""Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path."""
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
return DiscordAdapter(config=config)
|
||||
|
||||
def test_starts_empty_when_no_state_file(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
assert adapter._bot_participated_threads == set()
|
||||
|
||||
def test_track_thread_persists_to_disk(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
adapter._track_thread("111")
|
||||
adapter._track_thread("222")
|
||||
|
||||
state_file = tmp_path / "discord_threads.json"
|
||||
assert state_file.exists()
|
||||
saved = json.loads(state_file.read_text())
|
||||
assert set(saved) == {"111", "222"}
|
||||
|
||||
def test_threads_survive_restart(self, tmp_path):
|
||||
"""Threads tracked by one adapter instance are visible to the next."""
|
||||
adapter1 = self._make_adapter(tmp_path)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
adapter1._track_thread("aaa")
|
||||
adapter1._track_thread("bbb")
|
||||
|
||||
adapter2 = self._make_adapter(tmp_path)
|
||||
assert "aaa" in adapter2._bot_participated_threads
|
||||
assert "bbb" in adapter2._bot_participated_threads
|
||||
|
||||
def test_duplicate_track_does_not_double_save(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
adapter._track_thread("111")
|
||||
adapter._track_thread("111") # no-op
|
||||
|
||||
saved = json.loads((tmp_path / "discord_threads.json").read_text())
|
||||
assert saved.count("111") == 1
|
||||
|
||||
def test_caps_at_max_tracked_threads(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
adapter._MAX_TRACKED_THREADS = 5
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
for i in range(10):
|
||||
adapter._track_thread(str(i))
|
||||
|
||||
assert len(adapter._bot_participated_threads) == 5
|
||||
|
||||
def test_corrupted_state_file_falls_back_to_empty(self, tmp_path):
|
||||
state_file = tmp_path / "discord_threads.json"
|
||||
state_file.write_text("not valid json{{{")
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
assert adapter._bot_participated_threads == set()
|
||||
|
||||
def test_missing_hermes_home_does_not_crash(self, tmp_path):
|
||||
"""Load/save tolerate missing directories."""
|
||||
fake_home = tmp_path / "nonexistent" / "deep"
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(fake_home)}):
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
# _load should return empty set, not crash
|
||||
threads = DiscordAdapter._load_participated_threads()
|
||||
assert threads == set()
|
||||
317
tests/gateway/test_extract_local_files.py
Normal file
317
tests/gateway/test_extract_local_files.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Tests for extract_local_files() — auto-detection of bare local file paths
|
||||
in model response text for native media delivery.
|
||||
|
||||
Covers: path matching, code-block exclusion, URL rejection, tilde expansion,
|
||||
deduplication, text cleanup, and extension routing.
|
||||
|
||||
Based on PR #1636 by sudoingX (salvaged + hardened).
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract(content: str, existing_files: set[str] | None = None):
|
||||
"""
|
||||
Run extract_local_files with os.path.isfile mocked to return True
|
||||
for any path in *existing_files* (expanded form). If *existing_files*
|
||||
is None every path passes.
|
||||
"""
|
||||
existing = existing_files
|
||||
|
||||
def fake_isfile(p):
|
||||
if existing is None:
|
||||
return True
|
||||
return p in existing
|
||||
|
||||
def fake_expanduser(p):
|
||||
if p.startswith("~/"):
|
||||
return "/home/user" + p[1:]
|
||||
return p
|
||||
|
||||
with patch("os.path.isfile", side_effect=fake_isfile), \
|
||||
patch("os.path.expanduser", side_effect=fake_expanduser):
|
||||
return BasePlatformAdapter.extract_local_files(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBasicDetection:
|
||||
|
||||
def test_absolute_path_image(self):
|
||||
paths, cleaned = _extract("Here is the screenshot /root/screenshots/game.png enjoy")
|
||||
assert paths == ["/root/screenshots/game.png"]
|
||||
assert "/root/screenshots/game.png" not in cleaned
|
||||
assert "Here is the screenshot" in cleaned
|
||||
|
||||
def test_tilde_path_image(self):
|
||||
paths, cleaned = _extract("Check out ~/photos/cat.jpg for the cat")
|
||||
assert paths == ["/home/user/photos/cat.jpg"]
|
||||
assert "~/photos/cat.jpg" not in cleaned
|
||||
|
||||
def test_video_extensions(self):
|
||||
for ext in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
|
||||
text = f"Video at /tmp/clip{ext} here"
|
||||
paths, _ = _extract(text)
|
||||
assert len(paths) == 1, f"Failed for {ext}"
|
||||
assert paths[0] == f"/tmp/clip{ext}"
|
||||
|
||||
def test_image_extensions(self):
|
||||
for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
|
||||
text = f"Image at /tmp/pic{ext} here"
|
||||
paths, _ = _extract(text)
|
||||
assert len(paths) == 1, f"Failed for {ext}"
|
||||
assert paths[0] == f"/tmp/pic{ext}"
|
||||
|
||||
def test_case_insensitive_extension(self):
|
||||
paths, _ = _extract("See /tmp/PHOTO.PNG and /tmp/vid.MP4 now")
|
||||
assert len(paths) == 2
|
||||
|
||||
def test_multiple_paths(self):
|
||||
text = "First /tmp/a.png then /tmp/b.jpg and /tmp/c.mp4 done"
|
||||
paths, cleaned = _extract(text)
|
||||
assert len(paths) == 3
|
||||
assert "/tmp/a.png" in paths
|
||||
assert "/tmp/b.jpg" in paths
|
||||
assert "/tmp/c.mp4" in paths
|
||||
for p in paths:
|
||||
assert p not in cleaned
|
||||
|
||||
def test_path_at_line_start(self):
|
||||
paths, _ = _extract("/var/data/image.png")
|
||||
assert paths == ["/var/data/image.png"]
|
||||
|
||||
def test_path_at_end_of_line(self):
|
||||
paths, _ = _extract("saved to /var/data/image.png")
|
||||
assert paths == ["/var/data/image.png"]
|
||||
|
||||
def test_path_with_dots_in_directory(self):
|
||||
paths, _ = _extract("See /opt/my.app/assets/logo.png here")
|
||||
assert paths == ["/opt/my.app/assets/logo.png"]
|
||||
|
||||
def test_path_with_hyphens(self):
|
||||
paths, _ = _extract("File at /tmp/my-screenshot-2024.png done")
|
||||
assert paths == ["/tmp/my-screenshot-2024.png"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-existent files are skipped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsfileGuard:
|
||||
|
||||
def test_nonexistent_path_skipped(self):
|
||||
"""Paths that don't exist on disk are not extracted."""
|
||||
paths, cleaned = _extract(
|
||||
"See /tmp/nope.png here",
|
||||
existing_files=set(), # nothing exists
|
||||
)
|
||||
assert paths == []
|
||||
assert "/tmp/nope.png" in cleaned # not stripped
|
||||
|
||||
def test_only_existing_paths_extracted(self):
|
||||
"""Mix of existing and non-existing — only existing are returned."""
|
||||
paths, cleaned = _extract(
|
||||
"A /tmp/real.png and /tmp/fake.jpg end",
|
||||
existing_files={"/tmp/real.png"},
|
||||
)
|
||||
assert paths == ["/tmp/real.png"]
|
||||
assert "/tmp/real.png" not in cleaned
|
||||
assert "/tmp/fake.jpg" in cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL false-positive prevention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestURLRejection:
|
||||
|
||||
def test_https_url_not_matched(self):
|
||||
"""Paths embedded in HTTP URLs must not be extracted."""
|
||||
paths, cleaned = _extract("Visit https://example.com/images/photo.png for details")
|
||||
# The regex lookbehind should prevent matching the URL's path segment
|
||||
# Even if it did match, isfile would be False for /images/photo.png
|
||||
# (we mock isfile to True-for-all here, so the lookbehind is the guard)
|
||||
assert paths == []
|
||||
assert "https://example.com/images/photo.png" in cleaned
|
||||
|
||||
def test_http_url_not_matched(self):
|
||||
paths, _ = _extract("See http://cdn.example.com/assets/banner.jpg here")
|
||||
assert paths == []
|
||||
|
||||
def test_file_url_not_matched(self):
|
||||
paths, _ = _extract("Open file:///home/user/doc.png in browser")
|
||||
# file:// has :// before /home so lookbehind blocks it
|
||||
assert paths == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code block exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCodeBlockExclusion:
|
||||
|
||||
def test_fenced_code_block_skipped(self):
|
||||
text = "Here's how:\n```python\nimg = open('/tmp/image.png')\n```\nDone."
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == []
|
||||
assert "/tmp/image.png" in cleaned # not stripped
|
||||
|
||||
def test_inline_code_skipped(self):
|
||||
text = "Use the path `/tmp/image.png` in your config"
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == []
|
||||
assert "`/tmp/image.png`" in cleaned
|
||||
|
||||
def test_path_outside_code_block_still_matched(self):
|
||||
text = (
|
||||
"```\ncode: /tmp/inside.png\n```\n"
|
||||
"But this one is real: /tmp/outside.png"
|
||||
)
|
||||
paths, _ = _extract(text, existing_files={"/tmp/outside.png"})
|
||||
assert paths == ["/tmp/outside.png"]
|
||||
|
||||
def test_mixed_inline_code_and_bare_path(self):
|
||||
text = "Config uses `/etc/app/bg.png` but output is /tmp/result.jpg"
|
||||
paths, cleaned = _extract(text, existing_files={"/tmp/result.jpg"})
|
||||
assert paths == ["/tmp/result.jpg"]
|
||||
assert "`/etc/app/bg.png`" in cleaned
|
||||
assert "/tmp/result.jpg" not in cleaned
|
||||
|
||||
def test_multiline_fenced_block(self):
|
||||
text = (
|
||||
"```bash\n"
|
||||
"cp /source/a.png /dest/b.png\n"
|
||||
"mv /source/c.mp4 /dest/d.mp4\n"
|
||||
"```\n"
|
||||
"Files are ready."
|
||||
)
|
||||
paths, _ = _extract(text)
|
||||
assert paths == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deduplication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeduplication:
|
||||
|
||||
def test_duplicate_paths_deduplicated(self):
|
||||
text = "See /tmp/img.png and also /tmp/img.png again"
|
||||
paths, _ = _extract(text)
|
||||
assert paths == ["/tmp/img.png"]
|
||||
|
||||
def test_tilde_and_expanded_same_file(self):
|
||||
"""~/photos/a.png and /home/user/photos/a.png are the same file."""
|
||||
text = "See ~/photos/a.png and /home/user/photos/a.png here"
|
||||
paths, _ = _extract(text, existing_files={"/home/user/photos/a.png"})
|
||||
assert len(paths) == 1
|
||||
assert paths[0] == "/home/user/photos/a.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTextCleanup:
|
||||
|
||||
def test_path_removed_from_text(self):
|
||||
paths, cleaned = _extract("Before /tmp/x.png after")
|
||||
assert "Before" in cleaned
|
||||
assert "after" in cleaned
|
||||
assert "/tmp/x.png" not in cleaned
|
||||
|
||||
def test_excessive_blank_lines_collapsed(self):
|
||||
text = "Before\n\n\n/tmp/x.png\n\n\nAfter"
|
||||
_, cleaned = _extract(text)
|
||||
assert "\n\n\n" not in cleaned
|
||||
|
||||
def test_no_paths_text_unchanged(self):
|
||||
text = "This is a normal response with no file paths."
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == []
|
||||
assert cleaned == text
|
||||
|
||||
def test_tilde_form_cleaned_from_text(self):
|
||||
"""The raw ~/... form should be removed, not the expanded /home/user/... form."""
|
||||
text = "Output saved to ~/result.png for review"
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == ["/home/user/result.png"]
|
||||
assert "~/result.png" not in cleaned
|
||||
|
||||
def test_only_path_in_text(self):
|
||||
"""If the response is just a path, cleaned text is empty."""
|
||||
paths, cleaned = _extract("/tmp/screenshot.png")
|
||||
assert paths == ["/tmp/screenshot.png"]
|
||||
assert cleaned == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
def test_empty_string(self):
|
||||
paths, cleaned = _extract("")
|
||||
assert paths == []
|
||||
assert cleaned == ""
|
||||
|
||||
def test_no_media_extensions(self):
|
||||
"""Non-media extensions should not be matched."""
|
||||
paths, _ = _extract("See /tmp/data.csv and /tmp/script.py and /tmp/notes.txt")
|
||||
assert paths == []
|
||||
|
||||
def test_path_with_spaces_not_matched(self):
|
||||
"""Paths with spaces are intentionally not matched (avoids false positives)."""
|
||||
paths, _ = _extract("File at /tmp/my file.png here")
|
||||
assert paths == []
|
||||
|
||||
def test_windows_path_not_matched(self):
|
||||
"""Windows-style paths should not match."""
|
||||
paths, _ = _extract("See C:\\Users\\test\\image.png")
|
||||
assert paths == []
|
||||
|
||||
def test_relative_path_not_matched(self):
|
||||
"""Relative paths like ./image.png should not match."""
|
||||
paths, _ = _extract("File at ./screenshots/image.png here")
|
||||
assert paths == []
|
||||
|
||||
def test_bare_filename_not_matched(self):
|
||||
"""Just 'image.png' without a path should not match."""
|
||||
paths, _ = _extract("Open image.png to see")
|
||||
assert paths == []
|
||||
|
||||
def test_path_followed_by_punctuation(self):
|
||||
"""Path followed by comma, period, paren should still match."""
|
||||
for suffix in [",", ".", ")", ":", ";"]:
|
||||
text = f"See /tmp/img.png{suffix} details"
|
||||
paths, _ = _extract(text)
|
||||
assert len(paths) == 1, f"Failed with suffix '{suffix}'"
|
||||
|
||||
def test_path_in_parentheses(self):
|
||||
paths, _ = _extract("(see /tmp/img.png)")
|
||||
assert paths == ["/tmp/img.png"]
|
||||
|
||||
def test_path_in_quotes(self):
|
||||
paths, _ = _extract('The file is "/tmp/img.png" right here')
|
||||
assert paths == ["/tmp/img.png"]
|
||||
|
||||
def test_deep_nested_path(self):
|
||||
paths, _ = _extract("At /a/b/c/d/e/f/g/h/image.png end")
|
||||
assert paths == ["/a/b/c/d/e/f/g/h/image.png"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -44,6 +44,26 @@ class TestGatewayPidState:
|
||||
|
||||
|
||||
class TestGatewayRuntimeStatus:
|
||||
def test_write_runtime_status_overwrites_stale_pid_on_restart(self, tmp_path, monkeypatch):
|
||||
"""Regression: setdefault() preserved stale PID from previous process (#1631)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
# Simulate a previous gateway run that left a state file with a stale PID
|
||||
state_path = tmp_path / "gateway_state.json"
|
||||
state_path.write_text(json.dumps({
|
||||
"pid": 99999,
|
||||
"start_time": 1000.0,
|
||||
"kind": "hermes-gateway",
|
||||
"platforms": {},
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
}))
|
||||
|
||||
status.write_runtime_status(gateway_state="running")
|
||||
|
||||
payload = status.read_runtime_status()
|
||||
assert payload["pid"] == os.getpid(), "PID should be overwritten, not preserved via setdefault"
|
||||
assert payload["start_time"] != 1000.0, "start_time should be overwritten on restart"
|
||||
|
||||
def test_write_runtime_status_records_platform_failure(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from hermes_cli.commands import (
|
||||
COMMANDS_BY_CATEGORY,
|
||||
CommandDef,
|
||||
GATEWAY_KNOWN_COMMANDS,
|
||||
SUBCOMMANDS,
|
||||
SlashCommandAutoSuggest,
|
||||
SlashCommandCompleter,
|
||||
gateway_help_lines,
|
||||
resolve_command,
|
||||
@@ -323,3 +325,182 @@ class TestSlashCommandCompleter:
|
||||
completions = _completions(completer, "/no-desc")
|
||||
assert len(completions) == 1
|
||||
assert "Skill command" in completions[0].display_meta_text
|
||||
|
||||
|
||||
# ── SUBCOMMANDS extraction ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubcommands:
|
||||
def test_explicit_subcommands_extracted(self):
|
||||
"""Commands with explicit subcommands on CommandDef are extracted."""
|
||||
assert "/prompt" in SUBCOMMANDS
|
||||
assert "clear" in SUBCOMMANDS["/prompt"]
|
||||
|
||||
def test_reasoning_has_subcommands(self):
|
||||
assert "/reasoning" in SUBCOMMANDS
|
||||
subs = SUBCOMMANDS["/reasoning"]
|
||||
assert "high" in subs
|
||||
assert "show" in subs
|
||||
assert "hide" in subs
|
||||
|
||||
def test_voice_has_subcommands(self):
|
||||
assert "/voice" in SUBCOMMANDS
|
||||
assert "on" in SUBCOMMANDS["/voice"]
|
||||
assert "off" in SUBCOMMANDS["/voice"]
|
||||
|
||||
def test_cron_has_subcommands(self):
|
||||
assert "/cron" in SUBCOMMANDS
|
||||
assert "list" in SUBCOMMANDS["/cron"]
|
||||
assert "add" in SUBCOMMANDS["/cron"]
|
||||
|
||||
def test_commands_without_subcommands_not_in_dict(self):
|
||||
"""Plain commands should not appear in SUBCOMMANDS."""
|
||||
assert "/help" not in SUBCOMMANDS
|
||||
assert "/quit" not in SUBCOMMANDS
|
||||
assert "/clear" not in SUBCOMMANDS
|
||||
|
||||
|
||||
# ── Subcommand tab completion ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubcommandCompletion:
|
||||
def test_subcommand_completion_after_space(self):
|
||||
"""Typing '/reasoning ' then Tab should show subcommands."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "high" in texts
|
||||
assert "show" in texts
|
||||
|
||||
def test_subcommand_prefix_filters(self):
|
||||
"""Typing '/reasoning sh' should only show 'show'."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning sh")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"show"}
|
||||
|
||||
def test_subcommand_exact_match_suppressed(self):
|
||||
"""Typing the full subcommand shouldn't re-suggest it."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning show")
|
||||
texts = {c.text for c in completions}
|
||||
assert "show" not in texts
|
||||
|
||||
def test_no_subcommands_for_plain_command(self):
|
||||
"""Commands without subcommands yield nothing after space."""
|
||||
completions = _completions(SlashCommandCompleter(), "/help ")
|
||||
assert completions == []
|
||||
|
||||
|
||||
# ── Two-stage /model completion ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _model_completer() -> SlashCommandCompleter:
|
||||
"""Build a completer with mock model/provider info."""
|
||||
return SlashCommandCompleter(
|
||||
model_completer_provider=lambda: {
|
||||
"current_provider": "openrouter",
|
||||
"providers": {
|
||||
"anthropic": "Anthropic",
|
||||
"openrouter": "OpenRouter",
|
||||
"nous": "Nous Research",
|
||||
},
|
||||
"models_for": lambda p: {
|
||||
"anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250414"],
|
||||
"openrouter": ["anthropic/claude-sonnet-4", "google/gemini-2.5-pro"],
|
||||
"nous": ["hermes-3-llama-3.1-405b"],
|
||||
}.get(p, []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestModelCompletion:
|
||||
def test_stage1_shows_providers(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:" in texts
|
||||
assert "openrouter:" in texts
|
||||
assert "nous:" in texts
|
||||
|
||||
def test_stage1_current_provider_last(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
texts = [c.text for c in completions]
|
||||
assert texts[-1] == "openrouter:"
|
||||
|
||||
def test_stage1_current_provider_labeled(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
for c in completions:
|
||||
if c.text == "openrouter:":
|
||||
assert "current" in c.display_meta_text.lower()
|
||||
break
|
||||
else:
|
||||
raise AssertionError("openrouter: not found in completions")
|
||||
|
||||
def test_stage1_prefix_filters(self):
|
||||
completions = _completions(_model_completer(), "/model an")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"anthropic:"}
|
||||
|
||||
def test_stage2_shows_models(self):
|
||||
completions = _completions(_model_completer(), "/model anthropic:")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:claude-sonnet-4-20250514" in texts
|
||||
assert "anthropic:claude-opus-4-20250414" in texts
|
||||
|
||||
def test_stage2_prefix_filters_models(self):
|
||||
completions = _completions(_model_completer(), "/model anthropic:claude-s")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:claude-sonnet-4-20250514" in texts
|
||||
assert "anthropic:claude-opus-4-20250414" not in texts
|
||||
|
||||
def test_stage2_no_model_provider_returns_empty(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/model ")
|
||||
assert completions == []
|
||||
|
||||
|
||||
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
||||
|
||||
|
||||
def _suggestion(text: str, completer=None) -> str | None:
|
||||
"""Get ghost text suggestion for given input."""
|
||||
suggest = SlashCommandAutoSuggest(completer=completer)
|
||||
doc = Document(text=text)
|
||||
|
||||
class FakeBuffer:
|
||||
pass
|
||||
|
||||
result = suggest.get_suggestion(FakeBuffer(), doc)
|
||||
return result.text if result else None
|
||||
|
||||
|
||||
class TestGhostText:
|
||||
def test_command_name_suggestion(self):
|
||||
"""/he → 'lp'"""
|
||||
assert _suggestion("/he") == "lp"
|
||||
|
||||
def test_command_name_suggestion_reasoning(self):
|
||||
"""/rea → 'soning'"""
|
||||
assert _suggestion("/rea") == "soning"
|
||||
|
||||
def test_no_suggestion_for_complete_command(self):
|
||||
assert _suggestion("/help") is None
|
||||
|
||||
def test_subcommand_suggestion(self):
|
||||
"""/reasoning h → 'igh'"""
|
||||
assert _suggestion("/reasoning h") == "igh"
|
||||
|
||||
def test_subcommand_suggestion_show(self):
|
||||
"""/reasoning sh → 'ow'"""
|
||||
assert _suggestion("/reasoning sh") == "ow"
|
||||
|
||||
def test_no_suggestion_for_non_slash(self):
|
||||
assert _suggestion("hello") is None
|
||||
|
||||
def test_model_stage1_ghost_text(self):
|
||||
"""/model a → 'nthropic:'"""
|
||||
completer = _model_completer()
|
||||
assert _suggestion("/model a", completer=completer) == "nthropic:"
|
||||
|
||||
def test_model_stage2_ghost_text(self):
|
||||
"""/model anthropic:cl → rest of first matching model"""
|
||||
completer = _model_completer()
|
||||
s = _suggestion("/model anthropic:cl", completer=completer)
|
||||
assert s is not None
|
||||
assert s.startswith("aude-")
|
||||
|
||||
@@ -12,9 +12,12 @@ from hermes_cli.config import (
|
||||
ensure_hermes_home,
|
||||
load_config,
|
||||
load_env,
|
||||
migrate_config,
|
||||
save_config,
|
||||
save_env_value,
|
||||
save_env_value_secure,
|
||||
sanitize_env_file,
|
||||
_sanitize_env_lines,
|
||||
)
|
||||
|
||||
|
||||
@@ -203,3 +206,142 @@ class TestSaveConfigAtomicity:
|
||||
raw = yaml.safe_load(f)
|
||||
assert raw["model"] == "test/atomic-model"
|
||||
assert raw["agent"]["max_turns"] == 77
|
||||
|
||||
|
||||
class TestSanitizeEnvLines:
|
||||
"""Tests for .env file corruption repair."""
|
||||
|
||||
def test_splits_concatenated_keys(self):
|
||||
"""Two KEY=VALUE pairs jammed on one line get split."""
|
||||
lines = ["ANTHROPIC_API_KEY=sk-ant-xxxOPENAI_BASE_URL=https://api.openai.com/v1\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == [
|
||||
"ANTHROPIC_API_KEY=sk-ant-xxx\n",
|
||||
"OPENAI_BASE_URL=https://api.openai.com/v1\n",
|
||||
]
|
||||
|
||||
def test_preserves_clean_file(self):
|
||||
"""A well-formed .env file passes through unchanged (modulo trailing newlines)."""
|
||||
lines = [
|
||||
"OPENROUTER_API_KEY=sk-or-xxx\n",
|
||||
"FIRECRAWL_API_KEY=fc-xxx\n",
|
||||
"# a comment\n",
|
||||
"\n",
|
||||
]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == lines
|
||||
|
||||
def test_preserves_comments_and_blanks(self):
|
||||
lines = ["# comment\n", "\n", "KEY=val\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == lines
|
||||
|
||||
def test_adds_missing_trailing_newline(self):
|
||||
"""Lines missing trailing newline get one added."""
|
||||
lines = ["FOO_BAR=baz"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == ["FOO_BAR=baz\n"]
|
||||
|
||||
def test_three_concatenated_keys(self):
|
||||
"""Three known keys on one line all get separated."""
|
||||
lines = ["FAL_KEY=111FIRECRAWL_API_KEY=222GITHUB_TOKEN=333\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == [
|
||||
"FAL_KEY=111\n",
|
||||
"FIRECRAWL_API_KEY=222\n",
|
||||
"GITHUB_TOKEN=333\n",
|
||||
]
|
||||
|
||||
def test_value_with_equals_sign_not_split(self):
|
||||
"""A value containing '=' shouldn't be falsely split (lowercase in value)."""
|
||||
lines = ["OPENAI_BASE_URL=https://api.example.com/v1?key=abc123\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == lines
|
||||
|
||||
def test_unknown_keys_not_split(self):
|
||||
"""Unknown key names on one line are NOT split (avoids false positives)."""
|
||||
lines = ["CUSTOM_VAR=value123OTHER_THING=value456\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
# Unknown keys stay on one line — no false split
|
||||
assert len(result) == 1
|
||||
|
||||
def test_value_ending_with_digits_still_splits(self):
|
||||
"""Concatenation is detected even when value ends with digits."""
|
||||
lines = ["OPENROUTER_API_KEY=sk-or-v1-abc123OPENAI_BASE_URL=https://api.openai.com/v1\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert len(result) == 2
|
||||
assert result[0].startswith("OPENROUTER_API_KEY=")
|
||||
assert result[1].startswith("OPENAI_BASE_URL=")
|
||||
|
||||
def test_save_env_value_fixes_corruption_on_write(self, tmp_path):
|
||||
"""save_env_value sanitizes corrupted lines when writing a new key."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n"
|
||||
"FAL_KEY=existing\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
save_env_value("MESSAGING_CWD", "/tmp")
|
||||
|
||||
content = env_file.read_text()
|
||||
lines = content.strip().split("\n")
|
||||
|
||||
# Corrupted line should be split, new key added
|
||||
assert "ANTHROPIC_API_KEY=sk-ant" in lines
|
||||
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines
|
||||
assert "MESSAGING_CWD=/tmp" in lines
|
||||
|
||||
def test_sanitize_env_file_returns_fix_count(self, tmp_path):
|
||||
"""sanitize_env_file reports how many entries were fixed."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"FAL_KEY=good\n"
|
||||
"OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
fixes = sanitize_env_file()
|
||||
assert fixes > 0
|
||||
|
||||
# Verify file is now clean
|
||||
content = env_file.read_text()
|
||||
assert "OPENROUTER_API_KEY=val\n" in content
|
||||
assert "FIRECRAWL_API_KEY=val2\n" in content
|
||||
|
||||
def test_sanitize_env_file_noop_on_clean_file(self, tmp_path):
|
||||
"""No changes when file is already clean."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
fixes = sanitize_env_file()
|
||||
assert fixes == 0
|
||||
|
||||
|
||||
class TestAnthropicTokenMigration:
|
||||
"""Test that config version 8→9 clears ANTHROPIC_TOKEN."""
|
||||
|
||||
def _write_config_version(self, tmp_path, version):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
import yaml
|
||||
config_path.write_text(yaml.safe_dump({"_config_version": version}))
|
||||
|
||||
def test_clears_token_on_upgrade_to_v9(self, tmp_path):
|
||||
"""ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9."""
|
||||
self._write_config_version(tmp_path, 8)
|
||||
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n")
|
||||
with patch.dict(os.environ, {
|
||||
"HERMES_HOME": str(tmp_path),
|
||||
"ANTHROPIC_TOKEN": "old-token",
|
||||
}):
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
assert load_env().get("ANTHROPIC_TOKEN") == ""
|
||||
|
||||
def test_skips_on_version_9_or_later(self, tmp_path):
|
||||
"""Already at v9 — ANTHROPIC_TOKEN is not touched."""
|
||||
self._write_config_version(tmp_path, 9)
|
||||
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=current-token\n")
|
||||
with patch.dict(os.environ, {
|
||||
"HERMES_HOME": str(tmp_path),
|
||||
"ANTHROPIC_TOKEN": "current-token",
|
||||
}):
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
assert load_env().get("ANTHROPIC_TOKEN") == "current-token"
|
||||
|
||||
@@ -7,6 +7,29 @@ import hermes_cli.gateway as gateway_cli
|
||||
|
||||
|
||||
class TestSystemdServiceRefresh:
|
||||
def test_systemd_install_repairs_outdated_unit_without_force(self, tmp_path, monkeypatch):
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("old unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=True, **kwargs):
|
||||
calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.systemd_install()
|
||||
|
||||
assert unit_path.read_text(encoding="utf-8") == "new unit\n"
|
||||
assert calls[:2] == [
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
["systemctl", "--user", "enable", gateway_cli.get_service_name()],
|
||||
]
|
||||
|
||||
def test_systemd_start_refreshes_outdated_unit(self, tmp_path, monkeypatch):
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("old unit\n", encoding="utf-8")
|
||||
@@ -96,6 +119,71 @@ class TestGatewayStopCleanup:
|
||||
assert kill_calls == [False]
|
||||
|
||||
|
||||
class TestLaunchdServiceRecovery:
|
||||
def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.launchd_install()
|
||||
|
||||
assert "--replace" in plist_path.read_text(encoding="utf-8")
|
||||
assert calls[:2] == [
|
||||
["launchctl", "unload", str(plist_path)],
|
||||
["launchctl", "load", str(plist_path)],
|
||||
]
|
||||
|
||||
def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd == ["launchctl", "start", "ai.hermes.gateway"] and calls.count(cmd) == 1:
|
||||
raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.launchd_start()
|
||||
|
||||
assert calls == [
|
||||
["launchctl", "start", "ai.hermes.gateway"],
|
||||
["launchctl", "load", str(plist_path)],
|
||||
["launchctl", "start", "ai.hermes.gateway"],
|
||||
]
|
||||
|
||||
def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(returncode=113, stdout="", stderr="Could not find service"),
|
||||
)
|
||||
|
||||
gateway_cli.launchd_status()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert str(plist_path) in output
|
||||
assert "stale" in output.lower()
|
||||
assert "not loaded" in output.lower()
|
||||
|
||||
|
||||
class TestGatewayServiceDetection:
|
||||
def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch):
|
||||
user_unit = SimpleNamespace(exists=lambda: True)
|
||||
@@ -158,6 +246,34 @@ class TestGatewaySystemServiceRouting:
|
||||
|
||||
assert calls == [(False, False)]
|
||||
|
||||
def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("plist\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"launchd_restart",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
gateway_cli.subprocess.CalledProcessError(5, ["launchctl", "start", "ai.hermes.gateway"])
|
||||
),
|
||||
)
|
||||
|
||||
run_calls = []
|
||||
monkeypatch.setattr(gateway_cli, "run_gateway", lambda verbose=False, replace=False: run_calls.append((verbose, replace)))
|
||||
monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda force=False: 0)
|
||||
|
||||
try:
|
||||
gateway_cli.gateway_command(SimpleNamespace(gateway_command="restart", system=False))
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 1
|
||||
else:
|
||||
raise AssertionError("Expected gateway_command to exit when service restart fails")
|
||||
|
||||
assert run_calls == []
|
||||
|
||||
|
||||
class TestEnsureUserSystemdEnv:
|
||||
"""Tests for _ensure_user_systemd_env() D-Bus session bus auto-detection."""
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
"""
|
||||
Tests for --yes / --force flag separation in `hermes skills install`.
|
||||
|
||||
--yes / -y → skip_confirm (bypass interactive prompt, needed in TUI mode)
|
||||
--force → force (install despite blocked scan verdict)
|
||||
|
||||
Based on PR #1595 by 333Alden333 (salvaged).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def test_cli_skills_install_accepts_yes_alias(monkeypatch):
|
||||
def test_cli_skills_install_yes_sets_skip_confirm(monkeypatch):
|
||||
"""--yes should set skip_confirm=True but NOT force."""
|
||||
from hermes_cli.main import main
|
||||
|
||||
captured = {}
|
||||
@@ -10,6 +20,7 @@ def test_cli_skills_install_accepts_yes_alias(monkeypatch):
|
||||
def fake_skills_command(args):
|
||||
captured["identifier"] = args.identifier
|
||||
captured["force"] = args.force
|
||||
captured["yes"] = args.yes
|
||||
|
||||
monkeypatch.setattr("hermes_cli.skills_hub.skills_command", fake_skills_command)
|
||||
monkeypatch.setattr(
|
||||
@@ -20,7 +31,98 @@ def test_cli_skills_install_accepts_yes_alias(monkeypatch):
|
||||
|
||||
main()
|
||||
|
||||
assert captured == {
|
||||
"identifier": "official/email/agentmail",
|
||||
"force": True,
|
||||
}
|
||||
assert captured["identifier"] == "official/email/agentmail"
|
||||
assert captured["yes"] is True
|
||||
assert captured["force"] is False
|
||||
|
||||
|
||||
def test_cli_skills_install_y_alias(monkeypatch):
|
||||
"""-y should behave the same as --yes."""
|
||||
from hermes_cli.main import main
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_skills_command(args):
|
||||
captured["yes"] = args.yes
|
||||
captured["force"] = args.force
|
||||
|
||||
monkeypatch.setattr("hermes_cli.skills_hub.skills_command", fake_skills_command)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "skills", "install", "test/skill", "-y"],
|
||||
)
|
||||
|
||||
main()
|
||||
|
||||
assert captured["yes"] is True
|
||||
assert captured["force"] is False
|
||||
|
||||
|
||||
def test_cli_skills_install_force_sets_force(monkeypatch):
|
||||
"""--force should set force=True but NOT yes."""
|
||||
from hermes_cli.main import main
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_skills_command(args):
|
||||
captured["force"] = args.force
|
||||
captured["yes"] = args.yes
|
||||
|
||||
monkeypatch.setattr("hermes_cli.skills_hub.skills_command", fake_skills_command)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "skills", "install", "test/skill", "--force"],
|
||||
)
|
||||
|
||||
main()
|
||||
|
||||
assert captured["force"] is True
|
||||
assert captured["yes"] is False
|
||||
|
||||
|
||||
def test_cli_skills_install_force_and_yes_together(monkeypatch):
|
||||
"""--force --yes should set both flags."""
|
||||
from hermes_cli.main import main
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_skills_command(args):
|
||||
captured["force"] = args.force
|
||||
captured["yes"] = args.yes
|
||||
|
||||
monkeypatch.setattr("hermes_cli.skills_hub.skills_command", fake_skills_command)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "skills", "install", "test/skill", "--force", "--yes"],
|
||||
)
|
||||
|
||||
main()
|
||||
|
||||
assert captured["force"] is True
|
||||
assert captured["yes"] is True
|
||||
|
||||
|
||||
def test_cli_skills_install_no_flags(monkeypatch):
|
||||
"""Without flags, both force and yes should be False."""
|
||||
from hermes_cli.main import main
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_skills_command(args):
|
||||
captured["force"] = args.force
|
||||
captured["yes"] = args.yes
|
||||
|
||||
monkeypatch.setattr("hermes_cli.skills_hub.skills_command", fake_skills_command)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "skills", "install", "test/skill"],
|
||||
)
|
||||
|
||||
main()
|
||||
|
||||
assert captured["force"] is False
|
||||
assert captured["yes"] is False
|
||||
|
||||
132
tests/hermes_cli/test_skills_skip_confirm.py
Normal file
132
tests/hermes_cli/test_skills_skip_confirm.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Tests for skip_confirm behavior in /skills install and /skills uninstall.
|
||||
|
||||
Verifies that --yes / -y bypasses the interactive confirmation prompt
|
||||
that hangs inside prompt_toolkit's TUI.
|
||||
|
||||
Based on PR #1595 by 333Alden333 (salvaged).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHandleSkillsSlashInstallFlags:
|
||||
"""Test flag parsing in handle_skills_slash for install."""
|
||||
|
||||
def test_yes_flag_sets_skip_confirm(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill --yes")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
assert kwargs.get("force") is False
|
||||
|
||||
def test_y_flag_sets_skip_confirm(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill -y")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_force_flag_sets_force_not_skip(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill --force")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("force") is True
|
||||
assert kwargs.get("skip_confirm") is False
|
||||
|
||||
def test_no_flags(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("force") is False
|
||||
assert kwargs.get("skip_confirm") is False
|
||||
|
||||
|
||||
class TestHandleSkillsSlashUninstallFlags:
|
||||
"""Test flag parsing in handle_skills_slash for uninstall."""
|
||||
|
||||
def test_yes_flag_sets_skip_confirm(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_uninstall") as mock_uninstall:
|
||||
handle_skills_slash("/skills uninstall test-skill --yes")
|
||||
mock_uninstall.assert_called_once()
|
||||
_, kwargs = mock_uninstall.call_args
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_y_flag_sets_skip_confirm(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_uninstall") as mock_uninstall:
|
||||
handle_skills_slash("/skills uninstall test-skill -y")
|
||||
mock_uninstall.assert_called_once()
|
||||
_, kwargs = mock_uninstall.call_args
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_no_flags(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_uninstall") as mock_uninstall:
|
||||
handle_skills_slash("/skills uninstall test-skill")
|
||||
mock_uninstall.assert_called_once()
|
||||
_, kwargs = mock_uninstall.call_args
|
||||
assert kwargs.get("skip_confirm", False) is False
|
||||
|
||||
|
||||
class TestDoInstallSkipConfirm:
|
||||
"""Test that do_install respects skip_confirm parameter."""
|
||||
|
||||
@patch("hermes_cli.skills_hub.input", return_value="n")
|
||||
def test_without_skip_confirm_prompts_user(self, mock_input):
|
||||
"""Without skip_confirm, input() is called for confirmation."""
|
||||
from hermes_cli.skills_hub import do_install
|
||||
with patch("hermes_cli.skills_hub._console"), \
|
||||
patch("tools.skills_hub.ensure_hub_dirs"), \
|
||||
patch("tools.skills_hub.GitHubAuth"), \
|
||||
patch("tools.skills_hub.create_source_router") as mock_router, \
|
||||
patch("hermes_cli.skills_hub._resolve_short_name", return_value="test/skill"), \
|
||||
patch("hermes_cli.skills_hub._resolve_source_meta_and_bundle") as mock_resolve:
|
||||
|
||||
# Make it return None so we exit early
|
||||
mock_resolve.return_value = (None, None, None)
|
||||
do_install("test-skill", skip_confirm=False)
|
||||
# We don't get to the input() call because resolve returns None,
|
||||
# but the parameter wiring is correct
|
||||
|
||||
|
||||
class TestDoUninstallSkipConfirm:
|
||||
"""Test that do_uninstall respects skip_confirm parameter."""
|
||||
|
||||
def test_skip_confirm_bypasses_input(self):
|
||||
"""With skip_confirm=True, input() should not be called."""
|
||||
from hermes_cli.skills_hub import do_uninstall
|
||||
with patch("hermes_cli.skills_hub._console") as mock_console, \
|
||||
patch("tools.skills_hub.uninstall_skill", return_value=(True, "Removed")) as mock_uninstall, \
|
||||
patch("builtins.input") as mock_input:
|
||||
do_uninstall("test-skill", skip_confirm=True)
|
||||
mock_input.assert_not_called()
|
||||
mock_uninstall.assert_called_once_with("test-skill")
|
||||
|
||||
def test_without_skip_confirm_calls_input(self):
|
||||
"""Without skip_confirm, input() should be called."""
|
||||
from hermes_cli.skills_hub import do_uninstall
|
||||
with patch("hermes_cli.skills_hub._console"), \
|
||||
patch("tools.skills_hub.uninstall_skill", return_value=(True, "Removed")), \
|
||||
patch("builtins.input", return_value="y") as mock_input:
|
||||
do_uninstall("test-skill", skip_confirm=False)
|
||||
mock_input.assert_called_once()
|
||||
|
||||
def test_without_skip_confirm_cancel(self):
|
||||
"""Without skip_confirm, answering 'n' should cancel."""
|
||||
from hermes_cli.skills_hub import do_uninstall
|
||||
with patch("hermes_cli.skills_hub._console"), \
|
||||
patch("tools.skills_hub.uninstall_skill") as mock_uninstall, \
|
||||
patch("builtins.input", return_value="n"):
|
||||
do_uninstall("test-skill", skip_confirm=False)
|
||||
mock_uninstall.assert_not_called()
|
||||
268
tests/test_1630_context_overflow_loop.py
Normal file
268
tests/test_1630_context_overflow_loop.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Tests for #1630 — gateway infinite 400 failure loop prevention.
|
||||
|
||||
Verifies that:
|
||||
1. Generic 400 errors with large sessions are treated as context-length errors
|
||||
and trigger compression instead of aborting.
|
||||
2. The gateway does not persist messages when the agent fails early, preventing
|
||||
the session from growing on each failure.
|
||||
3. Context-overflow failures produce helpful error messages suggesting /compact.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: Agent heuristic — generic 400 with large session → compression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGeneric400Heuristic:
|
||||
"""The agent should treat a generic 400 with a large session as a
|
||||
probable context-length error and trigger compression, not abort."""
|
||||
|
||||
def _make_agent(self):
|
||||
"""Create a minimal AIAgent for testing error handling."""
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
from run_agent import AIAgent
|
||||
a = AIAgent(
|
||||
api_key="test-key-12345",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
a.client = MagicMock()
|
||||
a._cached_system_prompt = "You are helpful."
|
||||
a._use_prompt_caching = False
|
||||
a.tool_delay = 0
|
||||
a.compression_enabled = False
|
||||
return a
|
||||
|
||||
def test_generic_400_with_small_session_is_client_error(self):
|
||||
"""A generic 400 with a small session should still be treated
|
||||
as a non-retryable client error (not context overflow)."""
|
||||
error_msg = "error"
|
||||
status_code = 400
|
||||
approx_tokens = 1000 # Small session
|
||||
api_messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
# Simulate the phrase matching
|
||||
is_context_length_error = any(phrase in error_msg for phrase in [
|
||||
'context length', 'context size', 'maximum context',
|
||||
'token limit', 'too many tokens', 'reduce the length',
|
||||
'exceeds the limit', 'context window',
|
||||
'request entity too large',
|
||||
'prompt is too long',
|
||||
])
|
||||
assert not is_context_length_error
|
||||
|
||||
# The heuristic should NOT trigger for small sessions
|
||||
ctx_len = 200000
|
||||
is_large_session = approx_tokens > ctx_len * 0.4 or len(api_messages) > 80
|
||||
is_generic_error = len(error_msg.strip()) < 30
|
||||
assert not is_large_session # Small session → heuristic doesn't fire
|
||||
|
||||
def test_generic_400_with_large_token_count_triggers_heuristic(self):
|
||||
"""A generic 400 with high token count should be treated as
|
||||
probable context overflow."""
|
||||
error_msg = "error"
|
||||
status_code = 400
|
||||
ctx_len = 200000
|
||||
approx_tokens = 100000 # > 40% of 200k
|
||||
api_messages = [{"role": "user", "content": "hi"}] * 20
|
||||
|
||||
is_context_length_error = any(phrase in error_msg for phrase in [
|
||||
'context length', 'context size', 'maximum context',
|
||||
])
|
||||
assert not is_context_length_error
|
||||
|
||||
# Heuristic check
|
||||
is_large_session = approx_tokens > ctx_len * 0.4 or len(api_messages) > 80
|
||||
is_generic_error = len(error_msg.strip()) < 30
|
||||
assert is_large_session
|
||||
assert is_generic_error
|
||||
# Both conditions true → should be treated as context overflow
|
||||
|
||||
def test_generic_400_with_many_messages_triggers_heuristic(self):
|
||||
"""A generic 400 with >80 messages should trigger the heuristic
|
||||
even if estimated tokens are low."""
|
||||
error_msg = "error"
|
||||
status_code = 400
|
||||
ctx_len = 200000
|
||||
approx_tokens = 5000 # Low token estimate
|
||||
api_messages = [{"role": "user", "content": "x"}] * 100 # > 80 messages
|
||||
|
||||
is_large_session = approx_tokens > ctx_len * 0.4 or len(api_messages) > 80
|
||||
is_generic_error = len(error_msg.strip()) < 30
|
||||
assert is_large_session
|
||||
assert is_generic_error
|
||||
|
||||
def test_specific_error_message_bypasses_heuristic(self):
|
||||
"""A 400 with a specific, long error message should NOT trigger
|
||||
the heuristic even with a large session."""
|
||||
error_msg = "invalid model: anthropic/claude-nonexistent-model is not available"
|
||||
status_code = 400
|
||||
ctx_len = 200000
|
||||
approx_tokens = 100000
|
||||
|
||||
is_generic_error = len(error_msg.strip()) < 30
|
||||
assert not is_generic_error # Long specific message → heuristic doesn't fire
|
||||
|
||||
def test_descriptive_context_error_caught_by_phrases(self):
|
||||
"""Descriptive context-length errors should still be caught by
|
||||
the existing phrase matching (not the heuristic)."""
|
||||
error_msg = "prompt is too long: 250000 tokens > 200000 maximum"
|
||||
is_context_length_error = any(phrase in error_msg for phrase in [
|
||||
'context length', 'context size', 'maximum context',
|
||||
'token limit', 'too many tokens', 'reduce the length',
|
||||
'exceeds the limit', 'context window',
|
||||
'request entity too large',
|
||||
'prompt is too long',
|
||||
])
|
||||
assert is_context_length_error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: Gateway skips persistence on failed agent results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewaySkipsPersistenceOnFailure:
|
||||
"""When the agent returns failed=True with no final_response,
|
||||
the gateway should NOT persist messages to the transcript."""
|
||||
|
||||
def test_agent_failed_early_detected(self):
|
||||
"""The agent_failed_early flag is True when failed=True and
|
||||
no final_response."""
|
||||
agent_result = {
|
||||
"failed": True,
|
||||
"final_response": None,
|
||||
"messages": [],
|
||||
"error": "Non-retryable client error",
|
||||
}
|
||||
agent_failed_early = (
|
||||
agent_result.get("failed")
|
||||
and not agent_result.get("final_response")
|
||||
)
|
||||
assert agent_failed_early
|
||||
|
||||
def test_agent_with_response_not_failed_early(self):
|
||||
"""When the agent has a final_response, it's not a failed-early
|
||||
scenario even if failed=True."""
|
||||
agent_result = {
|
||||
"failed": True,
|
||||
"final_response": "Here is a partial response",
|
||||
"messages": [],
|
||||
}
|
||||
agent_failed_early = (
|
||||
agent_result.get("failed")
|
||||
and not agent_result.get("final_response")
|
||||
)
|
||||
assert not agent_failed_early
|
||||
|
||||
def test_successful_agent_not_failed_early(self):
|
||||
"""A successful agent result should not trigger skip."""
|
||||
agent_result = {
|
||||
"final_response": "Hello!",
|
||||
"messages": [{"role": "assistant", "content": "Hello!"}],
|
||||
}
|
||||
agent_failed_early = (
|
||||
agent_result.get("failed")
|
||||
and not agent_result.get("final_response")
|
||||
)
|
||||
assert not agent_failed_early
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: Context-overflow error messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContextOverflowErrorMessages:
|
||||
"""The gateway should produce helpful error messages when the failure
|
||||
looks like a context overflow."""
|
||||
|
||||
def test_detects_context_keywords(self):
|
||||
"""Error messages containing context-related keywords should be
|
||||
identified as context failures."""
|
||||
keywords = [
|
||||
"context length exceeded",
|
||||
"too many tokens in the prompt",
|
||||
"request entity too large",
|
||||
"payload too large for model",
|
||||
"context window exceeded",
|
||||
]
|
||||
for error_str in keywords:
|
||||
_is_ctx_fail = any(p in error_str.lower() for p in (
|
||||
"context", "token", "too large", "too long",
|
||||
"exceed", "payload",
|
||||
))
|
||||
assert _is_ctx_fail, f"Should detect: {error_str}"
|
||||
|
||||
def test_detects_generic_400_with_large_history(self):
|
||||
"""A generic 400 error code in the string with a large history
|
||||
should be flagged as context failure."""
|
||||
error_str = "error code: 400 - {'type': 'error', 'message': 'Error'}"
|
||||
history_len = 100 # Large session
|
||||
|
||||
_is_ctx_fail = any(p in error_str.lower() for p in (
|
||||
"context", "token", "too large", "too long",
|
||||
"exceed", "payload",
|
||||
)) or (
|
||||
"400" in error_str.lower()
|
||||
and history_len > 50
|
||||
)
|
||||
assert _is_ctx_fail
|
||||
|
||||
def test_unrelated_error_not_flagged(self):
|
||||
"""Unrelated errors should not be flagged as context failures."""
|
||||
error_str = "invalid api key: authentication failed"
|
||||
history_len = 10
|
||||
|
||||
_is_ctx_fail = any(p in error_str.lower() for p in (
|
||||
"context", "token", "too large", "too long",
|
||||
"exceed", "payload",
|
||||
)) or (
|
||||
"400" in error_str.lower()
|
||||
and history_len > 50
|
||||
)
|
||||
assert not _is_ctx_fail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: Agent skips persistence for large failed sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentSkipsPersistenceForLargeFailedSessions:
|
||||
"""When a 400 error occurs and the session is large, the agent
|
||||
should skip persisting to prevent the growth loop."""
|
||||
|
||||
def test_large_session_400_skips_persistence(self):
|
||||
"""Status 400 + high token count should skip persistence."""
|
||||
status_code = 400
|
||||
approx_tokens = 60000 # > 50000 threshold
|
||||
api_messages = [{"role": "user", "content": "x"}] * 10
|
||||
|
||||
should_skip = status_code == 400 and (approx_tokens > 50000 or len(api_messages) > 80)
|
||||
assert should_skip
|
||||
|
||||
def test_small_session_400_persists_normally(self):
|
||||
"""Status 400 + small session should still persist."""
|
||||
status_code = 400
|
||||
approx_tokens = 5000 # < 50000
|
||||
api_messages = [{"role": "user", "content": "x"}] * 10 # < 80
|
||||
|
||||
should_skip = status_code == 400 and (approx_tokens > 50000 or len(api_messages) > 80)
|
||||
assert not should_skip
|
||||
|
||||
def test_non_400_error_persists_normally(self):
|
||||
"""Non-400 errors should always persist normally."""
|
||||
status_code = 401 # Auth error
|
||||
approx_tokens = 100000 # Large session, but not a 400
|
||||
api_messages = [{"role": "user", "content": "x"}] * 100
|
||||
|
||||
should_skip = status_code == 400 and (approx_tokens > 50000 or len(api_messages) > 80)
|
||||
assert not should_skip
|
||||
@@ -144,9 +144,11 @@ class TestIsClaudeCodeTokenValid:
|
||||
|
||||
|
||||
class TestResolveAnthropicToken:
|
||||
def test_prefers_oauth_token_over_api_key(self, monkeypatch):
|
||||
def test_prefers_oauth_token_over_api_key(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
|
||||
|
||||
def test_reports_claude_json_primary_key_source(self, monkeypatch, tmp_path):
|
||||
@@ -174,9 +176,11 @@ class TestResolveAnthropicToken:
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() == "sk-ant-api03-mykey"
|
||||
|
||||
def test_falls_back_to_token(self, monkeypatch):
|
||||
def test_falls_back_to_token(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
|
||||
|
||||
def test_returns_none_with_no_creds(self, monkeypatch, tmp_path):
|
||||
|
||||
@@ -72,15 +72,17 @@ class TestSlashCommandPrefixMatching:
|
||||
def test_ambiguous_prefix_shows_suggestions(self):
|
||||
"""/re matches multiple commands — should show ambiguous message."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.process_command("/re")
|
||||
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj.process_command("/re")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "Ambiguous" in printed or "Did you mean" in printed
|
||||
|
||||
def test_unknown_command_shows_error(self):
|
||||
"""/xyz should show unknown command error."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.process_command("/xyz")
|
||||
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj.process_command("/xyz")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "Unknown command" in printed
|
||||
|
||||
def test_exact_command_still_works(self):
|
||||
|
||||
115
tests/test_context_token_tracking.py
Normal file
115
tests/test_context_token_tracking.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Tests for context token tracking in run_agent.py's usage extraction.
|
||||
|
||||
The context counter (status bar) must show the TOTAL prompt tokens including
|
||||
Anthropic's cached portions. This is an integration test for the token
|
||||
extraction in run_conversation(), not the ContextCompressor itself (which
|
||||
is tested in tests/agent/test_context_compressor.py).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
|
||||
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
|
||||
sys.modules.setdefault("fal_client", types.SimpleNamespace())
|
||||
|
||||
import run_agent
|
||||
|
||||
|
||||
def _patch_bootstrap(monkeypatch):
|
||||
monkeypatch.setattr(run_agent, "get_tool_definitions", lambda **kwargs: [{
|
||||
"type": "function",
|
||||
"function": {"name": "t", "description": "t", "parameters": {"type": "object", "properties": {}}},
|
||||
}])
|
||||
monkeypatch.setattr(run_agent, "check_toolset_requirements", lambda: {})
|
||||
|
||||
|
||||
class _FakeAnthropicClient:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _make_agent(monkeypatch, api_mode, provider, response_fn):
|
||||
_patch_bootstrap(monkeypatch)
|
||||
if api_mode == "anthropic_messages":
|
||||
monkeypatch.setattr("agent.anthropic_adapter.build_anthropic_client", lambda k, b=None: _FakeAnthropicClient())
|
||||
|
||||
class _A(run_agent.AIAgent):
|
||||
def __init__(self, *a, **kw):
|
||||
kw.update(skip_context_files=True, skip_memory=True, max_iterations=4)
|
||||
super().__init__(*a, **kw)
|
||||
self._cleanup_task_resources = self._persist_session = lambda *a, **k: None
|
||||
self._save_trajectory = self._save_session_log = lambda *a, **k: None
|
||||
|
||||
def run_conversation(self, msg, conversation_history=None, task_id=None):
|
||||
self._interruptible_api_call = lambda kw: response_fn()
|
||||
return super().run_conversation(msg, conversation_history=conversation_history, task_id=task_id)
|
||||
|
||||
return _A(model="test-model", api_key="test-key", provider=provider, api_mode=api_mode)
|
||||
|
||||
|
||||
def _anthropic_resp(input_tok, output_tok, cache_read=0, cache_creation=0):
|
||||
usage_fields = {"input_tokens": input_tok, "output_tokens": output_tok}
|
||||
if cache_read:
|
||||
usage_fields["cache_read_input_tokens"] = cache_read
|
||||
if cache_creation:
|
||||
usage_fields["cache_creation_input_tokens"] = cache_creation
|
||||
return SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="ok")],
|
||||
stop_reason="end_turn",
|
||||
usage=SimpleNamespace(**usage_fields),
|
||||
model="claude-sonnet-4-6",
|
||||
)
|
||||
|
||||
|
||||
# -- Anthropic: cached tokens must be included --
|
||||
|
||||
def test_anthropic_cache_read_and_creation_added(monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
|
||||
lambda: _anthropic_resp(3, 10, cache_read=15000, cache_creation=2000))
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 17003 # 3+15000+2000
|
||||
assert agent.session_prompt_tokens == 17003
|
||||
|
||||
|
||||
def test_anthropic_no_cache_fields(monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
|
||||
lambda: _anthropic_resp(500, 20))
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 500
|
||||
|
||||
|
||||
def test_anthropic_cache_read_only(monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
|
||||
lambda: _anthropic_resp(5, 15, cache_read=17666, cache_creation=15))
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 17686 # 5+17666+15
|
||||
|
||||
|
||||
# -- OpenAI: prompt_tokens already total --
|
||||
|
||||
def test_openai_prompt_tokens_unchanged(monkeypatch):
|
||||
resp = lambda: SimpleNamespace(
|
||||
choices=[SimpleNamespace(index=0, message=SimpleNamespace(
|
||||
role="assistant", content="ok", tool_calls=None, reasoning_content=None,
|
||||
), finish_reason="stop")],
|
||||
usage=SimpleNamespace(prompt_tokens=5000, completion_tokens=100, total_tokens=5100),
|
||||
model="gpt-4o",
|
||||
)
|
||||
agent = _make_agent(monkeypatch, "chat_completions", "openrouter", resp)
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 5000
|
||||
|
||||
|
||||
# -- Codex: no cache fields, getattr returns 0 --
|
||||
|
||||
def test_codex_no_cache_fields(monkeypatch):
|
||||
resp = lambda: SimpleNamespace(
|
||||
output=[SimpleNamespace(type="message", content=[SimpleNamespace(type="output_text", text="ok")])],
|
||||
usage=SimpleNamespace(input_tokens=3000, output_tokens=50, total_tokens=3050),
|
||||
status="completed", model="gpt-5-codex",
|
||||
)
|
||||
agent = _make_agent(monkeypatch, "codex_responses", "openai-codex", resp)
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 3000
|
||||
@@ -72,10 +72,11 @@ class TestCLIQuickCommands:
|
||||
|
||||
def test_unknown_command_still_shows_error(self):
|
||||
cli = self._make_cli({})
|
||||
cli.process_command("/nonexistent")
|
||||
cli.console.print.assert_called()
|
||||
args = cli.console.print.call_args_list[0][0][0]
|
||||
assert "unknown command" in args.lower()
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli.process_command("/nonexistent")
|
||||
mock_cprint.assert_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "unknown command" in printed.lower()
|
||||
|
||||
def test_timeout_shows_error(self):
|
||||
cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})
|
||||
|
||||
@@ -62,22 +62,44 @@ class TestScanCronPrompt:
|
||||
|
||||
|
||||
class TestCronjobRequirements:
|
||||
def test_requires_crontab_binary_even_in_interactive_mode(self, monkeypatch):
|
||||
def test_requires_no_crontab_binary(self, monkeypatch):
|
||||
"""Cron is internal (JSON-based scheduler), no system crontab needed."""
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||||
# Even with no crontab in PATH, the cronjob tool should be available
|
||||
# because hermes uses an internal scheduler, not system crontab.
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
assert check_cronjob_requirements() is False
|
||||
|
||||
def test_accepts_interactive_mode_when_crontab_exists(self, monkeypatch):
|
||||
def test_accepts_interactive_mode(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/crontab")
|
||||
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
def test_accepts_gateway_session(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
def test_accepts_exec_ask(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.setenv("HERMES_EXEC_ASK", "1")
|
||||
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
def test_rejects_when_no_session_env(self, monkeypatch):
|
||||
"""Without any session env vars, cronjob tool should not be available."""
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
|
||||
assert check_cronjob_requirements() is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# schedule_cronjob
|
||||
|
||||
@@ -9,7 +9,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from gateway.config import Platform
|
||||
from tools.send_message_tool import _send_telegram, send_message_tool
|
||||
from tools.send_message_tool import _send_telegram, _send_to_platform, send_message_tool
|
||||
|
||||
|
||||
def _run_async_immediately(coro):
|
||||
@@ -25,8 +25,11 @@ def _make_config():
|
||||
|
||||
|
||||
def _install_telegram_mock(monkeypatch, bot):
|
||||
telegram_mod = SimpleNamespace(Bot=lambda token: bot)
|
||||
parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2")
|
||||
constants_mod = SimpleNamespace(ParseMode=parse_mode)
|
||||
telegram_mod = SimpleNamespace(Bot=lambda token: bot, constants=constants_mod)
|
||||
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
|
||||
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
|
||||
|
||||
|
||||
class TestSendMessageTool:
|
||||
@@ -342,3 +345,49 @@ class TestSendTelegramMediaDelivery:
|
||||
assert "error" in result
|
||||
assert "No deliverable text or media remained" in result["error"]
|
||||
bot.send_message.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: long messages are chunked before platform dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSendToPlatformChunking:
|
||||
def test_long_message_is_chunked(self):
|
||||
"""Messages exceeding the platform limit are split into multiple sends."""
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit
|
||||
with patch("tools.send_message_tool._send_discord", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
SimpleNamespace(enabled=True, token="tok", extra={}),
|
||||
"ch", long_msg,
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert send.await_count >= 3
|
||||
for call in send.await_args_list:
|
||||
assert len(call.args[2]) <= 2020 # each chunk fits the limit
|
||||
|
||||
def test_telegram_media_attaches_to_last_chunk(self):
|
||||
"""When chunked, media files are sent only with the last chunk."""
|
||||
sent_calls = []
|
||||
|
||||
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
|
||||
sent_calls.append(media_files or [])
|
||||
return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))}
|
||||
|
||||
long_msg = "word " * 2000 # ~10000 chars, well over 4096
|
||||
media = [("/tmp/photo.png", False)]
|
||||
with patch("tools.send_message_tool._send_telegram", fake_send):
|
||||
asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.TELEGRAM,
|
||||
SimpleNamespace(enabled=True, token="tok", extra={}),
|
||||
"123", long_msg, media_files=media,
|
||||
)
|
||||
)
|
||||
assert len(sent_calls) >= 3
|
||||
assert all(call == [] for call in sent_calls[:-1])
|
||||
assert sent_calls[-1] == media
|
||||
|
||||
77
tests/tools/test_singularity_preflight.py
Normal file
77
tests/tools/test_singularity_preflight.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for Singularity/Apptainer preflight availability check.
|
||||
|
||||
Verifies that a clear error is raised when neither apptainer nor
|
||||
singularity is installed, instead of a cryptic FileNotFoundError.
|
||||
|
||||
See: https://github.com/NousResearch/hermes-agent/issues/1511
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.environments.singularity import (
|
||||
_find_singularity_executable,
|
||||
_ensure_singularity_available,
|
||||
)
|
||||
|
||||
|
||||
class TestFindSingularityExecutable:
|
||||
"""_find_singularity_executable resolution tests."""
|
||||
|
||||
def test_prefers_apptainer(self):
|
||||
"""When both are available, apptainer should be preferred."""
|
||||
def which_both(name):
|
||||
return f"/usr/bin/{name}" if name in ("apptainer", "singularity") else None
|
||||
|
||||
with patch("shutil.which", side_effect=which_both):
|
||||
assert _find_singularity_executable() == "apptainer"
|
||||
|
||||
def test_falls_back_to_singularity(self):
|
||||
"""When only singularity is available, use it."""
|
||||
def which_singularity_only(name):
|
||||
return "/usr/bin/singularity" if name == "singularity" else None
|
||||
|
||||
with patch("shutil.which", side_effect=which_singularity_only):
|
||||
assert _find_singularity_executable() == "singularity"
|
||||
|
||||
def test_raises_when_neither_found(self):
|
||||
"""Must raise RuntimeError with install instructions."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="Neither.*apptainer.*nor.*singularity"):
|
||||
_find_singularity_executable()
|
||||
|
||||
|
||||
class TestEnsureSingularityAvailable:
|
||||
"""_ensure_singularity_available preflight tests."""
|
||||
|
||||
def test_returns_executable_on_success(self):
|
||||
"""Returns the executable name when version check passes."""
|
||||
fake_result = MagicMock(returncode=0, stderr="")
|
||||
|
||||
with patch("shutil.which", side_effect=lambda n: "/usr/bin/apptainer" if n == "apptainer" else None), \
|
||||
patch("subprocess.run", return_value=fake_result):
|
||||
assert _ensure_singularity_available() == "apptainer"
|
||||
|
||||
def test_raises_on_version_failure(self):
|
||||
"""Raises RuntimeError when version command fails."""
|
||||
fake_result = MagicMock(returncode=1, stderr="unknown flag")
|
||||
|
||||
with patch("shutil.which", side_effect=lambda n: "/usr/bin/apptainer" if n == "apptainer" else None), \
|
||||
patch("subprocess.run", return_value=fake_result):
|
||||
with pytest.raises(RuntimeError, match="version.*failed"):
|
||||
_ensure_singularity_available()
|
||||
|
||||
def test_raises_on_timeout(self):
|
||||
"""Raises RuntimeError when version command times out."""
|
||||
with patch("shutil.which", side_effect=lambda n: "/usr/bin/apptainer" if n == "apptainer" else None), \
|
||||
patch("subprocess.run", side_effect=subprocess.TimeoutExpired("apptainer", 10)):
|
||||
with pytest.raises(RuntimeError, match="timed out"):
|
||||
_ensure_singularity_available()
|
||||
|
||||
def test_raises_when_not_installed(self):
|
||||
"""Raises RuntimeError when neither executable exists."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="Neither.*apptainer.*nor.*singularity"):
|
||||
_ensure_singularity_available()
|
||||
10
tools/browser_providers/__init__.py
Normal file
10
tools/browser_providers/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Cloud browser provider abstraction.
|
||||
|
||||
Import the ABC so callers can do::
|
||||
|
||||
from tools.browser_providers import CloudBrowserProvider
|
||||
"""
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
|
||||
__all__ = ["CloudBrowserProvider"]
|
||||
59
tools/browser_providers/base.py
Normal file
59
tools/browser_providers/base.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Abstract base class for cloud browser providers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class CloudBrowserProvider(ABC):
|
||||
"""Interface for cloud browser backends (Browserbase, Steel, etc.).
|
||||
|
||||
Implementations live in sibling modules and are registered in
|
||||
``browser_tool._PROVIDER_REGISTRY``. The user selects a provider via
|
||||
``hermes setup`` / ``hermes tools``; the choice is persisted as
|
||||
``config["browser"]["cloud_provider"]``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def provider_name(self) -> str:
|
||||
"""Short, human-readable name shown in logs and diagnostics."""
|
||||
|
||||
@abstractmethod
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True when all required env vars / credentials are present.
|
||||
|
||||
Called at tool-registration time (``check_browser_requirements``) to
|
||||
gate availability. Must be cheap — no network calls.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
"""Create a cloud browser session and return session metadata.
|
||||
|
||||
Must return a dict with at least::
|
||||
|
||||
{
|
||||
"session_name": str, # unique name for agent-browser --session
|
||||
"bb_session_id": str, # provider session ID (for close/cleanup)
|
||||
"cdp_url": str, # CDP websocket URL
|
||||
"features": dict, # feature flags that were enabled
|
||||
}
|
||||
|
||||
``bb_session_id`` is a legacy key name kept for backward compat with
|
||||
the rest of browser_tool.py — it holds the provider's session ID
|
||||
regardless of which provider is in use.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
"""Release / terminate a cloud session by its provider session ID.
|
||||
|
||||
Returns True on success, False on failure. Should not raise.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
"""Best-effort session teardown during process exit.
|
||||
|
||||
Called from atexit / signal handlers. Must tolerate missing
|
||||
credentials, network errors, etc. — log and move on.
|
||||
"""
|
||||
107
tools/browser_providers/browser_use.py
Normal file
107
tools/browser_providers/browser_use.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Browser Use cloud browser provider."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Dict
|
||||
|
||||
import requests
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.browser-use.com/api/v2"
|
||||
|
||||
|
||||
class BrowserUseProvider(CloudBrowserProvider):
|
||||
"""Browser Use (https://browser-use.com) cloud browser backend."""
|
||||
|
||||
def provider_name(self) -> str:
|
||||
return "Browser Use"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(os.environ.get("BROWSER_USE_API_KEY"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"BROWSER_USE_API_KEY environment variable is required. "
|
||||
"Get your key at https://browser-use.com"
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Browser-Use-API-Key": api_key,
|
||||
}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
response = requests.post(
|
||||
f"{_BASE_URL}/browsers",
|
||||
headers=self._headers(),
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browser Use session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info("Created Browser Use session %s", session_name)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["cdpUrl"],
|
||||
"features": {"browser_use": True},
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
response = requests.patch(
|
||||
f"{_BASE_URL}/browsers/{session_id}",
|
||||
headers=self._headers(),
|
||||
json={"action": "stop"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
logger.debug("Successfully closed Browser Use session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close Browser Use session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browser Use session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
||||
if not api_key:
|
||||
logger.warning("Cannot emergency-cleanup Browser Use session %s — missing credentials", session_id)
|
||||
return
|
||||
try:
|
||||
requests.patch(
|
||||
f"{_BASE_URL}/browsers/{session_id}",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Browser-Use-API-Key": api_key,
|
||||
},
|
||||
json={"action": "stop"},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Emergency cleanup failed for Browser Use session %s: %s", session_id, e)
|
||||
206
tools/browser_providers/browserbase.py
Normal file
206
tools/browser_providers/browserbase.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Browserbase cloud browser provider."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Dict
|
||||
|
||||
import requests
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrowserbaseProvider(CloudBrowserProvider):
|
||||
"""Browserbase (https://browserbase.com) cloud browser backend."""
|
||||
|
||||
def provider_name(self) -> str:
|
||||
return "Browserbase"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
os.environ.get("BROWSERBASE_API_KEY")
|
||||
and os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config(self) -> Dict[str, str]:
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
raise ValueError(
|
||||
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment "
|
||||
"variables are required. Get your credentials at "
|
||||
"https://browserbase.com"
|
||||
)
|
||||
return {"api_key": api_key, "project_id": project_id}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
config = self._get_config()
|
||||
|
||||
# Optional env-var knobs
|
||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||
enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
|
||||
enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
|
||||
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
|
||||
|
||||
features_enabled = {
|
||||
"basic_stealth": True,
|
||||
"proxies": False,
|
||||
"advanced_stealth": False,
|
||||
"keep_alive": False,
|
||||
"custom_timeout": False,
|
||||
}
|
||||
|
||||
session_config: Dict[str, object] = {"projectId": config["project_id"]}
|
||||
|
||||
if enable_keep_alive:
|
||||
session_config["keepAlive"] = True
|
||||
|
||||
if custom_timeout_ms:
|
||||
try:
|
||||
timeout_val = int(custom_timeout_ms)
|
||||
if timeout_val > 0:
|
||||
session_config["timeout"] = timeout_val
|
||||
except ValueError:
|
||||
logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms)
|
||||
|
||||
if enable_proxies:
|
||||
session_config["proxies"] = True
|
||||
|
||||
if enable_advanced_stealth:
|
||||
session_config["browserSettings"] = {"advancedStealth": True}
|
||||
|
||||
# --- Create session via API ---
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
}
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
proxies_fallback = False
|
||||
keepalive_fallback = False
|
||||
|
||||
# Handle 402 — paid features unavailable
|
||||
if response.status_code == 402:
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning(
|
||||
"keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations."
|
||||
)
|
||||
session_config.pop("keepAlive", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 402 and enable_proxies:
|
||||
proxies_fallback = True
|
||||
logger.warning(
|
||||
"Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective."
|
||||
)
|
||||
session_config.pop("proxies", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browserbase session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
if enable_proxies and not proxies_fallback:
|
||||
features_enabled["proxies"] = True
|
||||
if enable_advanced_stealth:
|
||||
features_enabled["advanced_stealth"] = True
|
||||
if enable_keep_alive and not keepalive_fallback:
|
||||
features_enabled["keep_alive"] = True
|
||||
if custom_timeout_ms and "timeout" in session_config:
|
||||
features_enabled["custom_timeout"] = True
|
||||
|
||||
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
|
||||
logger.info("Created Browserbase session %s with features: %s", session_name, feature_str)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["connectUrl"],
|
||||
"features": features_enabled,
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
config = self._get_config()
|
||||
except ValueError:
|
||||
logger.warning("Cannot close Browserbase session %s — missing credentials", session_id)
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": config["project_id"],
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
logger.debug("Successfully closed Browserbase session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browserbase session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
|
||||
return
|
||||
try:
|
||||
requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": project_id,
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Emergency cleanup failed for Browserbase session %s: %s", session_id, e)
|
||||
@@ -65,6 +65,9 @@ import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
from agent.auxiliary_client import call_llm
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -108,16 +111,43 @@ def _get_cdp_override() -> str:
|
||||
return os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||
|
||||
|
||||
def _is_local_mode() -> bool:
|
||||
"""Return True when no Browserbase credentials are configured.
|
||||
# ============================================================================
|
||||
# Cloud Provider Registry
|
||||
# ============================================================================
|
||||
|
||||
In local mode the browser tools launch a headless Chromium instance via
|
||||
``agent-browser --session`` instead of connecting to a remote Browserbase
|
||||
session via ``--cdp``.
|
||||
_PROVIDER_REGISTRY: Dict[str, type] = {
|
||||
"browserbase": BrowserbaseProvider,
|
||||
"browser-use": BrowserUseProvider,
|
||||
}
|
||||
|
||||
_cached_cloud_provider: Optional[CloudBrowserProvider] = None
|
||||
_cloud_provider_resolved = False
|
||||
|
||||
|
||||
def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
"""Return the configured cloud browser provider, or None for local mode.
|
||||
|
||||
Reads ``config["browser"]["cloud_provider"]`` once and caches the result
|
||||
for the process lifetime. If unset → local mode (None).
|
||||
"""
|
||||
if _get_cdp_override():
|
||||
return False # CDP override takes priority
|
||||
return not (os.environ.get("BROWSERBASE_API_KEY") and os.environ.get("BROWSERBASE_PROJECT_ID"))
|
||||
global _cached_cloud_provider, _cloud_provider_resolved
|
||||
if _cloud_provider_resolved:
|
||||
return _cached_cloud_provider
|
||||
|
||||
_cloud_provider_resolved = True
|
||||
try:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
config_path = hermes_home / "config.yaml"
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
provider_key = cfg.get("browser", {}).get("cloud_provider")
|
||||
if provider_key and provider_key in _PROVIDER_REGISTRY:
|
||||
_cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]()
|
||||
except Exception as e:
|
||||
logger.debug("Could not read cloud_provider from config: %s", e)
|
||||
return _cached_cloud_provider
|
||||
|
||||
|
||||
def _socket_safe_tmpdir() -> str:
|
||||
@@ -452,161 +482,6 @@ BROWSER_TOOL_SCHEMAS = [
|
||||
# Utility Functions
|
||||
# ============================================================================
|
||||
|
||||
def _create_browserbase_session(task_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Create a Browserbase session with stealth features.
|
||||
|
||||
Browserbase Stealth Modes:
|
||||
- Basic Stealth: ALWAYS enabled automatically. Generates random fingerprints,
|
||||
viewports, and solves visual CAPTCHAs. No configuration needed.
|
||||
- Advanced Stealth: Uses custom Chromium build for better bot detection avoidance.
|
||||
Requires Scale Plan. Enable via BROWSERBASE_ADVANCED_STEALTH=true.
|
||||
|
||||
Proxies are enabled by default to route traffic through residential IPs,
|
||||
which significantly improves CAPTCHA solving rates. Can be disabled via
|
||||
BROWSERBASE_PROXIES=false if needed.
|
||||
|
||||
Args:
|
||||
task_id: Unique identifier for the task
|
||||
|
||||
Returns:
|
||||
Dict with session_name, bb_session_id, cdp_url, and feature flags
|
||||
"""
|
||||
import uuid
|
||||
import sys
|
||||
|
||||
config = _get_browserbase_config()
|
||||
|
||||
# Check for optional settings from environment
|
||||
# Proxies: enabled by default for better CAPTCHA solving
|
||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||
# Advanced Stealth: requires Scale Plan, disabled by default
|
||||
enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
|
||||
# keepAlive: enabled by default (requires paid plan) - allows reconnection after disconnects
|
||||
enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
|
||||
# Custom session timeout in milliseconds (optional) - extends session beyond project default
|
||||
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
|
||||
|
||||
# Track which features are actually enabled for logging/debugging
|
||||
features_enabled = {
|
||||
"basic_stealth": True, # Always on
|
||||
"proxies": False,
|
||||
"advanced_stealth": False,
|
||||
"keep_alive": False,
|
||||
"custom_timeout": False,
|
||||
}
|
||||
|
||||
# Build session configuration
|
||||
# Note: Basic stealth mode is ALWAYS active - no configuration needed
|
||||
session_config = {
|
||||
"projectId": config["project_id"],
|
||||
}
|
||||
|
||||
# Enable keepAlive for session reconnection (default: true, requires paid plan)
|
||||
# Allows reconnecting to the same session after network hiccups
|
||||
if enable_keep_alive:
|
||||
session_config["keepAlive"] = True
|
||||
|
||||
# Add custom timeout if specified (in milliseconds)
|
||||
# This extends session duration beyond project's default timeout
|
||||
if custom_timeout_ms:
|
||||
try:
|
||||
timeout_val = int(custom_timeout_ms)
|
||||
if timeout_val > 0:
|
||||
session_config["timeout"] = timeout_val
|
||||
except ValueError:
|
||||
logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms)
|
||||
|
||||
# Enable proxies for better CAPTCHA solving (default: true)
|
||||
# Routes traffic through residential IPs for more reliable access
|
||||
if enable_proxies:
|
||||
session_config["proxies"] = True
|
||||
|
||||
# Add advanced stealth if enabled (requires Scale Plan)
|
||||
# Uses custom Chromium build to avoid bot detection altogether
|
||||
if enable_advanced_stealth:
|
||||
session_config["browserSettings"] = {
|
||||
"advancedStealth": True,
|
||||
}
|
||||
|
||||
# Create session via Browserbase API
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
},
|
||||
json=session_config,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Track if we fell back from paid features
|
||||
proxies_fallback = False
|
||||
keepalive_fallback = False
|
||||
|
||||
# Handle 402 Payment Required - likely paid features not available
|
||||
# Try to identify which feature caused the issue and retry without it
|
||||
if response.status_code == 402:
|
||||
# First try without keepAlive (most likely culprit for paid plan requirement)
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning("keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations.")
|
||||
session_config.pop("keepAlive", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
},
|
||||
json=session_config,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# If still 402, try without proxies too
|
||||
if response.status_code == 402 and enable_proxies:
|
||||
proxies_fallback = True
|
||||
logger.warning("Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective.")
|
||||
session_config.pop("proxies", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
},
|
||||
json=session_config,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"Failed to create Browserbase session: {response.status_code} {response.text}")
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Update features based on what actually succeeded
|
||||
if enable_proxies and not proxies_fallback:
|
||||
features_enabled["proxies"] = True
|
||||
if enable_advanced_stealth:
|
||||
features_enabled["advanced_stealth"] = True
|
||||
if enable_keep_alive and not keepalive_fallback:
|
||||
features_enabled["keep_alive"] = True
|
||||
if custom_timeout_ms and "timeout" in session_config:
|
||||
features_enabled["custom_timeout"] = True
|
||||
|
||||
# Log session info for debugging
|
||||
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
|
||||
logger.info("Created session %s with features: %s", session_name, feature_str)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["connectUrl"],
|
||||
"features": features_enabled,
|
||||
}
|
||||
|
||||
|
||||
def _create_local_session(task_id: str) -> Dict[str, str]:
|
||||
import uuid
|
||||
session_name = f"h_{uuid.uuid4().hex[:10]}"
|
||||
@@ -667,10 +542,12 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
|
||||
cdp_override = _get_cdp_override()
|
||||
if cdp_override:
|
||||
session_info = _create_cdp_session(task_id, cdp_override)
|
||||
elif _is_local_mode():
|
||||
session_info = _create_local_session(task_id)
|
||||
else:
|
||||
session_info = _create_browserbase_session(task_id)
|
||||
provider = _get_cloud_provider()
|
||||
if provider is None:
|
||||
session_info = _create_local_session(task_id)
|
||||
else:
|
||||
session_info = provider.create_session(task_id)
|
||||
|
||||
with _cleanup_lock:
|
||||
_active_sessions[task_id] = session_info
|
||||
@@ -692,31 +569,6 @@ def _get_session_name(task_id: Optional[str] = None) -> str:
|
||||
return session_info["session_name"]
|
||||
|
||||
|
||||
def _get_browserbase_config() -> Dict[str, str]:
|
||||
"""
|
||||
Get Browserbase configuration from environment.
|
||||
|
||||
Returns:
|
||||
Dict with api_key and project_id
|
||||
|
||||
Raises:
|
||||
ValueError: If required env vars are not set
|
||||
"""
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
|
||||
if not api_key or not project_id:
|
||||
raise ValueError(
|
||||
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables are required. "
|
||||
"Get your credentials at https://browserbase.com"
|
||||
)
|
||||
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"project_id": project_id
|
||||
}
|
||||
|
||||
|
||||
def _find_agent_browser() -> str:
|
||||
"""
|
||||
Find the agent-browser CLI executable.
|
||||
@@ -859,27 +711,62 @@ def _run_browser_command(
|
||||
browser_env["PATH"] = ":".join(path_parts)
|
||||
browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir
|
||||
|
||||
result = subprocess.run(
|
||||
cmd_parts,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=browser_env,
|
||||
)
|
||||
|
||||
# Use temp files for stdout/stderr instead of pipes.
|
||||
# agent-browser starts a background daemon that inherits file
|
||||
# descriptors. With capture_output=True (pipes), the daemon keeps
|
||||
# the pipe fds open after the CLI exits, so communicate() never
|
||||
# sees EOF and blocks until the timeout fires.
|
||||
stdout_path = os.path.join(task_socket_dir, f"_stdout_{command}")
|
||||
stderr_path = os.path.join(task_socket_dir, f"_stderr_{command}")
|
||||
stdout_fd = os.open(stdout_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
stderr_fd = os.open(stderr_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd_parts,
|
||||
stdout=stdout_fd,
|
||||
stderr=stderr_fd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
env=browser_env,
|
||||
)
|
||||
finally:
|
||||
os.close(stdout_fd)
|
||||
os.close(stderr_fd)
|
||||
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
|
||||
command, timeout, task_id, task_socket_dir)
|
||||
return {"success": False, "error": f"Command timed out after {timeout} seconds"}
|
||||
|
||||
with open(stdout_path, "r") as f:
|
||||
stdout = f.read()
|
||||
with open(stderr_path, "r") as f:
|
||||
stderr = f.read()
|
||||
returncode = proc.returncode
|
||||
|
||||
# Clean up temp files (best-effort)
|
||||
for p in (stdout_path, stderr_path):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Log stderr for diagnostics — use warning level on failure so it's visible
|
||||
if result.stderr and result.stderr.strip():
|
||||
level = logging.WARNING if result.returncode != 0 else logging.DEBUG
|
||||
logger.log(level, "browser '%s' stderr: %s", command, result.stderr.strip()[:500])
|
||||
if stderr and stderr.strip():
|
||||
level = logging.WARNING if returncode != 0 else logging.DEBUG
|
||||
logger.log(level, "browser '%s' stderr: %s", command, stderr.strip()[:500])
|
||||
|
||||
# Log empty output as warning — common sign of broken agent-browser
|
||||
if not result.stdout.strip() and result.returncode == 0:
|
||||
if not stdout.strip() and returncode == 0:
|
||||
logger.warning("browser '%s' returned empty stdout with rc=0. "
|
||||
"cmd=%s stderr=%s",
|
||||
command, " ".join(cmd_parts[:4]) + "...",
|
||||
(result.stderr or "")[:200])
|
||||
(stderr or "")[:200])
|
||||
|
||||
stdout_text = result.stdout.strip()
|
||||
stdout_text = stdout.strip()
|
||||
|
||||
if stdout_text:
|
||||
try:
|
||||
@@ -890,15 +777,15 @@ def _run_browser_command(
|
||||
if not snap_data.get("snapshot") and not snap_data.get("refs"):
|
||||
logger.warning("snapshot returned empty content. "
|
||||
"Possible stale daemon or CDP connection issue. "
|
||||
"returncode=%s", result.returncode)
|
||||
"returncode=%s", returncode)
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
raw = stdout_text[:2000]
|
||||
logger.warning("browser '%s' returned non-JSON output (rc=%s): %s",
|
||||
command, result.returncode, raw[:500])
|
||||
command, returncode, raw[:500])
|
||||
|
||||
if command == "screenshot":
|
||||
stderr_text = (result.stderr or "").strip()
|
||||
stderr_text = (stderr or "").strip()
|
||||
combined_text = "\n".join(
|
||||
part for part in [stdout_text, stderr_text] if part
|
||||
)
|
||||
@@ -923,17 +810,13 @@ def _run_browser_command(
|
||||
}
|
||||
|
||||
# Check for errors
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.strip() if result.stderr else f"Command failed with code {result.returncode}"
|
||||
logger.warning("browser '%s' failed (rc=%s): %s", command, result.returncode, error_msg[:300])
|
||||
if returncode != 0:
|
||||
error_msg = stderr.strip() if stderr else f"Command failed with code {returncode}"
|
||||
logger.warning("browser '%s' failed (rc=%s): %s", command, returncode, error_msg[:300])
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
|
||||
command, timeout, task_id, task_socket_dir)
|
||||
return {"success": False, "error": f"Command timed out after {timeout} seconds"}
|
||||
except Exception as e:
|
||||
logger.warning("browser '%s' exception: %s", command, e, exc_info=True)
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1509,7 +1392,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
|
||||
if not result.get("success"):
|
||||
error_detail = result.get("error", "Unknown error")
|
||||
mode = "local" if _is_local_mode() else "cloud"
|
||||
_cp = _get_cloud_provider()
|
||||
mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Failed to take screenshot ({mode} mode): {error_detail}"
|
||||
@@ -1521,7 +1405,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
|
||||
# Check if screenshot file was created
|
||||
if not screenshot_path.exists():
|
||||
mode = "local" if _is_local_mode() else "cloud"
|
||||
_cp = _get_cloud_provider()
|
||||
mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": (
|
||||
@@ -1639,48 +1524,6 @@ def _cleanup_old_recordings(max_age_hours=72):
|
||||
# Cleanup and Management Functions
|
||||
# ============================================================================
|
||||
|
||||
def _close_browserbase_session(session_id: str, api_key: str, project_id: str) -> bool:
|
||||
"""
|
||||
Close a Browserbase session immediately via the API.
|
||||
|
||||
Uses POST /v1/sessions/{id} with status=REQUEST_RELEASE to immediately
|
||||
terminate the session without waiting for keepAlive timeout.
|
||||
|
||||
Args:
|
||||
session_id: The Browserbase session ID
|
||||
api_key: Browserbase API key
|
||||
project_id: Browserbase project ID
|
||||
|
||||
Returns:
|
||||
True if session was successfully closed, False otherwise
|
||||
"""
|
||||
try:
|
||||
# POST to update session status to REQUEST_RELEASE
|
||||
response = requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": api_key,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"projectId": project_id,
|
||||
"status": "REQUEST_RELEASE"
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201, 204):
|
||||
logger.debug("Successfully closed BrowserBase session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning("Failed to close session %s: HTTP %s - %s", session_id, response.status_code, response.text[:200])
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Exception closing session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clean up browser session for a task.
|
||||
@@ -1721,15 +1564,14 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||
_active_sessions.pop(task_id, None)
|
||||
_session_last_activity.pop(task_id, None)
|
||||
|
||||
# Cloud mode: close the Browserbase session via API
|
||||
if bb_session_id and not _is_local_mode():
|
||||
try:
|
||||
config = _get_browserbase_config()
|
||||
success = _close_browserbase_session(bb_session_id, config["api_key"], config["project_id"])
|
||||
if not success:
|
||||
logger.warning("Could not close BrowserBase session %s", bb_session_id)
|
||||
except Exception as e:
|
||||
logger.error("Exception during BrowserBase session close: %s", e)
|
||||
# Cloud mode: close the cloud browser session via provider API
|
||||
if bb_session_id:
|
||||
provider = _get_cloud_provider()
|
||||
if provider is not None:
|
||||
try:
|
||||
provider.close_session(bb_session_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not close cloud browser session: %s", e)
|
||||
|
||||
# Kill the daemon process and clean up socket directory
|
||||
session_name = session_info.get("session_name", "")
|
||||
@@ -1798,12 +1640,10 @@ def check_browser_requirements() -> bool:
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
# In cloud mode, also require Browserbase credentials
|
||||
if not _is_local_mode():
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
return False
|
||||
# In cloud mode, also require provider credentials
|
||||
provider = _get_cloud_provider()
|
||||
if provider is not None and not provider.is_configured():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1819,7 +1659,8 @@ if __name__ == "__main__":
|
||||
print("🌐 Browser Tool Module")
|
||||
print("=" * 40)
|
||||
|
||||
mode = "local" if _is_local_mode() else "cloud (Browserbase)"
|
||||
_cp = _get_cloud_provider()
|
||||
mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
|
||||
print(f" Mode: {mode}")
|
||||
|
||||
# Check requirements
|
||||
@@ -1832,12 +1673,9 @@ if __name__ == "__main__":
|
||||
except FileNotFoundError:
|
||||
print(" - agent-browser CLI not found")
|
||||
print(" Install: npm install -g agent-browser && agent-browser install --with-deps")
|
||||
if not _is_local_mode():
|
||||
if not os.environ.get("BROWSERBASE_API_KEY"):
|
||||
print(" - BROWSERBASE_API_KEY not set (required for cloud mode)")
|
||||
if not os.environ.get("BROWSERBASE_PROJECT_ID"):
|
||||
print(" - BROWSERBASE_PROJECT_ID not set (required for cloud mode)")
|
||||
print(" Tip: unset BROWSERBASE_API_KEY to use free local mode instead")
|
||||
if _cp is not None and not _cp.is_configured():
|
||||
print(f" - {_cp.provider_name()} credentials not configured")
|
||||
print(" Tip: remove cloud_provider from config to use free local mode instead")
|
||||
|
||||
print("\n📋 Available Browser Tools:")
|
||||
for schema in BROWSER_TOOL_SCHEMAS:
|
||||
|
||||
@@ -8,7 +8,6 @@ Compatibility wrappers remain for direct Python callers and legacy tests.
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -414,13 +413,10 @@ def check_cronjob_requirements() -> bool:
|
||||
"""
|
||||
Check if cronjob tools can be used.
|
||||
|
||||
Requires 'crontab' executable to be present in the system PATH.
|
||||
Available in interactive CLI mode and gateway/messaging platforms.
|
||||
The cron system is internal (JSON file-based scheduler ticked by the gateway),
|
||||
so no external crontab executable is required.
|
||||
"""
|
||||
# Ensure the system can actually install and manage cron entries.
|
||||
if not shutil.which("crontab"):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
os.getenv("HERMES_INTERACTIVE")
|
||||
or os.getenv("HERMES_GATEWAY_SESSION")
|
||||
|
||||
@@ -25,6 +25,57 @@ logger = logging.getLogger(__name__)
|
||||
_SNAPSHOT_STORE = get_hermes_home() / "singularity_snapshots.json"
|
||||
|
||||
|
||||
def _find_singularity_executable() -> str:
|
||||
"""Locate the apptainer or singularity CLI binary.
|
||||
|
||||
Returns the executable name (``"apptainer"`` or ``"singularity"``).
|
||||
Raises ``RuntimeError`` with install instructions if neither is found.
|
||||
"""
|
||||
if shutil.which("apptainer"):
|
||||
return "apptainer"
|
||||
if shutil.which("singularity"):
|
||||
return "singularity"
|
||||
raise RuntimeError(
|
||||
"Neither 'apptainer' nor 'singularity' was found in PATH. "
|
||||
"Install Apptainer (https://apptainer.org/docs/admin/main/installation.html) "
|
||||
"or Singularity and ensure the CLI is available."
|
||||
)
|
||||
|
||||
|
||||
def _ensure_singularity_available() -> str:
|
||||
"""Preflight check: resolve the executable and verify it responds.
|
||||
|
||||
Returns the executable name on success.
|
||||
Raises ``RuntimeError`` with an actionable message on failure.
|
||||
"""
|
||||
exe = _find_singularity_executable()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[exe, "version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
f"Singularity backend selected but the resolved executable '{exe}' "
|
||||
"could not be executed. Check your installation."
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
f"'{exe} version' timed out. The runtime may be misconfigured."
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()[:200]
|
||||
raise RuntimeError(
|
||||
f"'{exe} version' failed (exit code {result.returncode}): {stderr}"
|
||||
)
|
||||
|
||||
return exe
|
||||
|
||||
|
||||
def _load_snapshots() -> Dict[str, str]:
|
||||
if _SNAPSHOT_STORE.exists():
|
||||
try:
|
||||
@@ -169,7 +220,7 @@ class SingularityEnvironment(BaseEnvironment):
|
||||
task_id: str = "default",
|
||||
):
|
||||
super().__init__(cwd=cwd, timeout=timeout)
|
||||
self.executable = "apptainer" if shutil.which("apptainer") else "singularity"
|
||||
self.executable = _ensure_singularity_available()
|
||||
self.image = _get_or_build_sif(image, self.executable)
|
||||
self.instance_id = f"hermes_{uuid.uuid4().hex[:12]}"
|
||||
self._instance_started = False
|
||||
|
||||
@@ -169,6 +169,27 @@ def clear_file_ops_cache(task_id: str = None):
|
||||
def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = "default") -> str:
|
||||
"""Read a file with pagination and line numbers."""
|
||||
try:
|
||||
# Security: block direct reads of internal Hermes cache/index files
|
||||
# to prevent prompt injection via catalog or hub metadata files.
|
||||
import pathlib as _pathlib
|
||||
_resolved = _pathlib.Path(path).expanduser().resolve()
|
||||
_hermes_home = _pathlib.Path("~/.hermes").expanduser().resolve()
|
||||
_blocked_dirs = [
|
||||
_hermes_home / "skills" / ".hub" / "index-cache",
|
||||
_hermes_home / "skills" / ".hub",
|
||||
]
|
||||
for _blocked in _blocked_dirs:
|
||||
try:
|
||||
_resolved.relative_to(_blocked)
|
||||
return json.dumps({
|
||||
"error": (
|
||||
f"Access denied: {path} is an internal Hermes cache file "
|
||||
"and cannot be read directly to prevent prompt injection. "
|
||||
"Use the skills_list or skill_view tools instead."
|
||||
)
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
file_ops = _get_file_ops(task_id)
|
||||
result = file_ops.read_file(path, offset, limit)
|
||||
if result.content:
|
||||
|
||||
@@ -263,18 +263,53 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id:
|
||||
|
||||
|
||||
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
|
||||
"""Route a message to the appropriate platform sender."""
|
||||
"""Route a message to the appropriate platform sender.
|
||||
|
||||
Long messages are automatically chunked to fit within platform limits
|
||||
using the same smart-splitting algorithm as the gateway adapters
|
||||
(preserves code-block boundaries, adds part indicators).
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from gateway.platforms.slack import SlackAdapter
|
||||
|
||||
media_files = media_files or []
|
||||
|
||||
# Platform message length limits (from adapter class attributes)
|
||||
_MAX_LENGTHS = {
|
||||
Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH,
|
||||
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
|
||||
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
|
||||
}
|
||||
|
||||
# Smart-chunk the message to fit within platform limits.
|
||||
# For short messages or platforms without a known limit this is a no-op.
|
||||
max_len = _MAX_LENGTHS.get(platform)
|
||||
if max_len:
|
||||
chunks = BasePlatformAdapter.truncate_message(message, max_len)
|
||||
else:
|
||||
chunks = [message]
|
||||
|
||||
# --- Telegram: special handling for media attachments ---
|
||||
if platform == Platform.TELEGRAM:
|
||||
return await _send_telegram(
|
||||
pconfig.token,
|
||||
chat_id,
|
||||
message,
|
||||
media_files=media_files,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
last_result = None
|
||||
for i, chunk in enumerate(chunks):
|
||||
is_last = (i == len(chunks) - 1)
|
||||
result = await _send_telegram(
|
||||
pconfig.token,
|
||||
chat_id,
|
||||
chunk,
|
||||
media_files=media_files if is_last else [],
|
||||
thread_id=thread_id,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
return result
|
||||
last_result = result
|
||||
return last_result
|
||||
|
||||
# --- Non-Telegram platforms ---
|
||||
if media_files and not message.strip():
|
||||
return {
|
||||
"error": (
|
||||
@@ -289,28 +324,48 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
"native send_message media delivery is currently only supported for telegram"
|
||||
)
|
||||
|
||||
if platform == Platform.DISCORD:
|
||||
result = await _send_discord(pconfig.token, chat_id, message)
|
||||
elif platform == Platform.SLACK:
|
||||
result = await _send_slack(pconfig.token, chat_id, message)
|
||||
elif platform == Platform.SIGNAL:
|
||||
result = await _send_signal(pconfig.extra, chat_id, message)
|
||||
elif platform == Platform.EMAIL:
|
||||
result = await _send_email(pconfig.extra, chat_id, message)
|
||||
else:
|
||||
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
||||
last_result = None
|
||||
for chunk in chunks:
|
||||
if platform == Platform.DISCORD:
|
||||
result = await _send_discord(pconfig.token, chat_id, chunk)
|
||||
elif platform == Platform.SLACK:
|
||||
result = await _send_slack(pconfig.token, chat_id, chunk)
|
||||
elif platform == Platform.SIGNAL:
|
||||
result = await _send_signal(pconfig.extra, chat_id, chunk)
|
||||
elif platform == Platform.EMAIL:
|
||||
result = await _send_email(pconfig.extra, chat_id, chunk)
|
||||
else:
|
||||
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
||||
|
||||
if warning and isinstance(result, dict) and result.get("success"):
|
||||
warnings = list(result.get("warnings", []))
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
return result
|
||||
last_result = result
|
||||
|
||||
if warning and isinstance(last_result, dict) and last_result.get("success"):
|
||||
warnings = list(last_result.get("warnings", []))
|
||||
warnings.append(warning)
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
last_result["warnings"] = warnings
|
||||
return last_result
|
||||
|
||||
|
||||
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None):
|
||||
"""Send via Telegram Bot API (one-shot, no polling needed)."""
|
||||
"""Send via Telegram Bot API (one-shot, no polling needed).
|
||||
|
||||
Applies markdown→MarkdownV2 formatting (same as the gateway adapter)
|
||||
so that bold, links, and headers render correctly.
|
||||
"""
|
||||
try:
|
||||
from telegram import Bot
|
||||
from telegram.constants import ParseMode
|
||||
|
||||
# Reuse the gateway adapter's format_message for markdown→MarkdownV2
|
||||
try:
|
||||
from gateway.platforms.telegram import TelegramAdapter, _escape_mdv2, _strip_mdv2
|
||||
_adapter = TelegramAdapter.__new__(TelegramAdapter)
|
||||
formatted = _adapter.format_message(message)
|
||||
except Exception:
|
||||
# Fallback: send as-is if formatting unavailable
|
||||
formatted = message
|
||||
|
||||
bot = Bot(token=token)
|
||||
int_chat_id = int(chat_id)
|
||||
@@ -322,10 +377,27 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
|
||||
last_msg = None
|
||||
warnings = []
|
||||
|
||||
if message.strip():
|
||||
last_msg = await bot.send_message(
|
||||
chat_id=int_chat_id, text=message, **thread_kwargs
|
||||
)
|
||||
if formatted.strip():
|
||||
try:
|
||||
last_msg = await bot.send_message(
|
||||
chat_id=int_chat_id, text=formatted,
|
||||
parse_mode=ParseMode.MARKDOWN_V2, **thread_kwargs
|
||||
)
|
||||
except Exception as md_error:
|
||||
# MarkdownV2 failed, fall back to plain text
|
||||
if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
|
||||
logger.warning("MarkdownV2 parse failed in _send_telegram, falling back to plain text: %s", md_error)
|
||||
try:
|
||||
from gateway.platforms.telegram import _strip_mdv2
|
||||
plain = _strip_mdv2(formatted)
|
||||
except Exception:
|
||||
plain = message
|
||||
last_msg = await bot.send_message(
|
||||
chat_id=int_chat_id, text=plain,
|
||||
parse_mode=None, **thread_kwargs
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
for media_path, is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
@@ -384,7 +456,10 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
|
||||
|
||||
|
||||
async def _send_discord(token, chat_id, message):
|
||||
"""Send via Discord REST API (no websocket client needed)."""
|
||||
"""Send a single message via Discord REST API (no websocket client needed).
|
||||
|
||||
Chunking is handled by _send_to_platform() before this is called.
|
||||
"""
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
@@ -392,17 +467,13 @@ async def _send_discord(token, chat_id, message):
|
||||
try:
|
||||
url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
|
||||
headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"}
|
||||
chunks = [message[i:i+2000] for i in range(0, len(message), 2000)]
|
||||
message_ids = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for chunk in chunks:
|
||||
async with session.post(url, headers=headers, json={"content": chunk}) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord API error ({resp.status}): {body}"}
|
||||
data = await resp.json()
|
||||
message_ids.append(data.get("id"))
|
||||
return {"success": True, "platform": "discord", "chat_id": chat_id, "message_ids": message_ids}
|
||||
async with session.post(url, headers=headers, json={"content": message}) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord API error ({resp.status}): {body}"}
|
||||
data = await resp.json()
|
||||
return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": data.get("id")}
|
||||
except Exception as e:
|
||||
return {"error": f"Discord send failed: {e}"}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]:
|
||||
report = format_scan_report(result)
|
||||
return f"Security scan blocked this skill ({reason}):\n{report}"
|
||||
except Exception as e:
|
||||
logger.warning("Security scan failed for %s: %s", skill_dir, e)
|
||||
logger.warning("Security scan failed for %s: %s", skill_dir, e, exc_info=True)
|
||||
return None
|
||||
|
||||
import yaml
|
||||
@@ -219,7 +219,7 @@ def _atomic_write_text(file_path: Path, content: str, encoding: str = "utf-8") -
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
logger.error("Failed to remove temporary file %s during atomic write", temp_path, exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
@@ -873,6 +873,37 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# Security: warn if skill is loaded from outside the trusted skills directory
|
||||
try:
|
||||
skill_md.resolve().relative_to(SKILLS_DIR.resolve())
|
||||
_outside_skills_dir = False
|
||||
except ValueError:
|
||||
_outside_skills_dir = True
|
||||
|
||||
# Security: detect common prompt injection patterns
|
||||
_INJECTION_PATTERNS = [
|
||||
"ignore previous instructions",
|
||||
"ignore all previous",
|
||||
"you are now",
|
||||
"disregard your",
|
||||
"forget your instructions",
|
||||
"new instructions:",
|
||||
"system prompt:",
|
||||
"<system>",
|
||||
"]]>",
|
||||
]
|
||||
_content_lower = content.lower()
|
||||
_injection_detected = any(p in _content_lower for p in _INJECTION_PATTERNS)
|
||||
|
||||
if _outside_skills_dir or _injection_detected:
|
||||
_warnings = []
|
||||
if _outside_skills_dir:
|
||||
_warnings.append(f"skill file is outside the trusted skills directory (~/.hermes/skills/): {skill_md}")
|
||||
if _injection_detected:
|
||||
_warnings.append("skill content contains patterns that may indicate prompt injection")
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning("Skill security warning for '%s': %s", name, "; ".join(_warnings))
|
||||
|
||||
parsed_frontmatter: Dict[str, Any] = {}
|
||||
try:
|
||||
parsed_frontmatter, _ = _parse_frontmatter(content)
|
||||
|
||||
Reference in New Issue
Block a user