Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ed5eda2ad | |||
| 11aa44d34d | |||
| 07746dca0c | |||
| 7e0c2c3ce3 | |||
| 3c8f910973 | |||
| 13f3e67165 | |||
| 4a7c17fca5 | |||
| f007284d05 | |||
| 3d47af01c3 | |||
| 275fcc6673 | |||
| ab62614a89 | |||
| de368cac54 | |||
| 0d1003559d | |||
| eba8d52d54 | |||
| 72104eb06f | |||
| 4b35836ba4 | |||
| bd376fe976 | |||
| f93637b3a1 | |||
| 7b4fe0528f | |||
| 950f69475f | |||
| 7dac75f2ae | |||
| ed9af6e589 | |||
| 158f49f19a | |||
| 86250a3e45 | |||
| ea342f2382 | |||
| 60ecde8ac7 | |||
| f3069c649c | |||
| 0976bf6cd0 | |||
| da3e22bcfa | |||
| 9fd78c7a8e | |||
| 5ceed021dc |
@@ -231,6 +231,21 @@ VOICE_TOOLS_OPENAI_KEY=
|
||||
# Slack allowed users (comma-separated Slack user IDs)
|
||||
# SLACK_ALLOWED_USERS=
|
||||
|
||||
# =============================================================================
|
||||
# TELEGRAM INTEGRATION
|
||||
# =============================================================================
|
||||
# Telegram Bot Token - From @BotFather (https://t.me/BotFather)
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_ALLOWED_USERS= # Comma-separated user IDs
|
||||
# TELEGRAM_HOME_CHANNEL= # Default chat for cron delivery
|
||||
# TELEGRAM_HOME_CHANNEL_NAME= # Display name for home channel
|
||||
|
||||
# Webhook mode (optional — for cloud deployments like Fly.io/Railway)
|
||||
# Default is long polling. Setting TELEGRAM_WEBHOOK_URL switches to webhook mode.
|
||||
# TELEGRAM_WEBHOOK_URL=https://my-app.fly.dev/telegram
|
||||
# TELEGRAM_WEBHOOK_PORT=8443
|
||||
# TELEGRAM_WEBHOOK_SECRET= # Recommended for production
|
||||
|
||||
# WhatsApp (built-in Baileys bridge — run `hermes whatsapp` to pair)
|
||||
# WHATSAPP_ENABLED=false
|
||||
# WHATSAPP_ALLOWED_USERS=15551234567
|
||||
|
||||
@@ -162,6 +162,21 @@ def _is_oauth_token(key: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
||||
|
||||
Some third-party /anthropic endpoints implement Anthropic's Messages API but
|
||||
require Authorization: Bearer instead of Anthropic's native x-api-key header.
|
||||
MiniMax's global and China Anthropic-compatible endpoints follow this pattern.
|
||||
"""
|
||||
if not base_url:
|
||||
return False
|
||||
normalized = base_url.rstrip("/").lower()
|
||||
return normalized.startswith("https://api.minimax.io/anthropic") or normalized.startswith(
|
||||
"https://api.minimaxi.com/anthropic"
|
||||
)
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
@@ -180,7 +195,17 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
|
||||
if _is_oauth_token(api_key):
|
||||
if _requires_bearer_auth(base_url):
|
||||
# Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in
|
||||
# Authorization: Bearer even for regular API keys. Route those endpoints
|
||||
# through auth_token so the SDK sends Bearer auth instead of x-api-key.
|
||||
# Check this before OAuth token shape detection because MiniMax secrets do
|
||||
# not use Anthropic's sk-ant-api prefix and would otherwise be misread as
|
||||
# Anthropic OAuth/setup tokens.
|
||||
kwargs["auth_token"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
elif _is_oauth_token(api_key):
|
||||
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
||||
# Anthropic routes OAuth requests based on user-agent and headers;
|
||||
# without Claude Code's fingerprint, requests get intermittent 500s.
|
||||
|
||||
@@ -2837,6 +2837,28 @@ class HermesCLI:
|
||||
print(" Example: python cli.py --toolsets web,terminal")
|
||||
print()
|
||||
|
||||
def _handle_profile_command(self):
|
||||
"""Display active profile name and home directory."""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
display = display_hermes_home()
|
||||
|
||||
profiles_parent = Path.home() / ".hermes" / "profiles"
|
||||
try:
|
||||
rel = home.relative_to(profiles_parent)
|
||||
profile_name = str(rel).split("/")[0]
|
||||
except ValueError:
|
||||
profile_name = None
|
||||
|
||||
print()
|
||||
if profile_name:
|
||||
print(f" Profile: {profile_name}")
|
||||
else:
|
||||
print(" Profile: default")
|
||||
print(f" Home: {display}")
|
||||
print()
|
||||
|
||||
def show_config(self):
|
||||
"""Display current configuration with kawaii ASCII art."""
|
||||
# Get terminal config from environment (which was set from cli-config.yaml)
|
||||
@@ -3679,6 +3701,8 @@ class HermesCLI:
|
||||
return False
|
||||
elif canonical == "help":
|
||||
self.show_help()
|
||||
elif canonical == "profile":
|
||||
self._handle_profile_command()
|
||||
elif canonical == "tools":
|
||||
self._handle_tools_command(cmd_original)
|
||||
elif canonical == "toolsets":
|
||||
@@ -3836,6 +3860,8 @@ class HermesCLI:
|
||||
self.console.print(f" Status bar {state}")
|
||||
elif canonical == "verbose":
|
||||
self._toggle_verbose()
|
||||
elif canonical == "yolo":
|
||||
self._toggle_yolo()
|
||||
elif canonical == "reasoning":
|
||||
self._handle_reasoning_command(cmd_original)
|
||||
elif canonical == "compress":
|
||||
@@ -4434,6 +4460,17 @@ class HermesCLI:
|
||||
}
|
||||
_cprint(labels.get(self.tool_progress_mode, ""))
|
||||
|
||||
def _toggle_yolo(self):
|
||||
"""Toggle YOLO mode — skip all dangerous command approval prompts."""
|
||||
import os
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
self.console.print(" ⚠ YOLO mode [bold red]OFF[/] — dangerous commands will require approval.")
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
self.console.print(" ⚡ YOLO mode [bold green]ON[/] — all commands auto-approved. Use with caution.")
|
||||
|
||||
def _handle_reasoning_command(self, cmd: str):
|
||||
"""Handle /reasoning — manage effort level and display toggle.
|
||||
|
||||
@@ -5560,6 +5597,8 @@ class HermesCLI:
|
||||
self.agent = None
|
||||
|
||||
# Initialize agent if needed
|
||||
if self.agent is None:
|
||||
_cprint(f"{_DIM}Initializing agent...{_RST}")
|
||||
if not self._init_agent(
|
||||
model_override=turn_route["model"],
|
||||
runtime_override=turn_route["runtime"],
|
||||
|
||||
+9
-2
@@ -27,9 +27,16 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if lowered in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||
|
||||
+164
-4
@@ -49,6 +49,14 @@ _STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
|
||||
# E2EE key export file for persistence across restarts.
|
||||
_KEY_EXPORT_FILE = _STORE_DIR / "exported_keys.txt"
|
||||
_KEY_EXPORT_PASSPHRASE = "hermes-matrix-e2ee-keys"
|
||||
|
||||
# Pending undecrypted events: cap and TTL for retry buffer.
|
||||
_MAX_PENDING_EVENTS = 100
|
||||
_PENDING_EVENT_TTL = 300 # seconds — stop retrying after 5 min
|
||||
|
||||
|
||||
def check_matrix_requirements() -> bool:
|
||||
"""Return True if the Matrix adapter can be used."""
|
||||
@@ -111,6 +119,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._processed_events: deque = deque(maxlen=1000)
|
||||
self._processed_events_set: set = set()
|
||||
|
||||
# Buffer for undecrypted events pending key receipt.
|
||||
# Each entry: (room, event, timestamp)
|
||||
self._pending_megolm: list = []
|
||||
|
||||
def _is_duplicate_event(self, event_id) -> bool:
|
||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||
if not event_id:
|
||||
@@ -232,6 +244,16 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.info("Matrix: E2EE crypto initialized")
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: crypto init issue: %s", exc)
|
||||
|
||||
# Import previously exported Megolm keys (survives restarts).
|
||||
if _KEY_EXPORT_FILE.exists():
|
||||
try:
|
||||
await client.import_keys(
|
||||
str(_KEY_EXPORT_FILE), _KEY_EXPORT_PASSPHRASE,
|
||||
)
|
||||
logger.info("Matrix: imported Megolm keys from backup")
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: could not import keys: %s", exc)
|
||||
elif self._encryption:
|
||||
logger.warning(
|
||||
"Matrix: E2EE requested but crypto store is not loaded; "
|
||||
@@ -286,6 +308,18 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# Export Megolm keys before closing so the next restart can decrypt
|
||||
# events that used sessions from this run.
|
||||
if self._client and self._encryption and getattr(self._client, "olm", None):
|
||||
try:
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
await self._client.export_keys(
|
||||
str(_KEY_EXPORT_FILE), _KEY_EXPORT_PASSPHRASE,
|
||||
)
|
||||
logger.info("Matrix: exported Megolm keys for next restart")
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: could not export keys on disconnect: %s", exc)
|
||||
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
@@ -665,17 +699,22 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
Hermes uses a custom sync loop instead of matrix-nio's sync_forever(),
|
||||
so we need to explicitly drive the key management work that sync_forever()
|
||||
normally handles for encrypted rooms.
|
||||
|
||||
Also auto-trusts all devices (so senders share session keys with us)
|
||||
and retries decryption for any buffered MegolmEvents.
|
||||
"""
|
||||
client = self._client
|
||||
if not client or not self._encryption or not getattr(client, "olm", None):
|
||||
return
|
||||
|
||||
did_query_keys = client.should_query_keys
|
||||
|
||||
tasks = [asyncio.create_task(client.send_to_device_messages())]
|
||||
|
||||
if client.should_upload_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_upload()))
|
||||
|
||||
if client.should_query_keys:
|
||||
if did_query_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_query()))
|
||||
|
||||
if client.should_claim_keys:
|
||||
@@ -691,6 +730,111 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: E2EE maintenance task failed: %s", exc)
|
||||
|
||||
# After key queries, auto-trust all devices so senders share keys with
|
||||
# us. For a bot this is the right default — we want to decrypt
|
||||
# everything, not enforce manual verification.
|
||||
if did_query_keys:
|
||||
self._auto_trust_devices()
|
||||
|
||||
# Retry any buffered undecrypted events now that new keys may have
|
||||
# arrived (from key requests, key queries, or to-device forwarding).
|
||||
if self._pending_megolm:
|
||||
await self._retry_pending_decryptions()
|
||||
|
||||
def _auto_trust_devices(self) -> None:
|
||||
"""Trust/verify all unverified devices we know about.
|
||||
|
||||
When other clients see our device as verified, they proactively share
|
||||
Megolm session keys with us. Without this, many clients will refuse
|
||||
to include an unverified device in key distributions.
|
||||
"""
|
||||
client = self._client
|
||||
if not client:
|
||||
return
|
||||
|
||||
device_store = getattr(client, "device_store", None)
|
||||
if not device_store:
|
||||
return
|
||||
|
||||
own_device = getattr(client, "device_id", None)
|
||||
trusted_count = 0
|
||||
|
||||
try:
|
||||
# DeviceStore.__iter__ yields OlmDevice objects directly.
|
||||
for device in device_store:
|
||||
if getattr(device, "device_id", None) == own_device:
|
||||
continue
|
||||
if not getattr(device, "verified", False):
|
||||
client.verify_device(device)
|
||||
trusted_count += 1
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: auto-trust error: %s", exc)
|
||||
|
||||
if trusted_count:
|
||||
logger.info("Matrix: auto-trusted %d new device(s)", trusted_count)
|
||||
|
||||
async def _retry_pending_decryptions(self) -> None:
|
||||
"""Retry decrypting buffered MegolmEvents after new keys arrive."""
|
||||
import nio
|
||||
|
||||
client = self._client
|
||||
if not client or not self._pending_megolm:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
still_pending: list = []
|
||||
|
||||
for room, event, ts in self._pending_megolm:
|
||||
# Drop events that have aged past the TTL.
|
||||
if now - ts > _PENDING_EVENT_TTL:
|
||||
logger.debug(
|
||||
"Matrix: dropping expired pending event %s (age %.0fs)",
|
||||
getattr(event, "event_id", "?"), now - ts,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
decrypted = client.decrypt_event(event)
|
||||
except Exception:
|
||||
# Still missing the key — keep in buffer.
|
||||
still_pending.append((room, event, ts))
|
||||
continue
|
||||
|
||||
if isinstance(decrypted, nio.MegolmEvent):
|
||||
# decrypt_event returned the same undecryptable event.
|
||||
still_pending.append((room, event, ts))
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Matrix: decrypted buffered event %s (%s)",
|
||||
getattr(event, "event_id", "?"),
|
||||
type(decrypted).__name__,
|
||||
)
|
||||
|
||||
# Route to the appropriate handler based on decrypted type.
|
||||
try:
|
||||
if isinstance(decrypted, nio.RoomMessageText):
|
||||
await self._on_room_message(room, decrypted)
|
||||
elif isinstance(
|
||||
decrypted,
|
||||
(nio.RoomMessageImage, nio.RoomMessageAudio,
|
||||
nio.RoomMessageVideo, nio.RoomMessageFile),
|
||||
):
|
||||
await self._on_room_message_media(room, decrypted)
|
||||
else:
|
||||
logger.debug(
|
||||
"Matrix: decrypted event %s has unhandled type %s",
|
||||
getattr(event, "event_id", "?"),
|
||||
type(decrypted).__name__,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Matrix: error processing decrypted event %s: %s",
|
||||
getattr(event, "event_id", "?"), exc,
|
||||
)
|
||||
|
||||
self._pending_megolm = still_pending
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callbacks
|
||||
# ------------------------------------------------------------------
|
||||
@@ -712,13 +856,29 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
|
||||
return
|
||||
|
||||
# Handle decrypted MegolmEvents — extract the inner event.
|
||||
# Handle undecryptable MegolmEvents: request the missing session key
|
||||
# and buffer the event for retry once the key arrives.
|
||||
if isinstance(event, nio.MegolmEvent):
|
||||
# Failed to decrypt.
|
||||
logger.warning(
|
||||
"Matrix: could not decrypt event %s in %s",
|
||||
"Matrix: could not decrypt event %s in %s — requesting key",
|
||||
event.event_id, room.room_id,
|
||||
)
|
||||
|
||||
# Ask other devices in the room to forward the session key.
|
||||
try:
|
||||
resp = await self._client.request_room_key(event)
|
||||
if hasattr(resp, "event_id") or not isinstance(resp, Exception):
|
||||
logger.debug(
|
||||
"Matrix: room key request sent for session %s",
|
||||
getattr(event, "session_id", "?"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: room key request failed: %s", exc)
|
||||
|
||||
# Buffer for retry on next maintenance cycle.
|
||||
self._pending_megolm.append((room, event, time.time()))
|
||||
if len(self._pending_megolm) > _MAX_PENDING_EVENTS:
|
||||
self._pending_megolm = self._pending_megolm[-_MAX_PENDING_EVENTS:]
|
||||
return
|
||||
|
||||
# Skip edits (m.replace relation).
|
||||
|
||||
@@ -622,10 +622,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# gateway command there automatically adds it to the Telegram menu.
|
||||
try:
|
||||
from telegram import BotCommand
|
||||
from hermes_cli.commands import telegram_bot_commands
|
||||
from hermes_cli.commands import telegram_menu_commands
|
||||
# Telegram allows up to 100 commands but has an undocumented
|
||||
# payload size limit. Skill descriptions are truncated to 40
|
||||
# chars in telegram_menu_commands() to fit 100 commands safely.
|
||||
menu_commands, hidden_count = telegram_menu_commands(max_commands=100)
|
||||
await self._bot.set_my_commands([
|
||||
BotCommand(name, desc) for name, desc in telegram_bot_commands()
|
||||
BotCommand(name, desc) for name, desc in menu_commands
|
||||
])
|
||||
if hidden_count:
|
||||
logger.info(
|
||||
"[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.",
|
||||
self.name, len(menu_commands), hidden_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Could not register Telegram command menu: %s",
|
||||
|
||||
+205
-4
@@ -301,6 +301,50 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _check_unavailable_skill(command_name: str) -> str | None:
|
||||
"""Check if a command matches a known-but-inactive skill.
|
||||
|
||||
Returns a helpful message if the skill exists but is disabled or only
|
||||
available as an optional install. Returns None if no match found.
|
||||
"""
|
||||
# Normalize: command uses hyphens, skill names may use hyphens or underscores
|
||||
normalized = command_name.lower().replace("_", "-")
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _get_disabled_skill_names
|
||||
disabled = _get_disabled_skill_names()
|
||||
|
||||
# Check disabled built-in skills
|
||||
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
continue
|
||||
name = skill_md.parent.name.lower().replace("_", "-")
|
||||
if name == normalized and name in disabled:
|
||||
return (
|
||||
f"The **{command_name}** skill is installed but disabled.\n"
|
||||
f"Enable it with: `hermes skills config`"
|
||||
)
|
||||
|
||||
# Check optional skills (shipped with repo but not installed)
|
||||
from hermes_constants import get_hermes_home
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
optional_dir = repo_root / "optional-skills"
|
||||
if optional_dir.exists():
|
||||
for skill_md in optional_dir.rglob("SKILL.md"):
|
||||
name = skill_md.parent.name.lower().replace("_", "-")
|
||||
if name == normalized:
|
||||
# Build install path: official/<category>/<name>
|
||||
rel = skill_md.parent.relative_to(optional_dir)
|
||||
parts = list(rel.parts)
|
||||
install_path = f"official/{'/'.join(parts)}"
|
||||
return (
|
||||
f"The **{command_name}** skill is available but not installed.\n"
|
||||
f"Install it with: `hermes skills install {install_path}`"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _platform_config_key(platform: "Platform") -> str:
|
||||
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
|
||||
return "cli" if platform == Platform.LOCAL else platform.value
|
||||
@@ -432,6 +476,13 @@ class GatewayRunner:
|
||||
self._honcho_managers: Dict[str, Any] = {}
|
||||
self._honcho_configs: Dict[str, Any] = {}
|
||||
|
||||
# Rate-limit compression warning messages sent to users.
|
||||
# Keyed by chat_id — value is the timestamp of the last warning sent.
|
||||
# Prevents the warning from firing on every message when a session
|
||||
# remains above the threshold after compression.
|
||||
self._compression_warn_sent: Dict[str, float] = {}
|
||||
self._compression_warn_cooldown: int = 3600 # seconds (1 hour)
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
@@ -1651,6 +1702,11 @@ class GatewayRunner:
|
||||
# In DMs: offer pairing code. In groups: silently ignore.
|
||||
if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
|
||||
platform_name = source.platform.value if source.platform else "unknown"
|
||||
# Rate-limit ALL pairing responses (code or rejection) to
|
||||
# prevent spamming the user with repeated messages when
|
||||
# multiple DMs arrive in quick succession.
|
||||
if self.pairing_store._is_rate_limited(platform_name, source.user_id):
|
||||
return None
|
||||
code = self.pairing_store.generate_code(
|
||||
platform_name, source.user_id, source.user_name or ""
|
||||
)
|
||||
@@ -1672,6 +1728,8 @@ class GatewayRunner:
|
||||
"Too many pairing requests right now~ "
|
||||
"Please try again later!"
|
||||
)
|
||||
# Record rate limit so subsequent messages are silently ignored
|
||||
self.pairing_store._record_rate_limit(platform_name, source.user_id)
|
||||
return None
|
||||
|
||||
# PRIORITY handling when an agent is already running for this session.
|
||||
@@ -1817,7 +1875,13 @@ class GatewayRunner:
|
||||
|
||||
if canonical == "help":
|
||||
return await self._handle_help_command(event)
|
||||
|
||||
if canonical == "commands":
|
||||
return await self._handle_commands_command(event)
|
||||
|
||||
if canonical == "profile":
|
||||
return await self._handle_profile_command(event)
|
||||
|
||||
if canonical == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
@@ -1830,6 +1894,9 @@ class GatewayRunner:
|
||||
if canonical == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
|
||||
if canonical == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
|
||||
if canonical == "provider":
|
||||
return await self._handle_provider_command(event)
|
||||
|
||||
@@ -1974,6 +2041,12 @@ class GatewayRunner:
|
||||
if msg:
|
||||
event.text = msg
|
||||
# Fall through to normal message processing with skill content
|
||||
else:
|
||||
# Not an active skill — check if it's a known-but-disabled or
|
||||
# uninstalled skill and give actionable guidance.
|
||||
_unavail_msg = _check_unavailable_skill(command)
|
||||
if _unavail_msg:
|
||||
return _unavail_msg
|
||||
except Exception as e:
|
||||
logger.debug("Skill command check failed (non-fatal): %s", e)
|
||||
|
||||
@@ -2211,6 +2284,29 @@ class GatewayRunner:
|
||||
_hyg_api_key = _hyg_runtime.get("api_key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check custom_providers per-model context_length
|
||||
# (same fallback as run_agent.py lines 1171-1189).
|
||||
# Must run after runtime resolution so _hyg_base_url is set.
|
||||
if _hyg_config_context_length is None and _hyg_base_url:
|
||||
try:
|
||||
_hyg_custom_providers = _hyg_data.get("custom_providers")
|
||||
if isinstance(_hyg_custom_providers, list):
|
||||
for _cp in _hyg_custom_providers:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_cp_url = (_cp.get("base_url") or "").rstrip("/")
|
||||
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
|
||||
_cp_models = _cp.get("models", {})
|
||||
if isinstance(_cp_models, dict):
|
||||
_cp_model_cfg = _cp_models.get(_hyg_model, {})
|
||||
if isinstance(_cp_model_cfg, dict):
|
||||
_cp_ctx = _cp_model_cfg.get("context_length")
|
||||
if _cp_ctx is not None:
|
||||
_hyg_config_context_length = int(_cp_ctx)
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2344,13 +2440,18 @@ class GatewayRunner:
|
||||
pass
|
||||
|
||||
# Still too large after compression — warn user
|
||||
# Rate-limited to once per cooldown period per
|
||||
# chat to avoid spamming on every message.
|
||||
if _new_tokens >= _warn_token_threshold:
|
||||
logger.warning(
|
||||
"Session hygiene: still ~%s tokens after "
|
||||
"compression — suggesting /reset",
|
||||
f"{_new_tokens:,}",
|
||||
)
|
||||
if _hyg_adapter:
|
||||
_now = time.time()
|
||||
_last_warn = self._compression_warn_sent.get(source.chat_id, 0)
|
||||
if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown:
|
||||
self._compression_warn_sent[source.chat_id] = _now
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
@@ -2372,7 +2473,10 @@ class GatewayRunner:
|
||||
if _approx_tokens >= _warn_token_threshold:
|
||||
_hyg_adapter = self.adapters.get(source.platform)
|
||||
_hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
if _hyg_adapter:
|
||||
_now = time.time()
|
||||
_last_warn = self._compression_warn_sent.get(source.chat_id, 0)
|
||||
if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown:
|
||||
self._compression_warn_sent[source.chat_id] = _now
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
@@ -2999,6 +3103,36 @@ class GatewayRunner:
|
||||
return f"{header}\n\n{session_info}"
|
||||
return header
|
||||
|
||||
async def _handle_profile_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /profile — show active profile name and home directory."""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
from pathlib import Path
|
||||
|
||||
home = get_hermes_home()
|
||||
display = display_hermes_home()
|
||||
|
||||
# Detect profile name from HERMES_HOME path
|
||||
# Profile paths look like: ~/.hermes/profiles/<name>
|
||||
profiles_parent = Path.home() / ".hermes" / "profiles"
|
||||
try:
|
||||
rel = home.relative_to(profiles_parent)
|
||||
profile_name = str(rel).split("/")[0]
|
||||
except ValueError:
|
||||
profile_name = None
|
||||
|
||||
if profile_name:
|
||||
lines = [
|
||||
f"👤 **Profile:** `{profile_name}`",
|
||||
f"📂 **Home:** `{display}`",
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
"👤 **Profile:** default",
|
||||
f"📂 **Home:** `{display}`",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_status_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /status command."""
|
||||
source = event.source
|
||||
@@ -3065,12 +3199,69 @@ class GatewayRunner:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
skill_cmds = get_skill_commands()
|
||||
if skill_cmds:
|
||||
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):")
|
||||
for cmd in sorted(skill_cmds):
|
||||
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):")
|
||||
# Show first 10, then point to /commands for the rest
|
||||
sorted_cmds = sorted(skill_cmds)
|
||||
for cmd in sorted_cmds[:10]:
|
||||
lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}")
|
||||
if len(sorted_cmds) > 10:
|
||||
lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.")
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_commands_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /commands [page] - paginated list of all commands and skills."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
if raw_args:
|
||||
try:
|
||||
requested_page = int(raw_args)
|
||||
except ValueError:
|
||||
return "Usage: `/commands [page]`"
|
||||
else:
|
||||
requested_page = 1
|
||||
|
||||
# Build combined entry list: built-in commands + skill commands
|
||||
entries = list(gateway_help_lines())
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
skill_cmds = get_skill_commands()
|
||||
if skill_cmds:
|
||||
entries.append("")
|
||||
entries.append("⚡ **Skill Commands**:")
|
||||
for cmd in sorted(skill_cmds):
|
||||
desc = skill_cmds[cmd].get("description", "").strip() or "Skill command"
|
||||
entries.append(f"`{cmd}` — {desc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not entries:
|
||||
return "No commands available."
|
||||
|
||||
from gateway.config import Platform
|
||||
page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
|
||||
total_pages = max(1, (len(entries) + page_size - 1) // page_size)
|
||||
page = max(1, min(requested_page, total_pages))
|
||||
start = (page - 1) * page_size
|
||||
page_entries = entries[start:start + page_size]
|
||||
|
||||
lines = [
|
||||
f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})",
|
||||
"",
|
||||
*page_entries,
|
||||
]
|
||||
if total_pages > 1:
|
||||
nav_parts = []
|
||||
if page > 1:
|
||||
nav_parts.append(f"`/commands {page - 1}` ← prev")
|
||||
if page < total_pages:
|
||||
nav_parts.append(f"next → `/commands {page + 1}`")
|
||||
lines.extend(["", " | ".join(nav_parts)])
|
||||
if page != requested_page:
|
||||
lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_provider_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /provider command - show available providers."""
|
||||
@@ -3999,6 +4190,16 @@ class GatewayRunner:
|
||||
else:
|
||||
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass."""
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
return "⚠️ YOLO mode **OFF** — dangerous commands will require approval."
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
return "⚡ YOLO mode **ON** — all commands auto-approved. Use with caution."
|
||||
|
||||
async def _handle_verbose_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /verbose command — cycle tool progress display mode.
|
||||
|
||||
|
||||
+18
-2
@@ -1,8 +1,24 @@
|
||||
"""Shared ANSI color utilities for Hermes CLI modules."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def should_use_color() -> bool:
|
||||
"""Return True when colored output is appropriate.
|
||||
|
||||
Respects the NO_COLOR environment variable (https://no-color.org/)
|
||||
and TERM=dumb, in addition to the existing TTY check.
|
||||
"""
|
||||
if os.environ.get("NO_COLOR") is not None:
|
||||
return False
|
||||
if os.environ.get("TERM") == "dumb":
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
@@ -16,7 +32,7 @@ class Colors:
|
||||
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
"""Apply color codes to text (only when output is a TTY)."""
|
||||
if not sys.stdout.isatty():
|
||||
"""Apply color codes to text (only when color output is appropriate)."""
|
||||
if not should_use_color():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
@@ -71,6 +71,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
aliases=("q",), args_hint="<prompt>"),
|
||||
CommandDef("status", "Show session info", "Session",
|
||||
gateway_only=True),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
gateway_only=True, aliases=("set-home",)),
|
||||
CommandDef("resume", "Resume a previously-named session", "Session",
|
||||
@@ -90,6 +91,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||
"Configuration", cli_only=True,
|
||||
gateway_config_gate="display.tool_progress_command"),
|
||||
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
||||
"Configuration"),
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
args_hint="[level|show|hide]",
|
||||
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||
@@ -118,6 +121,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
"Tools & Skills", cli_only=True),
|
||||
|
||||
# Info
|
||||
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
||||
gateway_only=True, args_hint="[page]"),
|
||||
CommandDef("help", "Show available commands", "Info"),
|
||||
CommandDef("usage", "Show token usage for the current session", "Info"),
|
||||
CommandDef("insights", "Show usage insights and analytics", "Info",
|
||||
@@ -361,6 +366,69 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
return result
|
||||
|
||||
|
||||
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
|
||||
"""Return Telegram menu commands capped to the Bot API limit.
|
||||
|
||||
Priority order (higher priority = never bumped by overflow):
|
||||
1. Core CommandDef commands (always included)
|
||||
2. Plugin slash commands (take precedence over skills)
|
||||
3. Built-in skill commands (fill remaining slots, alphabetical)
|
||||
|
||||
Skills are the only tier that gets trimmed when the cap is hit.
|
||||
User-installed hub skills are excluded — accessible via /skills.
|
||||
|
||||
Returns:
|
||||
(menu_commands, hidden_count) where hidden_count is the number of
|
||||
skill commands omitted due to the cap.
|
||||
"""
|
||||
all_commands = list(telegram_bot_commands())
|
||||
|
||||
# Plugin slash commands get priority over skills
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
plugin_cmds = getattr(pm, "_plugin_commands", {})
|
||||
for cmd_name in sorted(plugin_cmds):
|
||||
tg_name = cmd_name.replace("-", "_")
|
||||
desc = "Plugin command"
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
all_commands.append((tg_name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remaining slots go to built-in skill commands (not hub-installed).
|
||||
skill_entries: list[tuple[str, str]] = []
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
_skills_dir = str(SKILLS_DIR.resolve())
|
||||
_hub_dir = str((SKILLS_DIR / ".hub").resolve())
|
||||
skill_cmds = get_skill_commands()
|
||||
for cmd_key in sorted(skill_cmds):
|
||||
info = skill_cmds[cmd_key]
|
||||
skill_path = info.get("skill_md_path", "")
|
||||
if not skill_path.startswith(_skills_dir):
|
||||
continue
|
||||
if skill_path.startswith(_hub_dir):
|
||||
continue
|
||||
name = cmd_key.lstrip("/").replace("-", "_")
|
||||
desc = info.get("description", "")
|
||||
# Keep descriptions short — setMyCommands has an undocumented
|
||||
# total payload limit. 40 chars fits 100 commands safely.
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
skill_entries.append((name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Skills fill remaining slots — they're the only tier that gets trimmed
|
||||
remaining_slots = max(0, max_commands - len(all_commands))
|
||||
hidden_count = max(0, len(skill_entries) - remaining_slots)
|
||||
all_commands.extend(skill_entries[:remaining_slots])
|
||||
return all_commands[:max_commands], hidden_count
|
||||
|
||||
|
||||
def slack_subcommand_map() -> dict[str, str]:
|
||||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||||
|
||||
|
||||
@@ -706,6 +706,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"CAMOFOX_URL": {
|
||||
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
|
||||
"prompt": "Camofox server URL",
|
||||
"url": "https://github.com/jo-inc/camofox-browser",
|
||||
"tools": ["browser_navigate", "browser_click"],
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"FAL_KEY": {
|
||||
"description": "FAL API key for image generation",
|
||||
"prompt": "FAL API key",
|
||||
|
||||
+15
-8
@@ -406,8 +406,11 @@ def run_doctor(args):
|
||||
if terminal_env == "docker":
|
||||
if shutil.which("docker"):
|
||||
# Check if docker daemon is running
|
||||
result = subprocess.run(["docker", "info"], capture_output=True)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = None
|
||||
if result is not None and result.returncode == 0:
|
||||
check_ok("docker", "(daemon running)")
|
||||
else:
|
||||
check_fail("docker daemon not running")
|
||||
@@ -426,12 +429,16 @@ def run_doctor(args):
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST")
|
||||
if ssh_host:
|
||||
# Try to connect
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = None
|
||||
if result is not None and result.returncode == 0:
|
||||
check_ok(f"SSH connection to {ssh_host}")
|
||||
else:
|
||||
check_fail(f"SSH connection to {ssh_host}")
|
||||
|
||||
+4
-2
@@ -601,13 +601,15 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||
).exists()
|
||||
)
|
||||
if get_env_value("BROWSERBASE_API_KEY"):
|
||||
if get_env_value("CAMOFOX_URL"):
|
||||
tool_status.append(("Browser Automation (Camofox)", True, None))
|
||||
elif get_env_value("BROWSERBASE_API_KEY"):
|
||||
tool_status.append(("Browser Automation (Browserbase)", True, None))
|
||||
elif _ab_found:
|
||||
tool_status.append(("Browser Automation (local)", True, None))
|
||||
else:
|
||||
tool_status.append(
|
||||
("Browser Automation", False, "npm install -g agent-browser")
|
||||
("Browser Automation", False, "npm install -g agent-browser or set CAMOFOX_URL")
|
||||
)
|
||||
|
||||
# FAL (image generation)
|
||||
|
||||
+20
-12
@@ -285,23 +285,31 @@ def show_status(args):
|
||||
_gw_svc = get_service_name()
|
||||
except Exception:
|
||||
_gw_svc = "hermes-gateway"
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", _gw_svc],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", _gw_svc],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
except subprocess.TimeoutExpired:
|
||||
is_active = False
|
||||
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
elif sys.platform == 'darwin':
|
||||
from hermes_cli.gateway import get_launchd_label
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
is_loaded = False
|
||||
print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}")
|
||||
print(" Manager: launchd")
|
||||
else:
|
||||
|
||||
@@ -273,6 +273,16 @@ TOOL_CATEGORIES = {
|
||||
"browser_provider": "browser-use",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
{
|
||||
"name": "Camofox",
|
||||
"tag": "Local anti-detection browser (Firefox/Camoufox)",
|
||||
"env_vars": [
|
||||
{"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377",
|
||||
"url": "https://github.com/jo-inc/camofox-browser"},
|
||||
],
|
||||
"browser_provider": "camofox",
|
||||
"post_setup": "camofox",
|
||||
},
|
||||
],
|
||||
},
|
||||
"homeassistant": {
|
||||
@@ -337,6 +347,28 @@ def _run_post_setup(post_setup_key: str):
|
||||
elif not node_modules.exists():
|
||||
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
|
||||
|
||||
elif post_setup_key == "camofox":
|
||||
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camoufox-browser"
|
||||
if not camofox_dir.exists() and shutil.which("npm"):
|
||||
_print_info(" Installing Camofox browser server...")
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["npm", "install", "--silent"],
|
||||
capture_output=True, text=True, cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" Camofox installed")
|
||||
else:
|
||||
_print_warning(" npm install failed - run manually: npm install")
|
||||
if camofox_dir.exists():
|
||||
_print_info(" Start the Camofox server:")
|
||||
_print_info(" npx @askjo/camoufox-browser")
|
||||
_print_info(" First run downloads the Camoufox engine (~300MB)")
|
||||
_print_info(" Or use Docker: docker run -p 9377:9377 jo-inc/camofox-browser")
|
||||
elif not shutil.which("npm"):
|
||||
_print_warning(" Node.js not found. Install Camofox via Docker:")
|
||||
_print_info(" docker run -p 9377:9377 jo-inc/camofox-browser")
|
||||
|
||||
elif post_setup_key == "rl_training":
|
||||
try:
|
||||
__import__("tinker_atropos")
|
||||
@@ -565,7 +597,9 @@ def _toolset_has_keys(ts_key: str) -> bool:
|
||||
if cat:
|
||||
for provider in cat.get("providers", []):
|
||||
env_vars = provider.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(e["key"]) for e in env_vars):
|
||||
if not env_vars:
|
||||
return True # No-key provider (e.g. Local Browser, Edge TTS)
|
||||
if all(get_env_value(e["key"]) for e in env_vars):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -10,16 +10,27 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
|
||||
|
||||
HOST = "hermes"
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
"""Return the active Honcho config path (instance-local or global)."""
|
||||
"""Return the active Honcho config path for reading (instance-local or global)."""
|
||||
return resolve_config_path()
|
||||
|
||||
|
||||
def _local_config_path() -> Path:
|
||||
"""Return the instance-local Honcho config path for writing.
|
||||
|
||||
Always returns $HERMES_HOME/honcho.json so each profile/instance gets
|
||||
its own config file. The global ~/.honcho/config.json is only used as
|
||||
a read fallback (via resolve_config_path) for cross-app interop.
|
||||
"""
|
||||
return get_hermes_home() / "honcho.json"
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
path = _config_path()
|
||||
if path.exists():
|
||||
@@ -31,7 +42,7 @@ def _read_config() -> dict:
|
||||
|
||||
|
||||
def _write_config(cfg: dict, path: Path | None = None) -> None:
|
||||
path = path or _config_path()
|
||||
path = path or _local_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
|
||||
@@ -95,13 +106,13 @@ def cmd_setup(args) -> None:
|
||||
"""Interactive Honcho setup wizard."""
|
||||
cfg = _read_config()
|
||||
|
||||
active_path = _config_path()
|
||||
write_path = _local_config_path()
|
||||
read_path = _config_path()
|
||||
print("\nHoncho memory setup\n" + "─" * 40)
|
||||
print(" Honcho gives Hermes persistent cross-session memory.")
|
||||
if active_path != GLOBAL_CONFIG_PATH:
|
||||
print(f" Instance config: {active_path}")
|
||||
else:
|
||||
print(" Config is shared with other hosts at ~/.honcho/config.json")
|
||||
print(f" Config: {write_path}")
|
||||
if read_path != write_path and read_path.exists():
|
||||
print(f" (seeding from existing config at {read_path})")
|
||||
print()
|
||||
|
||||
if not _ensure_sdk_installed():
|
||||
@@ -189,7 +200,7 @@ def cmd_setup(args) -> None:
|
||||
hermes_host.setdefault("saveMessages", True)
|
||||
|
||||
_write_config(cfg)
|
||||
print(f"\n Config written to {active_path}")
|
||||
print(f"\n Config written to {write_path}")
|
||||
|
||||
# Test connection
|
||||
print(" Testing connection... ", end="", flush=True)
|
||||
@@ -237,6 +248,7 @@ def cmd_status(args) -> None:
|
||||
cfg = _read_config()
|
||||
|
||||
active_path = _config_path()
|
||||
write_path = _local_config_path()
|
||||
|
||||
if not cfg:
|
||||
print(f" No Honcho config found at {active_path}")
|
||||
@@ -259,6 +271,8 @@ def cmd_status(args) -> None:
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
print(f" Host: {hcfg.host}")
|
||||
print(f" Config path: {active_path}")
|
||||
if write_path != active_path:
|
||||
print(f" Write path: {write_path} (instance-local)")
|
||||
print(f" AI peer: {hcfg.ai_peer}")
|
||||
print(f" User peer: {hcfg.peer_name or 'not set'}")
|
||||
print(f" Session key: {hcfg.resolve_session_name()}")
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
},
|
||||
"homepage": "https://github.com/NousResearch/Hermes-Agent#readme",
|
||||
"dependencies": {
|
||||
"agent-browser": "^0.13.0"
|
||||
"agent-browser": "^0.13.0",
|
||||
"@askjo/camoufox-browser": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
+12
-5
@@ -5221,11 +5221,8 @@ class AIAgent:
|
||||
except Exception as e:
|
||||
logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
|
||||
|
||||
# Reset context pressure warning and token estimate — usage drops
|
||||
# after compaction. Without this, the stale last_prompt_tokens from
|
||||
# the previous API call causes the pressure calculation to stay at
|
||||
# >1000% and spam warnings / re-trigger compression in a loop.
|
||||
self._context_pressure_warned = False
|
||||
# Update token estimate after compaction so pressure calculations
|
||||
# use the post-compression count, not the stale pre-compression one.
|
||||
_compressed_est = (
|
||||
estimate_tokens_rough(new_system_prompt)
|
||||
+ estimate_messages_tokens_rough(compressed)
|
||||
@@ -5233,6 +5230,16 @@ class AIAgent:
|
||||
self.context_compressor.last_prompt_tokens = _compressed_est
|
||||
self.context_compressor.last_completion_tokens = 0
|
||||
|
||||
# Only reset the pressure warning if compression actually brought
|
||||
# us below the warning level (85% of threshold). When compression
|
||||
# can't reduce enough (e.g. threshold is very low, or system prompt
|
||||
# alone exceeds the warning level), keep the flag set to prevent
|
||||
# spamming the user with repeated warnings every loop iteration.
|
||||
if self.context_compressor.threshold_tokens > 0:
|
||||
_post_progress = _compressed_est / self.context_compressor.threshold_tokens
|
||||
if _post_progress < 0.85:
|
||||
self._context_pressure_warned = False
|
||||
|
||||
return compressed, new_system_prompt
|
||||
|
||||
def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
|
||||
+15
-3
@@ -94,7 +94,7 @@ print_banner() {
|
||||
echo ""
|
||||
echo -e "${MAGENTA}${BOLD}"
|
||||
echo "┌─────────────────────────────────────────────────────────┐"
|
||||
echo "│ ⚕ Hermes Agent Installer │"
|
||||
echo "│ ⚕ Hermes Agent Installer │"
|
||||
echo "├─────────────────────────────────────────────────────────┤"
|
||||
echo "│ An open source AI agent by Nous Research. │"
|
||||
echo "└─────────────────────────────────────────────────────────┘"
|
||||
@@ -699,14 +699,19 @@ install_deps() {
|
||||
|
||||
# Install the main package in editable mode with all extras.
|
||||
# Try [all] first, fall back to base install if extras have issues.
|
||||
if ! $UV_CMD pip install -e ".[all]" 2>/dev/null; then
|
||||
ALL_INSTALL_LOG=$(mktemp)
|
||||
if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then
|
||||
log_warn "Full install (.[all]) failed, trying base install..."
|
||||
log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)"
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
if ! $UV_CMD pip install -e "."; then
|
||||
log_error "Package installation failed."
|
||||
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
fi
|
||||
|
||||
log_success "Main package installed"
|
||||
@@ -1070,7 +1075,14 @@ print_success() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}"
|
||||
echo ""
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
|
||||
if [ "$LOGIN_SHELL" = "zsh" ]; then
|
||||
echo " source ~/.zshrc"
|
||||
elif [ "$LOGIN_SHELL" = "bash" ]; then
|
||||
echo " source ~/.bashrc"
|
||||
else
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Show Node.js warning if auto-install failed
|
||||
|
||||
@@ -744,3 +744,149 @@ class PixelBlendStack:
|
||||
result = blend_canvas(result, canvas, mode, opacity)
|
||||
return result
|
||||
```
|
||||
|
||||
## Text Backdrop (Readability Mask)
|
||||
|
||||
When placing readable text over busy multi-grid ASCII backgrounds, the text will blend into the background and become illegible. **Always apply a dark backdrop behind text regions.**
|
||||
|
||||
The technique: compute the bounding box of all text glyphs, create a gaussian-blurred dark mask covering that area with padding, and multiply the background by `(1 - mask * darkness)` before rendering text on top.
|
||||
|
||||
```python
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
def apply_text_backdrop(canvas, glyphs, padding=80, darkness=0.75):
|
||||
"""Darken the background behind text for readability.
|
||||
|
||||
Call AFTER rendering background, BEFORE rendering text.
|
||||
|
||||
Args:
|
||||
canvas: (VH, VW, 3) uint8 background
|
||||
glyphs: list of {"x": float, "y": float, ...} glyph positions
|
||||
padding: pixel padding around text bounding box
|
||||
darkness: 0.0 = no darkening, 1.0 = fully black
|
||||
Returns:
|
||||
darkened canvas (uint8)
|
||||
"""
|
||||
if not glyphs:
|
||||
return canvas
|
||||
xs = [g['x'] for g in glyphs]
|
||||
ys = [g['y'] for g in glyphs]
|
||||
x0 = max(0, int(min(xs)) - padding)
|
||||
y0 = max(0, int(min(ys)) - padding)
|
||||
x1 = min(VW, int(max(xs)) + padding + 50) # extra for char width
|
||||
y1 = min(VH, int(max(ys)) + padding + 60) # extra for char height
|
||||
|
||||
# Soft dark mask with gaussian blur for feathered edges
|
||||
mask = np.zeros((VH, VW), dtype=np.float32)
|
||||
mask[y0:y1, x0:x1] = 1.0
|
||||
mask = gaussian_filter(mask, sigma=padding * 0.6)
|
||||
|
||||
factor = 1.0 - mask * darkness
|
||||
return (canvas.astype(np.float32) * factor[:, :, np.newaxis]).astype(np.uint8)
|
||||
```
|
||||
|
||||
### Usage in render pipeline
|
||||
|
||||
Insert between background rendering and text rendering:
|
||||
|
||||
```python
|
||||
# 1. Render background (multi-grid ASCII effects)
|
||||
bg = render_background(cfg, t)
|
||||
|
||||
# 2. Darken behind text region
|
||||
bg = apply_text_backdrop(bg, frame_glyphs, padding=80, darkness=0.75)
|
||||
|
||||
# 3. Render text on top (now readable against dark backdrop)
|
||||
bg = text_renderer.render(bg, frame_glyphs, color=(255, 255, 255))
|
||||
```
|
||||
|
||||
Combine with **reverse vignette** (see shaders.md) for scenes where text is always centered — the reverse vignette provides a persistent center-dark zone, while the backdrop handles per-frame glyph positions.
|
||||
|
||||
## External Layout Oracle Pattern
|
||||
|
||||
For text-heavy videos where text needs to dynamically reflow around obstacles (shapes, icons, other text), use an external layout engine to pre-compute glyph positions and feed them into the Python renderer via JSON.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Layout Engine (browser/Node.js) → layouts.json → Python ASCII Renderer
|
||||
↑ ↑
|
||||
Computes per-frame Reads glyph positions,
|
||||
glyph (x,y) positions renders as ASCII chars
|
||||
with obstacle-aware reflow with full effect pipeline
|
||||
```
|
||||
|
||||
### JSON interchange format
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"canvas_width": 1080, "canvas_height": 1080,
|
||||
"fps": 24, "total_frames": 1248,
|
||||
"fonts": {
|
||||
"body": {"charW": 12.04, "charH": 24, "fontSize": 20},
|
||||
"hero": {"charW": 24.08, "charH": 48, "fontSize": 40}
|
||||
}
|
||||
},
|
||||
"scenes": [
|
||||
{
|
||||
"id": "scene_name",
|
||||
"start_frame": 0, "end_frame": 96,
|
||||
"frames": {
|
||||
"0": {
|
||||
"glyphs": [
|
||||
{"char": "H", "x": 287.1, "y": 400.0, "alpha": 1.0},
|
||||
{"char": "e", "x": 311.2, "y": 400.0, "alpha": 1.0}
|
||||
],
|
||||
"obstacles": [
|
||||
{"type": "circle", "cx": 540, "cy": 540, "r": 80},
|
||||
{"type": "rect", "x": 300, "y": 500, "w": 120, "h": 80}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### When to use
|
||||
|
||||
- Text that dynamically reflows around moving objects
|
||||
- Per-glyph animation (reveal, scatter, physics)
|
||||
- Variable typography that needs precise measurement
|
||||
- Any case where Python's Pillow text layout is insufficient
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- Static centered text (just use PIL `draw.text()` directly)
|
||||
- Text that only fades in/out without spatial animation
|
||||
- Simple typewriter effects (handle in Python with a character counter)
|
||||
|
||||
### Running the oracle
|
||||
|
||||
Use Playwright to run the layout engine in a headless browser:
|
||||
|
||||
```javascript
|
||||
// extract.mjs
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`file://${oraclePath}`);
|
||||
await page.waitForFunction(() => window.__ORACLE_DONE__ === true, null, { timeout: 60000 });
|
||||
const result = await page.evaluate(() => window.__ORACLE_RESULT__);
|
||||
writeFileSync('layouts.json', JSON.stringify(result));
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
### Consuming in Python
|
||||
|
||||
```python
|
||||
# In the renderer, map pixel positions to the canvas:
|
||||
for glyph in frame_data['glyphs']:
|
||||
char, px, py = glyph['char'], glyph['x'], glyph['y']
|
||||
alpha = glyph.get('alpha', 1.0)
|
||||
# Render using PIL draw.text() at exact pixel position
|
||||
draw.text((px, py), char, fill=(int(255*alpha),)*3, font=font)
|
||||
```
|
||||
|
||||
Obstacles from the JSON can also be rendered as glowing ASCII shapes (circles, rectangles) to visualize the reflow zones.
|
||||
|
||||
@@ -834,6 +834,39 @@ def sh_vignette(c, s=0.22):
|
||||
return np.clip(c * _vig_cache[k][:,:,None], 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
#### Reverse Vignette
|
||||
|
||||
Inverted vignette: darkens the **center** and leaves edges bright. Useful when text is centered over busy backgrounds — creates a natural dark zone for readability without a hard-edged box.
|
||||
|
||||
Combine with `apply_text_backdrop()` (see composition.md) for per-frame glyph-aware darkening.
|
||||
|
||||
```python
|
||||
_rvignette_cache = {}
|
||||
|
||||
def sh_reverse_vignette(c, strength=0.5):
|
||||
"""Center darkening, edge brightening. Cached."""
|
||||
k = ('rv', c.shape[0], c.shape[1], round(strength, 2))
|
||||
if k not in _rvignette_cache:
|
||||
h, w = c.shape[:2]
|
||||
Y = np.linspace(-1, 1, h)[:, None]
|
||||
X = np.linspace(-1, 1, w)[None, :]
|
||||
d = np.sqrt(X**2 + Y**2)
|
||||
# Invert: bright at edges, dark at center
|
||||
mask = np.clip(1.0 - (1.0 - d * 0.7) * strength, 0.2, 1.0)
|
||||
_rvignette_cache[k] = mask[:, :, np.newaxis].astype(np.float32)
|
||||
return np.clip(c.astype(np.float32) * _rvignette_cache[k], 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
| Param | Default | Effect |
|
||||
|-------|---------|--------|
|
||||
| `strength` | 0.5 | 0 = no effect, 1.0 = center nearly black |
|
||||
|
||||
Add to ShaderChain dispatch:
|
||||
```python
|
||||
elif name == "reverse_vignette":
|
||||
return sh_reverse_vignette(canvas, kwargs.get("strength", 0.5))
|
||||
```
|
||||
|
||||
#### Contrast
|
||||
```python
|
||||
def sh_contrast(c, factor=1.3):
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
| Random dark holes in output | Font missing Unicode glyphs | Validate palettes at init |
|
||||
| Audio-visual desync | Frame timing accumulation | Use integer frame counter, compute t fresh each frame |
|
||||
| Single-color flat output | Hue field shape mismatch | Ensure h,s,v arrays all (rows,cols) before hsv2rgb |
|
||||
| Text unreadable over busy bg | No contrast between text and background | Use `apply_text_backdrop()` (composition.md) + `reverse_vignette` shader (shaders.md) |
|
||||
| Text garbled/mirrored | Kaleidoscope or mirror shader applied to text scene | **Never apply kaleidoscope, mirror_h/v/quad/diag to scenes with readable text** — radial folding destroys legibility. Apply these only to background layers or text-free scenes |
|
||||
|
||||
Common bugs, gotchas, and platform-specific issues encountered during ASCII video development.
|
||||
|
||||
|
||||
@@ -643,3 +643,353 @@ class TestMatrixEncryptedSendFallback:
|
||||
assert fake_client.room_send.await_count == 2
|
||||
second_call = fake_client.room_send.await_args_list[1]
|
||||
assert second_call.kwargs.get("ignore_unverified_devices") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2EE: Auto-trust devices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixAutoTrustDevices:
|
||||
def test_auto_trust_verifies_unverified_devices(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
# DeviceStore.__iter__ yields OlmDevice objects directly.
|
||||
device_a = MagicMock()
|
||||
device_a.device_id = "DEVICE_A"
|
||||
device_a.verified = False
|
||||
device_b = MagicMock()
|
||||
device_b.device_id = "DEVICE_B"
|
||||
device_b.verified = True # already trusted
|
||||
device_c = MagicMock()
|
||||
device_c.device_id = "DEVICE_C"
|
||||
device_c.verified = False
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.device_id = "OWN_DEVICE"
|
||||
fake_client.verify_device = MagicMock()
|
||||
|
||||
# Simulate DeviceStore iteration (yields OlmDevice objects)
|
||||
fake_client.device_store = MagicMock()
|
||||
fake_client.device_store.__iter__ = MagicMock(
|
||||
return_value=iter([device_a, device_b, device_c])
|
||||
)
|
||||
|
||||
adapter._client = fake_client
|
||||
adapter._auto_trust_devices()
|
||||
|
||||
# Should have verified device_a and device_c (not device_b, already verified)
|
||||
assert fake_client.verify_device.call_count == 2
|
||||
verified_devices = [call.args[0] for call in fake_client.verify_device.call_args_list]
|
||||
assert device_a in verified_devices
|
||||
assert device_c in verified_devices
|
||||
assert device_b not in verified_devices
|
||||
|
||||
def test_auto_trust_skips_own_device(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
own_device = MagicMock()
|
||||
own_device.device_id = "MY_DEVICE"
|
||||
own_device.verified = False
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.device_id = "MY_DEVICE"
|
||||
fake_client.verify_device = MagicMock()
|
||||
|
||||
fake_client.device_store = MagicMock()
|
||||
fake_client.device_store.__iter__ = MagicMock(
|
||||
return_value=iter([own_device])
|
||||
)
|
||||
|
||||
adapter._client = fake_client
|
||||
adapter._auto_trust_devices()
|
||||
|
||||
fake_client.verify_device.assert_not_called()
|
||||
|
||||
def test_auto_trust_handles_missing_device_store(self):
|
||||
adapter = _make_adapter()
|
||||
fake_client = MagicMock(spec=[]) # empty spec — no attributes
|
||||
adapter._client = fake_client
|
||||
# Should not raise
|
||||
adapter._auto_trust_devices()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2EE: MegolmEvent key request + buffering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixMegolmEventHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_megolm_event_requests_room_key_and_buffers(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._startup_ts = 0.0
|
||||
adapter._dm_rooms = {}
|
||||
|
||||
fake_megolm = MagicMock()
|
||||
fake_megolm.sender = "@alice:example.org"
|
||||
fake_megolm.event_id = "$encrypted_event"
|
||||
fake_megolm.server_timestamp = 9999999999000 # future
|
||||
fake_megolm.session_id = "SESSION123"
|
||||
|
||||
fake_room = MagicMock()
|
||||
fake_room.room_id = "!room:example.org"
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.request_room_key = AsyncMock(return_value=MagicMock())
|
||||
adapter._client = fake_client
|
||||
|
||||
# Create a MegolmEvent class for isinstance check
|
||||
fake_nio = MagicMock()
|
||||
FakeMegolmEvent = type("MegolmEvent", (), {})
|
||||
fake_megolm.__class__ = FakeMegolmEvent
|
||||
fake_nio.MegolmEvent = FakeMegolmEvent
|
||||
|
||||
with patch.dict("sys.modules", {"nio": fake_nio}):
|
||||
await adapter._on_room_message(fake_room, fake_megolm)
|
||||
|
||||
# Should have requested the room key
|
||||
fake_client.request_room_key.assert_awaited_once_with(fake_megolm)
|
||||
|
||||
# Should have buffered the event
|
||||
assert len(adapter._pending_megolm) == 1
|
||||
room, event, ts = adapter._pending_megolm[0]
|
||||
assert room is fake_room
|
||||
assert event is fake_megolm
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_megolm_buffer_capped(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._startup_ts = 0.0
|
||||
adapter._dm_rooms = {}
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.request_room_key = AsyncMock(return_value=MagicMock())
|
||||
adapter._client = fake_client
|
||||
|
||||
FakeMegolmEvent = type("MegolmEvent", (), {})
|
||||
fake_nio = MagicMock()
|
||||
fake_nio.MegolmEvent = FakeMegolmEvent
|
||||
|
||||
# Fill the buffer past max
|
||||
from gateway.platforms.matrix import _MAX_PENDING_EVENTS
|
||||
with patch.dict("sys.modules", {"nio": fake_nio}):
|
||||
for i in range(_MAX_PENDING_EVENTS + 10):
|
||||
evt = MagicMock()
|
||||
evt.__class__ = FakeMegolmEvent
|
||||
evt.sender = "@alice:example.org"
|
||||
evt.event_id = f"$event_{i}"
|
||||
evt.server_timestamp = 9999999999000
|
||||
evt.session_id = f"SESSION_{i}"
|
||||
room = MagicMock()
|
||||
room.room_id = "!room:example.org"
|
||||
await adapter._on_room_message(room, evt)
|
||||
|
||||
assert len(adapter._pending_megolm) == _MAX_PENDING_EVENTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2EE: Retry pending decryptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixRetryPendingDecryptions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_decryption_routes_to_text_handler(self):
|
||||
import time as _time
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._startup_ts = 0.0
|
||||
adapter._dm_rooms = {}
|
||||
|
||||
# Create types
|
||||
FakeMegolmEvent = type("MegolmEvent", (), {})
|
||||
FakeRoomMessageText = type("RoomMessageText", (), {})
|
||||
|
||||
decrypted_event = MagicMock()
|
||||
decrypted_event.__class__ = FakeRoomMessageText
|
||||
|
||||
fake_megolm = MagicMock()
|
||||
fake_megolm.__class__ = FakeMegolmEvent
|
||||
fake_megolm.event_id = "$encrypted"
|
||||
|
||||
fake_room = MagicMock()
|
||||
now = _time.time()
|
||||
|
||||
adapter._pending_megolm = [(fake_room, fake_megolm, now)]
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.decrypt_event = MagicMock(return_value=decrypted_event)
|
||||
adapter._client = fake_client
|
||||
|
||||
fake_nio = MagicMock()
|
||||
fake_nio.MegolmEvent = FakeMegolmEvent
|
||||
fake_nio.RoomMessageText = FakeRoomMessageText
|
||||
fake_nio.RoomMessageImage = type("RoomMessageImage", (), {})
|
||||
fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {})
|
||||
fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {})
|
||||
fake_nio.RoomMessageFile = type("RoomMessageFile", (), {})
|
||||
|
||||
with patch.dict("sys.modules", {"nio": fake_nio}):
|
||||
with patch.object(adapter, "_on_room_message", AsyncMock()) as mock_handler:
|
||||
await adapter._retry_pending_decryptions()
|
||||
mock_handler.assert_awaited_once_with(fake_room, decrypted_event)
|
||||
|
||||
# Buffer should be empty now
|
||||
assert len(adapter._pending_megolm) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_still_undecryptable_stays_in_buffer(self):
|
||||
import time as _time
|
||||
|
||||
adapter = _make_adapter()
|
||||
|
||||
FakeMegolmEvent = type("MegolmEvent", (), {})
|
||||
|
||||
fake_megolm = MagicMock()
|
||||
fake_megolm.__class__ = FakeMegolmEvent
|
||||
fake_megolm.event_id = "$still_encrypted"
|
||||
|
||||
now = _time.time()
|
||||
adapter._pending_megolm = [(MagicMock(), fake_megolm, now)]
|
||||
|
||||
fake_client = MagicMock()
|
||||
# decrypt_event raises when key is still missing
|
||||
fake_client.decrypt_event = MagicMock(side_effect=Exception("missing key"))
|
||||
adapter._client = fake_client
|
||||
|
||||
fake_nio = MagicMock()
|
||||
fake_nio.MegolmEvent = FakeMegolmEvent
|
||||
|
||||
with patch.dict("sys.modules", {"nio": fake_nio}):
|
||||
await adapter._retry_pending_decryptions()
|
||||
|
||||
assert len(adapter._pending_megolm) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_events_dropped(self):
|
||||
import time as _time
|
||||
|
||||
adapter = _make_adapter()
|
||||
|
||||
from gateway.platforms.matrix import _PENDING_EVENT_TTL
|
||||
|
||||
fake_megolm = MagicMock()
|
||||
fake_megolm.event_id = "$old_event"
|
||||
fake_megolm.__class__ = type("MegolmEvent", (), {})
|
||||
|
||||
# Timestamp well past TTL
|
||||
old_ts = _time.time() - _PENDING_EVENT_TTL - 60
|
||||
adapter._pending_megolm = [(MagicMock(), fake_megolm, old_ts)]
|
||||
|
||||
fake_client = MagicMock()
|
||||
adapter._client = fake_client
|
||||
|
||||
fake_nio = MagicMock()
|
||||
fake_nio.MegolmEvent = type("MegolmEvent", (), {})
|
||||
|
||||
with patch.dict("sys.modules", {"nio": fake_nio}):
|
||||
await adapter._retry_pending_decryptions()
|
||||
|
||||
# Should have been dropped
|
||||
assert len(adapter._pending_megolm) == 0
|
||||
# Should NOT have tried to decrypt
|
||||
fake_client.decrypt_event.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_event_routes_to_media_handler(self):
|
||||
import time as _time
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._startup_ts = 0.0
|
||||
|
||||
FakeMegolmEvent = type("MegolmEvent", (), {})
|
||||
FakeRoomMessageImage = type("RoomMessageImage", (), {})
|
||||
|
||||
decrypted_image = MagicMock()
|
||||
decrypted_image.__class__ = FakeRoomMessageImage
|
||||
|
||||
fake_megolm = MagicMock()
|
||||
fake_megolm.__class__ = FakeMegolmEvent
|
||||
fake_megolm.event_id = "$encrypted_image"
|
||||
|
||||
fake_room = MagicMock()
|
||||
now = _time.time()
|
||||
adapter._pending_megolm = [(fake_room, fake_megolm, now)]
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.decrypt_event = MagicMock(return_value=decrypted_image)
|
||||
adapter._client = fake_client
|
||||
|
||||
fake_nio = MagicMock()
|
||||
fake_nio.MegolmEvent = FakeMegolmEvent
|
||||
fake_nio.RoomMessageText = type("RoomMessageText", (), {})
|
||||
fake_nio.RoomMessageImage = FakeRoomMessageImage
|
||||
fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {})
|
||||
fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {})
|
||||
fake_nio.RoomMessageFile = type("RoomMessageFile", (), {})
|
||||
|
||||
with patch.dict("sys.modules", {"nio": fake_nio}):
|
||||
with patch.object(adapter, "_on_room_message_media", AsyncMock()) as mock_media:
|
||||
await adapter._retry_pending_decryptions()
|
||||
mock_media.assert_awaited_once_with(fake_room, decrypted_image)
|
||||
|
||||
assert len(adapter._pending_megolm) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2EE: Key export / import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixKeyExportImport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_exports_keys(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._encryption = True
|
||||
adapter._sync_task = None
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.olm = object()
|
||||
fake_client.export_keys = AsyncMock()
|
||||
fake_client.close = AsyncMock()
|
||||
adapter._client = fake_client
|
||||
|
||||
from gateway.platforms.matrix import _KEY_EXPORT_FILE, _KEY_EXPORT_PASSPHRASE
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
fake_client.export_keys.assert_awaited_once_with(
|
||||
str(_KEY_EXPORT_FILE), _KEY_EXPORT_PASSPHRASE,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_handles_export_failure(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._encryption = True
|
||||
adapter._sync_task = None
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.olm = object()
|
||||
fake_client.export_keys = AsyncMock(side_effect=Exception("export failed"))
|
||||
fake_client.close = AsyncMock()
|
||||
adapter._client = fake_client
|
||||
|
||||
# Should not raise
|
||||
await adapter.disconnect()
|
||||
assert adapter._client is None # still cleaned up
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_skips_export_when_no_encryption(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._encryption = False
|
||||
adapter._sync_task = None
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.close = AsyncMock()
|
||||
adapter._client = fake_client
|
||||
|
||||
await adapter.disconnect()
|
||||
# Should not have tried to export
|
||||
assert not hasattr(fake_client, "export_keys") or \
|
||||
not fake_client.export_keys.called
|
||||
|
||||
@@ -212,6 +212,49 @@ class TestSessionHygieneWarnThreshold:
|
||||
assert post_compress_tokens < warn_threshold
|
||||
|
||||
|
||||
class TestCompressionWarnRateLimit:
|
||||
"""Compression warning messages must be rate-limited per chat_id."""
|
||||
|
||||
def _make_runner(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
with patch("gateway.run.load_gateway_config"), \
|
||||
patch("gateway.run.SessionStore"), \
|
||||
patch("gateway.run.DeliveryRouter"):
|
||||
from gateway.run import GatewayRunner
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._compression_warn_sent = {}
|
||||
runner._compression_warn_cooldown = 3600
|
||||
return runner
|
||||
|
||||
def test_first_warn_is_sent(self):
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
last = runner._compression_warn_sent.get("chat:1", 0)
|
||||
assert now - last >= runner._compression_warn_cooldown
|
||||
|
||||
def test_second_warn_suppressed_within_cooldown(self):
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
runner._compression_warn_sent["chat:1"] = now - 60 # 1 minute ago
|
||||
last = runner._compression_warn_sent.get("chat:1", 0)
|
||||
assert now - last < runner._compression_warn_cooldown
|
||||
|
||||
def test_warn_allowed_after_cooldown(self):
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
runner._compression_warn_sent["chat:1"] = now - 3601 # just past cooldown
|
||||
last = runner._compression_warn_sent.get("chat:1", 0)
|
||||
assert now - last >= runner._compression_warn_cooldown
|
||||
|
||||
def test_rate_limit_is_per_chat(self):
|
||||
"""Rate-limiting one chat must not suppress warnings for another."""
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
runner._compression_warn_sent["chat:1"] = now - 60 # suppressed
|
||||
last_other = runner._compression_warn_sent.get("chat:2", 0)
|
||||
assert now - last_other >= runner._compression_warn_cooldown
|
||||
|
||||
|
||||
class TestEstimatedTokenThreshold:
|
||||
"""Verify that hygiene thresholds are always below the model's context
|
||||
limit — for both actual and estimated token counts.
|
||||
|
||||
@@ -60,6 +60,7 @@ def _make_runner(platform: Platform, config: GatewayConfig):
|
||||
runner.adapters = {platform: adapter}
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.pairing_store.is_approved.return_value = False
|
||||
runner.pairing_store._is_rate_limited.return_value = False
|
||||
return runner, adapter
|
||||
|
||||
|
||||
@@ -142,6 +143,56 @@ async def test_unauthorized_whatsapp_dm_can_be_ignored(monkeypatch):
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limited_user_gets_no_response(monkeypatch):
|
||||
"""When a user is already rate-limited, pairing messages are silently ignored."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.WHATSAPP, config)
|
||||
runner.pairing_store._is_rate_limited.return_value = True
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(
|
||||
Platform.WHATSAPP,
|
||||
"15551234567@s.whatsapp.net",
|
||||
"15551234567@s.whatsapp.net",
|
||||
)
|
||||
)
|
||||
|
||||
assert result is None
|
||||
runner.pairing_store.generate_code.assert_not_called()
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_message_records_rate_limit(monkeypatch):
|
||||
"""After sending a 'too many requests' rejection, rate limit is recorded
|
||||
so subsequent messages are silently ignored."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.WHATSAPP, config)
|
||||
runner.pairing_store.generate_code.return_value = None # triggers rejection
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(
|
||||
Platform.WHATSAPP,
|
||||
"15551234567@s.whatsapp.net",
|
||||
"15551234567@s.whatsapp.net",
|
||||
)
|
||||
)
|
||||
|
||||
assert result is None
|
||||
adapter.send.assert_awaited_once()
|
||||
assert "Too many" in adapter.send.await_args.args[1]
|
||||
runner.pairing_store._record_rate_limit.assert_called_once_with(
|
||||
"whatsapp", "15551234567@s.whatsapp.net"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_ignore_suppresses_pairing_reply(monkeypatch):
|
||||
_clear_auth_env(monkeypatch)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for subprocess.run() timeout coverage in CLI utilities."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Parameterise over every CLI module that calls subprocess.run
|
||||
_CLI_MODULES = [
|
||||
"hermes_cli/doctor.py",
|
||||
"hermes_cli/status.py",
|
||||
"hermes_cli/clipboard.py",
|
||||
"hermes_cli/banner.py",
|
||||
]
|
||||
|
||||
|
||||
def _subprocess_run_calls(filepath: str) -> list[dict]:
|
||||
"""Parse a Python file and return info about subprocess.run() calls."""
|
||||
source = Path(filepath).read_text()
|
||||
tree = ast.parse(source, filename=filepath)
|
||||
calls = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if (isinstance(func, ast.Attribute) and func.attr == "run"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id == "subprocess"):
|
||||
has_timeout = any(kw.arg == "timeout" for kw in node.keywords)
|
||||
calls.append({"line": node.lineno, "has_timeout": has_timeout})
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filepath", _CLI_MODULES)
|
||||
def test_all_subprocess_run_calls_have_timeout(filepath):
|
||||
"""Every subprocess.run() call in CLI modules must specify a timeout."""
|
||||
if not Path(filepath).exists():
|
||||
pytest.skip(f"{filepath} not found")
|
||||
calls = _subprocess_run_calls(filepath)
|
||||
missing = [c for c in calls if not c["has_timeout"]]
|
||||
assert not missing, (
|
||||
f"{filepath} has subprocess.run() without timeout at "
|
||||
f"line(s): {[c['line'] for c in missing]}"
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Tests for Honcho config profile isolation.
|
||||
|
||||
Verifies that each Hermes profile writes to its own instance-local
|
||||
honcho.json ($HERMES_HOME/honcho.json) rather than the shared global
|
||||
~/.honcho/config.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_integration.cli import (
|
||||
_config_path,
|
||||
_local_config_path,
|
||||
_read_config,
|
||||
_write_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Create an isolated HERMES_HOME + real home for testing."""
|
||||
hermes_home = tmp_path / "profile_a"
|
||||
hermes_home.mkdir()
|
||||
global_dir = tmp_path / "home" / ".honcho"
|
||||
global_dir.mkdir(parents=True)
|
||||
global_config = global_dir / "config.json"
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home"))
|
||||
# GLOBAL_CONFIG_PATH is a module-level constant cached at import time,
|
||||
# so we must patch it in both the defining module and the importing module.
|
||||
import honcho_integration.client as _client_mod
|
||||
import honcho_integration.cli as _cli_mod
|
||||
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config)
|
||||
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config)
|
||||
|
||||
return {
|
||||
"hermes_home": hermes_home,
|
||||
"global_config": global_config,
|
||||
"local_config": hermes_home / "honcho.json",
|
||||
}
|
||||
|
||||
|
||||
class TestLocalConfigPath:
|
||||
"""_local_config_path always returns $HERMES_HOME/honcho.json."""
|
||||
|
||||
def test_returns_hermes_home_path(self, isolated_home):
|
||||
assert _local_config_path() == isolated_home["local_config"]
|
||||
|
||||
def test_differs_from_global(self, isolated_home):
|
||||
from honcho_integration.client import GLOBAL_CONFIG_PATH
|
||||
assert _local_config_path() != GLOBAL_CONFIG_PATH
|
||||
|
||||
|
||||
class TestWriteConfigIsolation:
|
||||
"""_write_config defaults to the instance-local path."""
|
||||
|
||||
def test_write_creates_local_file(self, isolated_home):
|
||||
cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}}
|
||||
_write_config(cfg)
|
||||
|
||||
assert isolated_home["local_config"].exists()
|
||||
written = json.loads(isolated_home["local_config"].read_text())
|
||||
assert written["apiKey"] == "test-key"
|
||||
|
||||
def test_write_does_not_touch_global(self, isolated_home):
|
||||
# Pre-populate global config
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"apiKey": "global-key"})
|
||||
)
|
||||
|
||||
cfg = {"apiKey": "profile-key"}
|
||||
_write_config(cfg)
|
||||
|
||||
# Global should be untouched
|
||||
global_data = json.loads(isolated_home["global_config"].read_text())
|
||||
assert global_data["apiKey"] == "global-key"
|
||||
|
||||
# Local should have the new value
|
||||
local_data = json.loads(isolated_home["local_config"].read_text())
|
||||
assert local_data["apiKey"] == "profile-key"
|
||||
|
||||
def test_explicit_path_override_still_works(self, isolated_home):
|
||||
custom = isolated_home["hermes_home"] / "custom.json"
|
||||
_write_config({"custom": True}, path=custom)
|
||||
assert custom.exists()
|
||||
assert not isolated_home["local_config"].exists()
|
||||
|
||||
|
||||
class TestReadConfigFallback:
|
||||
"""_read_config falls back to global when no local file exists."""
|
||||
|
||||
def test_reads_local_when_exists(self, isolated_home):
|
||||
isolated_home["local_config"].write_text(
|
||||
json.dumps({"source": "local"})
|
||||
)
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "local"
|
||||
|
||||
def test_falls_back_to_global(self, isolated_home):
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"source": "global"})
|
||||
)
|
||||
# No local file exists
|
||||
assert not isolated_home["local_config"].exists()
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "global"
|
||||
|
||||
def test_local_takes_priority_over_global(self, isolated_home):
|
||||
isolated_home["local_config"].write_text(
|
||||
json.dumps({"source": "local"})
|
||||
)
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"source": "global"})
|
||||
)
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "local"
|
||||
|
||||
|
||||
class TestMultiProfileIsolation:
|
||||
"""Two profiles writing config don't interfere with each other."""
|
||||
|
||||
def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
|
||||
profile_a = tmp_path / "profile_a"
|
||||
profile_b = tmp_path / "profile_b"
|
||||
profile_a.mkdir()
|
||||
profile_b.mkdir()
|
||||
|
||||
# Profile A writes its config
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_a))
|
||||
_write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}})
|
||||
|
||||
# Profile B writes its config
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_b))
|
||||
_write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}})
|
||||
|
||||
# Verify isolation
|
||||
a_data = json.loads((profile_a / "honcho.json").read_text())
|
||||
b_data = json.loads((profile_b / "honcho.json").read_text())
|
||||
|
||||
assert a_data["hosts"]["hermes"]["peerName"] == "alice"
|
||||
assert b_data["hosts"]["hermes"]["peerName"] == "bob"
|
||||
|
||||
def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch):
|
||||
"""First setup reads global config, writes to local."""
|
||||
home = tmp_path / "home"
|
||||
global_dir = home / ".honcho"
|
||||
global_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
import honcho_integration.client as _client_mod
|
||||
import honcho_integration.cli as _cli_mod
|
||||
global_cfg_path = global_dir / "config.json"
|
||||
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
|
||||
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
|
||||
|
||||
# Existing global config
|
||||
global_config = global_dir / "config.json"
|
||||
global_config.write_text(json.dumps({
|
||||
"apiKey": "shared-key",
|
||||
"hosts": {"hermes": {"workspace": "shared-ws"}},
|
||||
}))
|
||||
|
||||
profile = tmp_path / "new_profile"
|
||||
profile.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile))
|
||||
|
||||
# Read seeds from global
|
||||
cfg = _read_config()
|
||||
assert cfg["apiKey"] == "shared-key"
|
||||
|
||||
# Modify and write goes to local
|
||||
cfg["hosts"]["hermes"]["peerName"] = "new-user"
|
||||
_write_config(cfg)
|
||||
|
||||
local_config = profile / "honcho.json"
|
||||
assert local_config.exists()
|
||||
local_data = json.loads(local_config.read_text())
|
||||
assert local_data["hosts"]["hermes"]["peerName"] == "new-user"
|
||||
|
||||
# Global unchanged
|
||||
global_data = json.loads(global_config.read_text())
|
||||
assert "peerName" not in global_data["hosts"]["hermes"]
|
||||
@@ -81,6 +81,19 @@ class TestBuildAnthropicClient:
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["base_url"] == "https://custom.api.com"
|
||||
|
||||
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(
|
||||
"minimax-secret-123",
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["auth_token"] == "minimax-secret-123"
|
||||
assert "api_key" not in kwargs
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
}
|
||||
|
||||
|
||||
class TestReadClaudeCodeCredentials:
|
||||
def test_reads_valid_credentials(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for trajectory_compressor AsyncOpenAI event loop binding.
|
||||
|
||||
The AsyncOpenAI client was created once at __init__ time and stored as an
|
||||
instance attribute. When process_directory() calls asyncio.run() — which
|
||||
creates and closes a fresh event loop — the client's internal httpx
|
||||
transport remains bound to the now-closed loop. A second call to
|
||||
process_directory() would fail with "Event loop is closed".
|
||||
|
||||
The fix creates the AsyncOpenAI client lazily via _get_async_client() so
|
||||
each asyncio.run() gets a client bound to the current loop.
|
||||
"""
|
||||
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAsyncClientLazyCreation:
|
||||
"""trajectory_compressor.py — _get_async_client()"""
|
||||
|
||||
def test_async_client_none_after_init(self):
|
||||
"""async_client should be None after __init__ (not eagerly created)."""
|
||||
from trajectory_compressor import TrajectoryCompressor
|
||||
|
||||
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
||||
comp.config = MagicMock()
|
||||
comp.config.base_url = "https://api.example.com/v1"
|
||||
comp.config.api_key_env = "TEST_API_KEY"
|
||||
comp._use_call_llm = False
|
||||
comp.async_client = None
|
||||
comp._async_client_api_key = "test-key"
|
||||
|
||||
assert comp.async_client is None
|
||||
|
||||
def test_get_async_client_creates_new_client(self):
|
||||
"""_get_async_client() should create a fresh AsyncOpenAI instance."""
|
||||
from trajectory_compressor import TrajectoryCompressor
|
||||
|
||||
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
||||
comp.config = MagicMock()
|
||||
comp.config.base_url = "https://api.example.com/v1"
|
||||
comp._async_client_api_key = "test-key"
|
||||
comp.async_client = None
|
||||
|
||||
mock_async_openai = MagicMock()
|
||||
with patch("openai.AsyncOpenAI", mock_async_openai):
|
||||
client = comp._get_async_client()
|
||||
|
||||
mock_async_openai.assert_called_once_with(
|
||||
api_key="test-key",
|
||||
base_url="https://api.example.com/v1",
|
||||
)
|
||||
assert comp.async_client is not None
|
||||
|
||||
def test_get_async_client_creates_fresh_each_call(self):
|
||||
"""Each call to _get_async_client() creates a NEW client instance,
|
||||
so it binds to the current event loop."""
|
||||
from trajectory_compressor import TrajectoryCompressor
|
||||
|
||||
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
||||
comp.config = MagicMock()
|
||||
comp.config.base_url = "https://api.example.com/v1"
|
||||
comp._async_client_api_key = "test-key"
|
||||
comp.async_client = None
|
||||
|
||||
call_count = 0
|
||||
instances = []
|
||||
|
||||
def mock_constructor(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
instance = MagicMock()
|
||||
instances.append(instance)
|
||||
return instance
|
||||
|
||||
with patch("openai.AsyncOpenAI", side_effect=mock_constructor):
|
||||
client1 = comp._get_async_client()
|
||||
client2 = comp._get_async_client()
|
||||
|
||||
# Should have created two separate instances
|
||||
assert call_count == 2
|
||||
assert instances[0] is not instances[1]
|
||||
|
||||
|
||||
class TestSourceLineVerification:
|
||||
"""Verify the actual source has the lazy pattern applied."""
|
||||
|
||||
@staticmethod
|
||||
def _read_file() -> str:
|
||||
import os
|
||||
base = os.path.dirname(os.path.dirname(__file__))
|
||||
with open(os.path.join(base, "trajectory_compressor.py")) as f:
|
||||
return f.read()
|
||||
|
||||
def test_no_eager_async_openai_in_init(self):
|
||||
"""__init__ should NOT create AsyncOpenAI eagerly."""
|
||||
src = self._read_file()
|
||||
# The old pattern: self.async_client = AsyncOpenAI(...) in _init_summarizer
|
||||
# should not exist — only self.async_client = None
|
||||
lines = src.split("\n")
|
||||
for i, line in enumerate(lines, 1):
|
||||
if "self.async_client = AsyncOpenAI(" in line and "_get_async_client" not in lines[max(0,i-3):i+1]:
|
||||
# Allow it inside _get_async_client method
|
||||
# Check if we're inside _get_async_client by looking at context
|
||||
context = "\n".join(lines[max(0,i-10):i+1])
|
||||
if "_get_async_client" not in context:
|
||||
pytest.fail(
|
||||
f"Line {i}: AsyncOpenAI created eagerly outside _get_async_client()"
|
||||
)
|
||||
|
||||
def test_get_async_client_method_exists(self):
|
||||
"""_get_async_client method should exist."""
|
||||
src = self._read_file()
|
||||
assert "def _get_async_client(self)" in src
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for the Camofox browser backend."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.browser_camofox import (
|
||||
camofox_back,
|
||||
camofox_click,
|
||||
camofox_close,
|
||||
camofox_console,
|
||||
camofox_get_images,
|
||||
camofox_navigate,
|
||||
camofox_press,
|
||||
camofox_scroll,
|
||||
camofox_snapshot,
|
||||
camofox_type,
|
||||
camofox_vision,
|
||||
check_camofox_available,
|
||||
cleanup_all_camofox_sessions,
|
||||
is_camofox_mode,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxMode:
|
||||
def test_disabled_by_default(self, monkeypatch):
|
||||
monkeypatch.delenv("CAMOFOX_URL", raising=False)
|
||||
assert is_camofox_mode() is False
|
||||
|
||||
def test_enabled_when_url_set(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
assert is_camofox_mode() is True
|
||||
|
||||
def test_health_check_unreachable(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
|
||||
assert check_camofox_available() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_response(status=200, json_data=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.json.return_value = json_data or {}
|
||||
resp.content = b"\x89PNG\r\n\x1a\nfake"
|
||||
resp.raise_for_status = MagicMock()
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navigate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxNavigate:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab1", "url": "https://example.com"})
|
||||
|
||||
result = json.loads(camofox_navigate("https://example.com", task_id="t1"))
|
||||
assert result["success"] is True
|
||||
assert result["url"] == "https://example.com"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_navigates_existing_tab(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
# First call creates tab
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab2", "url": "https://a.com"})
|
||||
camofox_navigate("https://a.com", task_id="t2")
|
||||
|
||||
# Second call navigates
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://b.com"})
|
||||
result = json.loads(camofox_navigate("https://b.com", task_id="t2"))
|
||||
assert result["success"] is True
|
||||
assert result["url"] == "https://b.com"
|
||||
|
||||
def test_connection_error_returns_helpful_message(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
|
||||
result = json.loads(camofox_navigate("https://example.com", task_id="t_err"))
|
||||
assert result["success"] is False
|
||||
assert "Cannot connect" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxSnapshot:
|
||||
def test_no_session_returns_error(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_snapshot(task_id="no_such_task"))
|
||||
assert result["success"] is False
|
||||
assert "browser_navigate" in result["error"]
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.get")
|
||||
def test_returns_snapshot(self, mock_get, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
# Create session
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab3", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t3")
|
||||
|
||||
# Return snapshot
|
||||
mock_get.return_value = _mock_response(json_data={
|
||||
"snapshot": "- heading \"Test\" [e1]\n- button \"Submit\" [e2]",
|
||||
"refsCount": 2,
|
||||
})
|
||||
result = json.loads(camofox_snapshot(task_id="t3"))
|
||||
assert result["success"] is True
|
||||
assert "[e1]" in result["snapshot"]
|
||||
assert result["element_count"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click / Type / Scroll / Back / Press
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxInteractions:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_click(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab4", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t4")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://x.com"})
|
||||
result = json.loads(camofox_click("@e5", task_id="t4"))
|
||||
assert result["success"] is True
|
||||
assert result["clicked"] == "e5"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_type(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab5", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t5")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_type("@e3", "hello world", task_id="t5"))
|
||||
assert result["success"] is True
|
||||
assert result["typed"] == "hello world"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_scroll(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab6", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t6")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_scroll("down", task_id="t6"))
|
||||
assert result["success"] is True
|
||||
assert result["scrolled"] == "down"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_back(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab7", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t7")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://prev.com"})
|
||||
result = json.loads(camofox_back(task_id="t7"))
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_press(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab8", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t8")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_press("Enter", task_id="t8"))
|
||||
assert result["success"] is True
|
||||
assert result["pressed"] == "Enter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxClose:
|
||||
@patch("tools.browser_camofox.requests.delete")
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_close_session(self, mock_post, mock_delete, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab9", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t9")
|
||||
|
||||
mock_delete.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_close(task_id="t9"))
|
||||
assert result["success"] is True
|
||||
assert result["closed"] is True
|
||||
|
||||
def test_close_nonexistent_session(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_close(task_id="nonexistent"))
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Console (limited support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxConsole:
|
||||
def test_console_returns_empty_with_note(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_console(task_id="t_console"))
|
||||
assert result["success"] is True
|
||||
assert result["total_messages"] == 0
|
||||
assert "not available" in result["note"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxGetImages:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.get")
|
||||
def test_get_images(self, mock_get, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t10")
|
||||
|
||||
mock_get.return_value = _mock_response(json_data={
|
||||
"images": [{"src": "https://x.com/img.png", "alt": "Logo"}],
|
||||
})
|
||||
result = json.loads(camofox_get_images(task_id="t10"))
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
assert result["images"][0]["src"] == "https://x.com/img.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing integration — verify browser_tool routes to camofox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBrowserToolRouting:
|
||||
"""Verify that browser_tool.py delegates to camofox when CAMOFOX_URL is set."""
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_browser_navigate_routes_to_camofox(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab_rt", "url": "https://example.com"})
|
||||
|
||||
from tools.browser_tool import browser_navigate
|
||||
# Bypass SSRF check for test URL
|
||||
with patch("tools.browser_tool._is_safe_url", return_value=True):
|
||||
result = json.loads(browser_navigate("https://example.com", task_id="t_route"))
|
||||
assert result["success"] is True
|
||||
|
||||
def test_check_requirements_passes_with_camofox(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
from tools.browser_tool import check_browser_requirements
|
||||
assert check_browser_requirements() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxCleanup:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.delete")
|
||||
def test_cleanup_all(self, mock_delete, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab_c", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t_cleanup")
|
||||
|
||||
mock_delete.return_value = _mock_response(json_data={"ok": True})
|
||||
cleanup_all_camofox_sessions()
|
||||
|
||||
# Session should be gone
|
||||
result = json.loads(camofox_snapshot(task_id="t_cleanup"))
|
||||
assert result["success"] is False
|
||||
@@ -0,0 +1,496 @@
|
||||
"""Camofox browser backend — local anti-detection browser via REST API.
|
||||
|
||||
Camofox-browser is a self-hosted Node.js server wrapping Camoufox (Firefox
|
||||
fork with C++ fingerprint spoofing). It exposes a REST API that maps 1:1
|
||||
to our browser tool interface: accessibility snapshots with element refs,
|
||||
click/type/scroll by ref, screenshots, etc.
|
||||
|
||||
When ``CAMOFOX_URL`` is set (e.g. ``http://localhost:9377``), the browser
|
||||
tools route through this module instead of the ``agent-browser`` CLI.
|
||||
|
||||
Setup::
|
||||
|
||||
# Option 1: npm
|
||||
git clone https://github.com/jo-inc/camofox-browser && cd camofox-browser
|
||||
npm install && npm start # downloads Camoufox (~300MB) on first run
|
||||
|
||||
# Option 2: Docker
|
||||
docker run -p 9377:9377 jo-inc/camofox-browser
|
||||
|
||||
Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_TIMEOUT = 30 # seconds per HTTP request
|
||||
_SNAPSHOT_MAX_CHARS = 80_000 # camofox paginates at this limit
|
||||
|
||||
|
||||
def get_camofox_url() -> str:
|
||||
"""Return the configured Camofox server URL, or empty string."""
|
||||
return os.getenv("CAMOFOX_URL", "").rstrip("/")
|
||||
|
||||
|
||||
def is_camofox_mode() -> bool:
|
||||
"""True when Camofox backend is configured."""
|
||||
return bool(get_camofox_url())
|
||||
|
||||
|
||||
def check_camofox_available() -> bool:
|
||||
"""Verify the Camofox server is reachable."""
|
||||
url = get_camofox_url()
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
resp = requests.get(f"{url}/health", timeout=5)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session management
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps task_id -> {"user_id": str, "tab_id": str|None}
|
||||
_sessions: Dict[str, Dict[str, Any]] = {}
|
||||
_sessions_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Get or create a camofox session for the given task."""
|
||||
task_id = task_id or "default"
|
||||
with _sessions_lock:
|
||||
if task_id in _sessions:
|
||||
return _sessions[task_id]
|
||||
session = {
|
||||
"user_id": f"hermes_{uuid.uuid4().hex[:10]}",
|
||||
"tab_id": None,
|
||||
"session_key": f"task_{task_id[:16]}",
|
||||
}
|
||||
_sessions[task_id] = session
|
||||
return session
|
||||
|
||||
|
||||
def _ensure_tab(task_id: Optional[str], url: str = "about:blank") -> Dict[str, Any]:
|
||||
"""Ensure a tab exists for the session, creating one if needed."""
|
||||
session = _get_session(task_id)
|
||||
if session["tab_id"]:
|
||||
return session
|
||||
base = get_camofox_url()
|
||||
resp = requests.post(
|
||||
f"{base}/tabs",
|
||||
json={
|
||||
"userId": session["user_id"],
|
||||
"sessionKey": session["session_key"],
|
||||
"url": url,
|
||||
},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
session["tab_id"] = data.get("tabId")
|
||||
return session
|
||||
|
||||
|
||||
def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Remove and return session info."""
|
||||
task_id = task_id or "default"
|
||||
with _sessions_lock:
|
||||
return _sessions.pop(task_id, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _post(path: str, body: dict, timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
||||
"""POST JSON to camofox and return parsed response."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.post(url, json=body, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
||||
"""GET from camofox and return parsed response."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.get(url, params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get_raw(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> requests.Response:
|
||||
"""GET from camofox and return raw response (for binary data)."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.get(url, params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
|
||||
def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
||||
"""DELETE to camofox and return parsed response."""
|
||||
url = f"{get_camofox_url()}{path}"
|
||||
resp = requests.delete(url, json=body, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
"""Navigate to a URL via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
# Create tab with the target URL directly
|
||||
session = _ensure_tab(task_id, url)
|
||||
data = {"ok": True, "url": url}
|
||||
else:
|
||||
# Navigate existing tab
|
||||
data = _post(
|
||||
f"/tabs/{session['tab_id']}/navigate",
|
||||
{"userId": session["user_id"], "url": url},
|
||||
timeout=60,
|
||||
)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"url": data.get("url", url),
|
||||
"title": data.get("title", ""),
|
||||
})
|
||||
except requests.HTTPError as e:
|
||||
return json.dumps({"success": False, "error": f"Navigation failed: {e}"})
|
||||
except requests.ConnectionError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Cannot connect to Camofox at {get_camofox_url()}. "
|
||||
"Is the server running? Start with: npm start (in camofox-browser dir) "
|
||||
"or: docker run -p 9377:9377 jo-inc/camofox-browser",
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_snapshot(full: bool = False, task_id: Optional[str] = None,
|
||||
user_task: Optional[str] = None) -> str:
|
||||
"""Get accessibility tree snapshot from Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
data = _get(
|
||||
f"/tabs/{session['tab_id']}/snapshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
|
||||
snapshot = data.get("snapshot", "")
|
||||
refs_count = data.get("refsCount", 0)
|
||||
|
||||
# Apply same summarization logic as the main browser tool
|
||||
from tools.browser_tool import (
|
||||
SNAPSHOT_SUMMARIZE_THRESHOLD,
|
||||
_extract_relevant_content,
|
||||
_truncate_snapshot,
|
||||
)
|
||||
|
||||
if len(snapshot) > SNAPSHOT_SUMMARIZE_THRESHOLD:
|
||||
if user_task:
|
||||
snapshot = _extract_relevant_content(snapshot, user_task)
|
||||
else:
|
||||
snapshot = _truncate_snapshot(snapshot)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"snapshot": snapshot,
|
||||
"element_count": refs_count,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_click(ref: str, task_id: Optional[str] = None) -> str:
|
||||
"""Click an element by ref via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
# Strip @ prefix if present (our tool convention)
|
||||
clean_ref = ref.lstrip("@")
|
||||
|
||||
data = _post(
|
||||
f"/tabs/{session['tab_id']}/click",
|
||||
{"userId": session["user_id"], "ref": clean_ref},
|
||||
)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"clicked": clean_ref,
|
||||
"url": data.get("url", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
||||
"""Type text into an element by ref via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
clean_ref = ref.lstrip("@")
|
||||
|
||||
_post(
|
||||
f"/tabs/{session['tab_id']}/type",
|
||||
{"userId": session["user_id"], "ref": clean_ref, "text": text},
|
||||
)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"typed": text,
|
||||
"element": clean_ref,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str:
|
||||
"""Scroll the page via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
_post(
|
||||
f"/tabs/{session['tab_id']}/scroll",
|
||||
{"userId": session["user_id"], "direction": direction},
|
||||
)
|
||||
return json.dumps({"success": True, "scrolled": direction})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_back(task_id: Optional[str] = None) -> str:
|
||||
"""Navigate back via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
data = _post(
|
||||
f"/tabs/{session['tab_id']}/back",
|
||||
{"userId": session["user_id"]},
|
||||
)
|
||||
return json.dumps({"success": True, "url": data.get("url", "")})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_press(key: str, task_id: Optional[str] = None) -> str:
|
||||
"""Press a keyboard key via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
_post(
|
||||
f"/tabs/{session['tab_id']}/press",
|
||||
{"userId": session["user_id"], "key": key},
|
||||
)
|
||||
return json.dumps({"success": True, "pressed": key})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_close(task_id: Optional[str] = None) -> str:
|
||||
"""Close the browser session via Camofox."""
|
||||
try:
|
||||
session = _drop_session(task_id)
|
||||
if not session:
|
||||
return json.dumps({"success": True, "closed": True})
|
||||
|
||||
_delete(
|
||||
f"/sessions/{session['user_id']}",
|
||||
)
|
||||
return json.dumps({"success": True, "closed": True})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": True, "closed": True, "warning": str(e)})
|
||||
|
||||
|
||||
def camofox_get_images(task_id: Optional[str] = None) -> str:
|
||||
"""Get images on the current page via Camofox.
|
||||
|
||||
Extracts image information from the accessibility tree snapshot,
|
||||
since Camofox does not expose a dedicated /images endpoint.
|
||||
"""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
import re
|
||||
|
||||
data = _get(
|
||||
f"/tabs/{session['tab_id']}/snapshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
snapshot = data.get("snapshot", "")
|
||||
|
||||
# Parse img elements from the accessibility tree.
|
||||
# Format: img "alt text" or img "alt text" [eN]
|
||||
# URLs appear on /url: lines following img entries
|
||||
images = []
|
||||
lines = snapshot.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- img ") or stripped.startswith("img "):
|
||||
alt_match = re.search(r'img\s+"([^"]*)"', stripped)
|
||||
alt = alt_match.group(1) if alt_match else ""
|
||||
# Look for URL on the next line
|
||||
src = ""
|
||||
if i + 1 < len(lines):
|
||||
url_match = re.search(r'/url:\s*(\S+)', lines[i + 1].strip())
|
||||
if url_match:
|
||||
src = url_match.group(1)
|
||||
if alt or src:
|
||||
images.append({"src": src, "alt": alt})
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"images": images,
|
||||
"count": len(images),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_vision(question: str, annotate: bool = False,
|
||||
task_id: Optional[str] = None) -> str:
|
||||
"""Take a screenshot and analyze it with vision AI via Camofox."""
|
||||
try:
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."})
|
||||
|
||||
# Get screenshot as binary PNG
|
||||
resp = _get_raw(
|
||||
f"/tabs/{session['tab_id']}/screenshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
|
||||
# Save screenshot to cache
|
||||
from hermes_constants import get_hermes_home
|
||||
screenshots_dir = get_hermes_home() / "browser_screenshots"
|
||||
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
screenshot_path = str(screenshots_dir / f"browser_screenshot_{uuid.uuid4().hex[:8]}.png")
|
||||
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
# Encode for vision LLM
|
||||
img_b64 = base64.b64encode(resp.content).decode("utf-8")
|
||||
|
||||
# Also get annotated snapshot if requested
|
||||
annotation_context = ""
|
||||
if annotate:
|
||||
try:
|
||||
snap_data = _get(
|
||||
f"/tabs/{session['tab_id']}/snapshot",
|
||||
params={"userId": session["user_id"]},
|
||||
)
|
||||
annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{snap_data.get('snapshot', '')[:3000]}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send to vision LLM
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
vision_prompt = (
|
||||
f"Analyze this browser screenshot and answer: {question}"
|
||||
f"{annotation_context}"
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
_vision_timeout = int(_cfg.get("auxiliary", {}).get("vision", {}).get("timeout", 120))
|
||||
except Exception:
|
||||
_vision_timeout = 120
|
||||
|
||||
analysis = call_llm(
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": vision_prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{img_b64}",
|
||||
},
|
||||
},
|
||||
],
|
||||
}],
|
||||
task="vision",
|
||||
timeout=_vision_timeout,
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"analysis": analysis,
|
||||
"screenshot_path": screenshot_path,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str:
|
||||
"""Get console output — limited support in Camofox.
|
||||
|
||||
Camofox does not expose browser console logs via its REST API.
|
||||
Returns an empty result with a note.
|
||||
"""
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"console_messages": [],
|
||||
"js_errors": [],
|
||||
"total_messages": 0,
|
||||
"total_errors": 0,
|
||||
"note": "Console log capture is not available with the Camofox backend. "
|
||||
"Use browser_snapshot or browser_vision to inspect page state.",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cleanup_all_camofox_sessions() -> None:
|
||||
"""Close all active camofox sessions."""
|
||||
with _sessions_lock:
|
||||
sessions = list(_sessions.items())
|
||||
for task_id, session in sessions:
|
||||
try:
|
||||
_delete(f"/sessions/{session['user_id']}")
|
||||
except Exception:
|
||||
pass
|
||||
with _sessions_lock:
|
||||
_sessions.clear()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user